fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws - #1139

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze
Jul 16, 2026
Merged

fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws#1139
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Closes#1135.

Five transcript event paths mutated the rendered lines of entries that had scrolled above pi-tui's live viewport. Any such change made pi-tui's differential renderer hit its firstChanged < viewportTop path and emit ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the scroll position.

The invariant #1130 established for toggles should own all of these: the rendered prefix of off-screen entries must stay frozen.

The fix is a single render-layer freeze point plus two targeted guards:

  1. Render freezerenderTranscriptEntryMemoized checks whether the entry's first line sits above the live viewport (using fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback #1130's renderGeometry). If so, it serves the cached render matching the current width instead of re-rendering. The underlying entry state still updates — only the visual output is frozen. This covers the ticker, settle/ownership updates, thinking_complete replacement, and tool_progress/tool_output_delta appends.

  2. Splice guard — poll-card removal (tool_result fold for Read/StopBackgroundTask) gets an entryInLiveViewport check: splicing an off-screen entry shifts subsequent line numbers, which the freeze alone cannot prevent. The stale entry stays in place until the next session switch.

  3. Ticker skiprefreshRunningShellRunElapsed skips off-screen running cards, so it returns false when no visible card needs an update and the 1s interval stops automatically.

Verification

  • packages/cli: tsc --noEmit clean, npm test 465/465 (3 new), root npm run lint clean.
  • New behavioral tests through the real pi-tui renderer asserting no ESC[3J for: off-screen running-Bash ticker updates (2.5s wait past two ticks), off-screen shell-run settle, and off-screen thinking_complete.
  • Updated pi-tui-layout.test.ts: the "in-place edit above the top" sub-case now expects viewportTop to stay put (the freeze prevents the rendered change from reaching the shadow diff), matching the new invariant.

…ing 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.
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.
…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 merged commit 3f89ca0 into mainJul 16, 2026
3 checks passed
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-mutation-freeze 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): in-place mutations of off-screen entries still trigger scrollback-clearing redraws

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): freeze off-screen entry renders to prevent scrollback-clearing redraws - #1139

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze
Jul 16, 2026
Merged

fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws#1139
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Closes#1135.

Five transcript event paths mutated the rendered lines of entries that had scrolled above pi-tui's live viewport. Any such change made pi-tui's differential renderer hit its firstChanged < viewportTop path and emit ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the scroll position.

The invariant #1130 established for toggles should own all of these: the rendered prefix of off-screen entries must stay frozen.

The fix is a single render-layer freeze point plus two targeted guards:

  1. Render freezerenderTranscriptEntryMemoized checks whether the entry's first line sits above the live viewport (using fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback #1130's renderGeometry). If so, it serves the cached render matching the current width instead of re-rendering. The underlying entry state still updates — only the visual output is frozen. This covers the ticker, settle/ownership updates, thinking_complete replacement, and tool_progress/tool_output_delta appends.

  2. Splice guard — poll-card removal (tool_result fold for Read/StopBackgroundTask) gets an entryInLiveViewport check: splicing an off-screen entry shifts subsequent line numbers, which the freeze alone cannot prevent. The stale entry stays in place until the next session switch.

  3. Ticker skiprefreshRunningShellRunElapsed skips off-screen running cards, so it returns false when no visible card needs an update and the 1s interval stops automatically.

Verification

  • packages/cli: tsc --noEmit clean, npm test 465/465 (3 new), root npm run lint clean.
  • New behavioral tests through the real pi-tui renderer asserting no ESC[3J for: off-screen running-Bash ticker updates (2.5s wait past two ticks), off-screen shell-run settle, and off-screen thinking_complete.
  • Updated pi-tui-layout.test.ts: the "in-place edit above the top" sub-case now expects viewportTop to stay put (the freeze prevents the rendered change from reaching the shadow diff), matching the new invariant.

…ing 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.
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.
…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 merged commit 3f89ca0 into mainJul 16, 2026
3 checks passed
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-mutation-freeze 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): in-place mutations of off-screen entries still trigger scrollback-clearing redraws

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): freeze off-screen entry renders to prevent scrollback-clearing redraws - #1139

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze
Jul 16, 2026
Merged

fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws#1139
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Closes#1135.

Five transcript event paths mutated the rendered lines of entries that had scrolled above pi-tui's live viewport. Any such change made pi-tui's differential renderer hit its firstChanged < viewportTop path and emit ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the scroll position.

The invariant #1130 established for toggles should own all of these: the rendered prefix of off-screen entries must stay frozen.

The fix is a single render-layer freeze point plus two targeted guards:

  1. Render freezerenderTranscriptEntryMemoized checks whether the entry's first line sits above the live viewport (using fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback #1130's renderGeometry). If so, it serves the cached render matching the current width instead of re-rendering. The underlying entry state still updates — only the visual output is frozen. This covers the ticker, settle/ownership updates, thinking_complete replacement, and tool_progress/tool_output_delta appends.

  2. Splice guard — poll-card removal (tool_result fold for Read/StopBackgroundTask) gets an entryInLiveViewport check: splicing an off-screen entry shifts subsequent line numbers, which the freeze alone cannot prevent. The stale entry stays in place until the next session switch.

  3. Ticker skiprefreshRunningShellRunElapsed skips off-screen running cards, so it returns false when no visible card needs an update and the 1s interval stops automatically.

Verification

  • packages/cli: tsc --noEmit clean, npm test 465/465 (3 new), root npm run lint clean.
  • New behavioral tests through the real pi-tui renderer asserting no ESC[3J for: off-screen running-Bash ticker updates (2.5s wait past two ticks), off-screen shell-run settle, and off-screen thinking_complete.
  • Updated pi-tui-layout.test.ts: the "in-place edit above the top" sub-case now expects viewportTop to stay put (the freeze prevents the rendered change from reaching the shadow diff), matching the new invariant.

…ing 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.
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.
…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 merged commit 3f89ca0 into mainJul 16, 2026
3 checks passed
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-mutation-freeze 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): in-place mutations of off-screen entries still trigger scrollback-clearing redraws

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): freeze off-screen entry renders to prevent scrollback-clearing redraws - #1139

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze
Jul 16, 2026
Merged

fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws#1139
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Closes#1135.

Five transcript event paths mutated the rendered lines of entries that had scrolled above pi-tui's live viewport. Any such change made pi-tui's differential renderer hit its firstChanged < viewportTop path and emit ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the scroll position.

The invariant #1130 established for toggles should own all of these: the rendered prefix of off-screen entries must stay frozen.

The fix is a single render-layer freeze point plus two targeted guards:

  1. Render freezerenderTranscriptEntryMemoized checks whether the entry's first line sits above the live viewport (using fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback #1130's renderGeometry). If so, it serves the cached render matching the current width instead of re-rendering. The underlying entry state still updates — only the visual output is frozen. This covers the ticker, settle/ownership updates, thinking_complete replacement, and tool_progress/tool_output_delta appends.

  2. Splice guard — poll-card removal (tool_result fold for Read/StopBackgroundTask) gets an entryInLiveViewport check: splicing an off-screen entry shifts subsequent line numbers, which the freeze alone cannot prevent. The stale entry stays in place until the next session switch.

  3. Ticker skiprefreshRunningShellRunElapsed skips off-screen running cards, so it returns false when no visible card needs an update and the 1s interval stops automatically.

Verification

  • packages/cli: tsc --noEmit clean, npm test 465/465 (3 new), root npm run lint clean.
  • New behavioral tests through the real pi-tui renderer asserting no ESC[3J for: off-screen running-Bash ticker updates (2.5s wait past two ticks), off-screen shell-run settle, and off-screen thinking_complete.
  • Updated pi-tui-layout.test.ts: the "in-place edit above the top" sub-case now expects viewportTop to stay put (the freeze prevents the rendered change from reaching the shadow diff), matching the new invariant.

…ing 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.
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.
…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 merged commit 3f89ca0 into mainJul 16, 2026
3 checks passed
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-mutation-freeze 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): in-place mutations of off-screen entries still trigger scrollback-clearing redraws

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): freeze off-screen entry renders to prevent scrollback-clearing redraws - #1139

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze
Jul 16, 2026
Merged

fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws#1139
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Closes#1135.

Five transcript event paths mutated the rendered lines of entries that had scrolled above pi-tui's live viewport. Any such change made pi-tui's differential renderer hit its firstChanged < viewportTop path and emit ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the scroll position.

The invariant #1130 established for toggles should own all of these: the rendered prefix of off-screen entries must stay frozen.

The fix is a single render-layer freeze point plus two targeted guards:

  1. Render freezerenderTranscriptEntryMemoized checks whether the entry's first line sits above the live viewport (using fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback #1130's renderGeometry). If so, it serves the cached render matching the current width instead of re-rendering. The underlying entry state still updates — only the visual output is frozen. This covers the ticker, settle/ownership updates, thinking_complete replacement, and tool_progress/tool_output_delta appends.

  2. Splice guard — poll-card removal (tool_result fold for Read/StopBackgroundTask) gets an entryInLiveViewport check: splicing an off-screen entry shifts subsequent line numbers, which the freeze alone cannot prevent. The stale entry stays in place until the next session switch.

  3. Ticker skiprefreshRunningShellRunElapsed skips off-screen running cards, so it returns false when no visible card needs an update and the 1s interval stops automatically.

Verification

  • packages/cli: tsc --noEmit clean, npm test 465/465 (3 new), root npm run lint clean.
  • New behavioral tests through the real pi-tui renderer asserting no ESC[3J for: off-screen running-Bash ticker updates (2.5s wait past two ticks), off-screen shell-run settle, and off-screen thinking_complete.
  • Updated pi-tui-layout.test.ts: the "in-place edit above the top" sub-case now expects viewportTop to stay put (the freeze prevents the rendered change from reaching the shadow diff), matching the new invariant.

…ing 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.
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.
…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 merged commit 3f89ca0 into mainJul 16, 2026
3 checks passed
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-mutation-freeze 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): in-place mutations of off-screen entries still trigger scrollback-clearing redraws

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): freeze off-screen entry renders to prevent scrollback-clearing redraws - #1139

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze
Jul 16, 2026
Merged

fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws#1139
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Closes#1135.

Five transcript event paths mutated the rendered lines of entries that had scrolled above pi-tui's live viewport. Any such change made pi-tui's differential renderer hit its firstChanged < viewportTop path and emit ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the scroll position.

The invariant #1130 established for toggles should own all of these: the rendered prefix of off-screen entries must stay frozen.

The fix is a single render-layer freeze point plus two targeted guards:

  1. Render freezerenderTranscriptEntryMemoized checks whether the entry's first line sits above the live viewport (using fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback #1130's renderGeometry). If so, it serves the cached render matching the current width instead of re-rendering. The underlying entry state still updates — only the visual output is frozen. This covers the ticker, settle/ownership updates, thinking_complete replacement, and tool_progress/tool_output_delta appends.

  2. Splice guard — poll-card removal (tool_result fold for Read/StopBackgroundTask) gets an entryInLiveViewport check: splicing an off-screen entry shifts subsequent line numbers, which the freeze alone cannot prevent. The stale entry stays in place until the next session switch.

  3. Ticker skiprefreshRunningShellRunElapsed skips off-screen running cards, so it returns false when no visible card needs an update and the 1s interval stops automatically.

Verification

  • packages/cli: tsc --noEmit clean, npm test 465/465 (3 new), root npm run lint clean.
  • New behavioral tests through the real pi-tui renderer asserting no ESC[3J for: off-screen running-Bash ticker updates (2.5s wait past two ticks), off-screen shell-run settle, and off-screen thinking_complete.
  • Updated pi-tui-layout.test.ts: the "in-place edit above the top" sub-case now expects viewportTop to stay put (the freeze prevents the rendered change from reaching the shadow diff), matching the new invariant.

…ing 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.
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.
…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 merged commit 3f89ca0 into mainJul 16, 2026
3 checks passed
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-mutation-freeze 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): in-place mutations of off-screen entries still trigger scrollback-clearing redraws

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): freeze off-screen entry renders to prevent scrollback-clearing redraws - #1139

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze
Jul 16, 2026
Merged

fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws#1139
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Closes#1135.

Five transcript event paths mutated the rendered lines of entries that had scrolled above pi-tui's live viewport. Any such change made pi-tui's differential renderer hit its firstChanged < viewportTop path and emit ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the scroll position.

The invariant #1130 established for toggles should own all of these: the rendered prefix of off-screen entries must stay frozen.

The fix is a single render-layer freeze point plus two targeted guards:

  1. Render freezerenderTranscriptEntryMemoized checks whether the entry's first line sits above the live viewport (using fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback #1130's renderGeometry). If so, it serves the cached render matching the current width instead of re-rendering. The underlying entry state still updates — only the visual output is frozen. This covers the ticker, settle/ownership updates, thinking_complete replacement, and tool_progress/tool_output_delta appends.

  2. Splice guard — poll-card removal (tool_result fold for Read/StopBackgroundTask) gets an entryInLiveViewport check: splicing an off-screen entry shifts subsequent line numbers, which the freeze alone cannot prevent. The stale entry stays in place until the next session switch.

  3. Ticker skiprefreshRunningShellRunElapsed skips off-screen running cards, so it returns false when no visible card needs an update and the 1s interval stops automatically.

Verification

  • packages/cli: tsc --noEmit clean, npm test 465/465 (3 new), root npm run lint clean.
  • New behavioral tests through the real pi-tui renderer asserting no ESC[3J for: off-screen running-Bash ticker updates (2.5s wait past two ticks), off-screen shell-run settle, and off-screen thinking_complete.
  • Updated pi-tui-layout.test.ts: the "in-place edit above the top" sub-case now expects viewportTop to stay put (the freeze prevents the rendered change from reaching the shadow diff), matching the new invariant.

…ing 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.
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.
…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 merged commit 3f89ca0 into mainJul 16, 2026
3 checks passed
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-mutation-freeze 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): in-place mutations of off-screen entries still trigger scrollback-clearing redraws

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): freeze off-screen entry renders to prevent scrollback-clearing redraws - #1139

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze
Jul 16, 2026
Merged

fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws#1139
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-mutation-freeze

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

Closes#1135.

Five transcript event paths mutated the rendered lines of entries that had scrolled above pi-tui's live viewport. Any such change made pi-tui's differential renderer hit its firstChanged < viewportTop path and emit ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the scroll position.

The invariant #1130 established for toggles should own all of these: the rendered prefix of off-screen entries must stay frozen.

The fix is a single render-layer freeze point plus two targeted guards:

  1. Render freezerenderTranscriptEntryMemoized checks whether the entry's first line sits above the live viewport (using fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback #1130's renderGeometry). If so, it serves the cached render matching the current width instead of re-rendering. The underlying entry state still updates — only the visual output is frozen. This covers the ticker, settle/ownership updates, thinking_complete replacement, and tool_progress/tool_output_delta appends.

  2. Splice guard — poll-card removal (tool_result fold for Read/StopBackgroundTask) gets an entryInLiveViewport check: splicing an off-screen entry shifts subsequent line numbers, which the freeze alone cannot prevent. The stale entry stays in place until the next session switch.

  3. Ticker skiprefreshRunningShellRunElapsed skips off-screen running cards, so it returns false when no visible card needs an update and the 1s interval stops automatically.

Verification

  • packages/cli: tsc --noEmit clean, npm test 465/465 (3 new), root npm run lint clean.
  • New behavioral tests through the real pi-tui renderer asserting no ESC[3J for: off-screen running-Bash ticker updates (2.5s wait past two ticks), off-screen shell-run settle, and off-screen thinking_complete.
  • Updated pi-tui-layout.test.ts: the "in-place edit above the top" sub-case now expects viewportTop to stay put (the freeze prevents the rendered change from reaching the shadow diff), matching the new invariant.

…ing 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.
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.
…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 merged commit 3f89ca0 into mainJul 16, 2026
3 checks passed
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-mutation-freeze 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): in-place mutations of off-screen entries still trigger scrollback-clearing redraws

1 participant

@Astro-Han