From c4dcf72e130cad87be0961bc89513bf2e38a4f34 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Aug 2026 20:46:52 -0400 Subject: [PATCH] fix(tui): detect clipped transcript bottom (#45100) --- packages/tui/src/routes/session/index.tsx | 2 +- packages/tui/test/app-lifecycle.test.tsx | 86 +++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index e579b43f7fc..cef7f18a005 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -459,7 +459,7 @@ export function Session(props: { verticalTabsWidth: number }) { function isAwayFromBottom() { if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage()) return true if (visibleEnd() < rows.length) return true - return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1 + return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) } function updateAwayFromBottom() { const preserveWindow = revealingOlderRows || revealingNewerRows || !!ensureAllRowsPending diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index d7a7fcf9dbc..a71d94ce42b 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1,4 +1,5 @@ import { expect, test } from "bun:test" +import { type Renderable, ScrollBoxRenderable } from "@opentui/core" import { createTestRenderer } from "@opentui/core/testing" import { Effect, FileSystem } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -296,6 +297,91 @@ test("session startup prompt is submitted exactly once", async () => { } }) +test("shows jump to latest after scrolling one line above the final message", async () => { + const setup = await createTestRenderer({ width: 80, height: 20, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + const events = createEventStream() + const session = { + id: "dummy", + title: "Demo session", + projectID: "project", + location: { directory }, + agent: "build", + model: { providerID: "provider", id: "model" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const messages = Array.from({ length: 8 }, (_, index) => ({ + id: `message-${index}`, + type: "user", + text: index === 7 ? "Final visible message" : `Earlier message ${index}`, + time: { created: index }, + })) + const calls = createFetch((url) => { + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === "/api/session/dummy") return json({ data: session }) + if (url.pathname === "/api/session/dummy/message") return json({ data: messages.toReversed(), cursor: {} }) + if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] }) + if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) + }, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { + get: async () => ({ + animations: false, + keybinds: { "session.line.up": "f6", "session.line.down": "f7" }, + }), + update: async () => ({}), + }, + packages: { resolve: async () => undefined }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }), + args: { sessionID: "dummy" }, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + + await setup.waitForFrame((frame) => frame.includes("Final visible message")) + const findScrollBox = (root: Renderable): ScrollBoxRenderable | undefined => + root instanceof ScrollBoxRenderable && root.getRenderable("message-7") + ? root + : root.getChildren().map(findScrollBox).find(Boolean) + const scroll = findScrollBox(setup.renderer.root) + expect(scroll).toBeDefined() + if (!scroll) throw new Error("session transcript scrollbox was not found") + const maximum = () => Math.max(0, scroll.scrollHeight - scroll.viewport.height) + + expect(scroll.scrollTop).toBe(maximum()) + const initial = setup.captureCharFrame().split("\n") + expect(initial.find((line) => line.includes("Jump to latest"))).toBeUndefined() + expect(initial[initial.findIndex((line) => line.includes("Final visible message")) + 1]).toContain("┃") + + setup.mockInput.pressKey("F6") + const clipped = (await setup.waitForFrame((frame) => frame.includes("Jump to latest"))).split("\n") + expect(scroll.scrollTop).toBe(maximum() - 1) + expect(clipped.find((line) => line.includes("Jump to latest"))).toBeDefined() + expect(clipped[clipped.findIndex((line) => line.includes("Final visible message")) + 1]).not.toContain("┃") + + setup.mockInput.pressKey("F7") + const restored = (await setup.waitForFrame((frame) => !frame.includes("Jump to latest"))).split("\n") + expect(scroll.scrollTop).toBe(maximum()) + expect(restored.find((line) => line.includes("Jump to latest"))).toBeUndefined() + expect(restored[restored.findIndex((line) => line.includes("Final visible message")) + 1]).toContain("┃") + + setup.renderer.destroy() + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + } +}) + test("new session inherits the active session model", async () => { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, kittyKeyboard: true }) setup.renderer.start()