Merge PR #8970: fix(tui): pin anchored regions under an unpinned streaming seam (@roboomp)
This commit is contained in:
@@ -13,6 +13,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 revived subagents (warm lifecycle reviver and cold persisted reviver) rebuilding the session without initializing the extension runtime, leaving every runtime action throwing `ExtensionRuntimeNotInitializedError`. An extension with a `tool_call` handler that touched a runtime action (e.g. `appendEntry`) then tripped the fail-closed gate in `emitToolCall` and blocked every tool — including the hidden `yield` — so the revived agent could neither finish nor exit and looped until killed. Both revivers now call the shared `initializeExtensions` helper, restoring runtime actions, `onError`, and the `session_start` event ([#8824](https://github.com/can1357/oh-my-pi/issues/8824)).
|
||||
- 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.
|
||||
|
||||
+30
-11
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user