From e48cc1c02c6bfc583fa7738edf5d80f20a39d3fd Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Thu, 10 Sep 2026 00:04:20 -0500 Subject: [PATCH] fix: preserve TUI transcript follow during reflow --- CHANGELOG.md | 2 ++ .../opencode/script/session-runtime-smoke.ts | 4 +-- .../src/cli/cmd/tui/routes/session/index.tsx | 8 +++-- .../test/cli/tui/prompt-working-start.test.ts | 30 +++++++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a080fba..87120cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ `run_once` results to the calling session. - Keep `Send now` attached to the first visible queued prompt when synthetic runtime notifications are present. +- Preserve transcript follow mode when a viewport resize or tall Shell output + reflow moves an already-bottom scroll position. - Add native PTY coverage for queued notifications, transcript paging and double-`Esc` cancellation. diff --git a/src/mendcode/packages/opencode/script/session-runtime-smoke.ts b/src/mendcode/packages/opencode/script/session-runtime-smoke.ts index 364c24e9..869c19b5 100644 --- a/src/mendcode/packages/opencode/script/session-runtime-smoke.ts +++ b/src/mendcode/packages/opencode/script/session-runtime-smoke.ts @@ -554,8 +554,8 @@ async function startTui( { cwd: PACKAGE_ROOT, env: ptyEnvironment(sandbox.env), - cols: 120, - rows: 40, + cols: Math.max(40, Number.parseInt(process.env.MENDCODE_SMOKE_COLS ?? "120", 10) || 120), + rows: Math.max(16, Number.parseInt(process.env.MENDCODE_SMOKE_ROWS ?? "40", 10) || 40), name: "xterm-256color", }, ) diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f1305fd5..46236d5a 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -499,6 +499,7 @@ export function sessionUserMovedViewport(input: { viewportHeight: number lastViewportHeight: number followOutput?: boolean + atBottom?: boolean }) { const scrollDelta = input.scrollTop - input.lastScrollTop const scrollMoved = Math.abs(scrollDelta) > 1 @@ -510,9 +511,11 @@ export function sessionUserMovedViewport(input: { // A sticky/following scrollbox can move its scrollTop while layout is // settling after a streamed part changes height. A downward movement can // be that layout adjustment, but a negative delta is an unmistakable manual - // scroll-up gesture and must detach follow immediately. + // scroll-up gesture and must detach follow immediately. When sticky follow + // has already settled at the new bottom, however, a negative delta can be + // caused entirely by a larger viewport or a shorter reflowed child. if (layoutChanged) { - if (input.followOutput) return scrollDelta < -1 + if (input.followOutput) return scrollDelta < -1 && input.atBottom !== true return false } return true @@ -2584,6 +2587,7 @@ export function Session() { viewportHeight, lastViewportHeight: lastObservedViewportHeight, followOutput: followSessionOutput(), + atBottom, }) const contentHeightChanged = Math.abs(scrollHeight - lastObservedScrollHeight) > 1 const viewportHeightChanged = Math.abs(viewportHeight - lastObservedViewportHeight) > 1 diff --git a/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts b/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts index 6fdf4f22..1b5a90e4 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts @@ -1966,10 +1966,40 @@ describe("resolveWorkingStartedAt", () => { viewportHeight: 48, lastViewportHeight: 48, followOutput: true, + atBottom: false, }), ).toBe(true) }) + test("does not detach follow when a layout reflow moves an already-bottom viewport", () => { + for (const input of [ + { + scrollTop: 180, + lastScrollTop: 200, + scrollHeight: 300, + lastScrollHeight: 300, + viewportHeight: 120, + lastViewportHeight: 100, + }, + { + scrollTop: 180, + lastScrollTop: 200, + scrollHeight: 280, + lastScrollHeight: 300, + viewportHeight: 100, + lastViewportHeight: 100, + }, + ]) { + expect( + sessionUserMovedViewport({ + ...input, + followOutput: true, + atBottom: true, + }), + ).toBe(false) + } + }) + test("detaches follow when a manual scroll-up overlaps a content reflow", () => { expect( sessionUserMovedViewport({