diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fdd5ae758..a8cbb31c0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Fixed the `/btw` panel re-committing its frame to native scrollback on every update while the primary turn is still streaming: a live region that pins itself (an anchored HUD/panel such as `/btw`) no longer leaks its scrolled-off rows just because an unpinned transcript seam sits above it in the same frame ([#8793](https://github.com/can1357/oh-my-pi/issues/8793)). - Fixed Claude Code marketplace plugins ignoring the `enabledPlugins` switch in `~/.claude/settings.json` and `.claude/settings(.local).json`: a plugin turned off for a project no longer loads there, and a local-scope install enabled for a project loads even when its recorded `projectPath` is a different directory - Fixed task and eval subagents discovering newly added agent definitions while resolving their role aliases from stale startup settings. Subagent preflight now atomically reloads persisted settings before agent discovery while preserving live runtime overrides. - Fixed images returned by tools mounted under `xd://` rendering only as file links instead of inline terminal graphics. diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 51342ada5..cd355d4ef 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1332,6 +1332,12 @@ export class TUI extends Container { #previousWindow: string[] = []; #nativeScrollbackLiveRegionStart: number | undefined; #nativeScrollbackLiveRegionPinned = false; + // Start row of the topmost live region that pinned itself. The topmost seam + // governs the exactness boundary and the frame-wide pin policy, but a pinned + // region BELOW an unpinned seam (an anchored HUD/panel under a streaming + // transcript) still must never commit its rows to native scrollback. This is + // the ceiling no commit may cross, independent of the topmost seam's policy. + #nativeScrollbackPinnedBoundary: number | undefined; #fullRedrawCount = 0; // Caps how many inline images render as live graphics; older ones fall back // to text via a purge + full redraw. Cap is configured by the host app. @@ -1625,6 +1631,7 @@ export class TUI extends Container { width = Math.max(1, width); this.#nativeScrollbackLiveRegionStart = undefined; this.#nativeScrollbackLiveRegionPinned = false; + this.#nativeScrollbackPinnedBoundary = undefined; const children = this.children; const previousSegments = this.#frameSegments; const segments: FrameSegment[] = new Array(children.length); @@ -1705,9 +1712,18 @@ export class TUI extends Container { // transcript) must never overwrite it — moving the boundary down // would commit the earlier child's still-mutable rows as stale // history. - if (liveLocalStart !== undefined && this.#nativeScrollbackLiveRegionStart === undefined) { - this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart; - this.#nativeScrollbackLiveRegionPinned = liveRegionPinned; + if (liveLocalStart !== undefined) { + const start = offset + liveLocalStart; + if (this.#nativeScrollbackLiveRegionStart === undefined) { + this.#nativeScrollbackLiveRegionStart = start; + this.#nativeScrollbackLiveRegionPinned = liveRegionPinned; + } + // A pinned region anywhere in the frame caps commits at its start, + // even when an earlier unpinned seam won the topmost merge above: + // its rows (a growing anchored panel) must never reach scrollback. + if (liveRegionPinned && this.#nativeScrollbackPinnedBoundary === undefined) { + this.#nativeScrollbackPinnedBoundary = start; + } } if (chainStable) { if (previous !== undefined && previous.component === child && previous.start === offset) { @@ -3529,6 +3545,11 @@ export class TUI extends Container { // reports no seam (shell semantics). const frameLength = rawFrame.length; const finalBoundary = Math.max(0, Math.min(frameLength, liveRegionStart ?? frameLength)); + // No commit may cross into a pinned region, even one below an unpinned + // topmost seam (an anchored HUD/panel under a streaming transcript). The + // topmost seam still governs exactness (finalBoundary); this ceiling only + // bars a growing pinned region's scrolled-off rows from native scrollback. + const commitCeiling = this.#nativeScrollbackPinnedBoundary ?? frameLength; // 2. Transition state captured before any emitter runs. let prevWindowTop = this.#windowTopRow; @@ -3747,7 +3768,7 @@ export class TUI extends Container { if (fullPaint) { committedPrefixResliced = true; windowTop = Math.max(0, frameLength - height); - chunkTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop; + chunkTo = Math.min(windowTop, commitCeiling); } else if (widthEpochReset) { // A terminal width change ends the physical-row coordinate epoch. // Resolve the last emitted logical source boundary at the new width; @@ -3767,7 +3788,7 @@ export class TUI extends Container { hasVisibleOverlay || widthEpochCurrentRows === undefined ? hasVisibleOverlay ? widthEpochAppendFrom - : Math.max(widthEpochAppendFrom, liveRegionPinned ? finalBoundary : frameLength) + : Math.max(widthEpochAppendFrom, commitCeiling) : Math.max(widthEpochAppendFrom, widthEpochCurrentRows); } else if (this.#widthEpochBaselineRows !== undefined) { // Only rows physically appended after the width epoch may drive the @@ -3778,7 +3799,7 @@ export class TUI extends Container { windowTop = Math.max(0, frameLength - height); chunkTo = this.#committedRows; widthEpochAppendFrom = this.#widthEpochBaselineRows; - const appendBoundary = liveRegionPinned ? finalBoundary : frameLength; + const appendBoundary = commitCeiling; widthEpochAppendTo = hasVisibleOverlay ? widthEpochAppendFrom : Math.max(widthEpochAppendFrom, appendBoundary); } else if ( frameLength <= this.#committedRows || @@ -3799,7 +3820,7 @@ export class TUI extends Container { // "duplication, never loss" is the ED3-unsafe fallback contract. committedPrefixResliced = true; windowTop = Math.max(0, frameLength - height); - chunkTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop; + chunkTo = Math.min(windowTop, commitCeiling); this.#committedRows = chunkTo; this.#committedPrefix = rawFrame.slice(0, chunkTo); } else if (geometryChanged && Math.max(0, frameLength - height) < this.#committedRows) { @@ -3831,9 +3852,7 @@ export class TUI extends Container { chunkTo = hasVisibleOverlay || geometryChanged ? this.#committedRows - : liveRegionPinned - ? Math.min(windowTop, Math.max(this.#committedRows, finalBoundary)) - : windowTop; + : Math.min(windowTop, Math.max(this.#committedRows, commitCeiling)); if (geometryChanged) { committedPrefixResliced = true; this.#committedPrefix = rawFrame.slice(0, this.#committedRows); @@ -3943,7 +3962,7 @@ export class TUI extends Container { let commitTo: number; if (replayUnresolvedWidthEpoch) { commitFrom = 0; - commitTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop; + commitTo = Math.min(windowTop, commitCeiling); scrollRows = commitTo; } else if (logicalAppend && !logicalPrefixAppend) { const sourceWindowTop = Math.max(0, widthEpochSourceBoundary - height); diff --git a/packages/tui/test/component-render.test.ts b/packages/tui/test/component-render.test.ts index d4bfe5dd0..0b87eb1ee 100644 --- a/packages/tui/test/component-render.test.ts +++ b/packages/tui/test/component-render.test.ts @@ -398,6 +398,45 @@ describe("TUI.requestComponentRender", () => { await term.flush(); } }); + it("keeps a pinned panel out of scrollback under an unpinned streaming transcript seam", async () => { + // Regression for the /btw panel re-committing its frame while the primary + // turn streams (#8793): the transcript reports the topmost, UNPINNED seam, + // so the frame-wide pin policy is false, yet an anchored pinned panel below + // it must still never commit its scrolled-off rows to native scrollback. + const term = new VirtualTerminal(40, 8, 1_000); + const scheduler = new StressRenderScheduler(); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); + const markers = Array.from({ length: 5 }, (_unused, index) => `HIST-${index}`); + const transcript = new LiveHead([...markers, "streaming-tail"]); + transcript.setSeam(markers.length); // committed prefix + one live (streaming) tail row + const status = new AnchoredStatusContainer(); + const editor = new CountingLines([`editor${CURSOR_MARKER}`]); + tui.addChild(transcript); + tui.addChild(status); + tui.addChild(editor); + + try { + tui.start(); + await scheduler.drain(term); + const panel = new CountingLines(["btw-0"]); + status.addChild(panel); + tui.requestRender(); + await scheduler.drain(term); + for (let tick = 1; tick <= 12; tick++) { + panel.set(Array.from({ length: tick + 1 }, (_row, index) => `BTWROW-${tick}-${index}`)); + tui.requestComponentRender(panel); + await scheduler.drain(term); + } + + // Native scrollback is everything above the visible viewport; mid-stream + // it must hold zero rows of the growing pinned panel. + const history = strip(term.getScrollBuffer()).slice(0, -term.rows); + expect(history.filter(row => row.startsWith("BTWROW-"))).toEqual([]); + } finally { + tui.stop(); + await term.flush(); + } + }); }); describe("TUI keystroke-scoped render", () => {