fix(tui): scroll session while terminal is focused (#46697)

Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-09-01 20:54:14 -04:00
committed by GitHub
parent 7f2645a8f4
commit f92a725dfd
3 changed files with 193 additions and 2 deletions
+17 -2
View File
@@ -1,4 +1,4 @@
import { RGBA, type MouseEvent } from "@opentui/core"
import { RGBA, MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
import { useConfig } from "../config"
@@ -54,6 +54,7 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const [terminalFocused, setTerminalFocused] = createSignal(false)
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
let focusTerminal: (() => void) | undefined
let sessionScroll: ScrollBoxRenderable | undefined
createResource(
() => (config.data.session.terminal ? props.sessionID : undefined),
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
@@ -146,6 +147,7 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
}}
>
<Session
scrollRef={(value) => (sessionScroll = value)}
verticalTabsWidth={props.verticalTabsWidth}
promptMuted={terminalFocused()}
sidebarVisible={rightPane() === "sidebar"}
@@ -161,6 +163,15 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
width="100%"
height="100%"
zIndex={1}
onMouseScroll={(event) => {
if (!sessionScroll || sessionScroll.isDestroyed) return
const viewport = sessionScroll.viewport
if (event.x < viewport.x || event.x >= viewport.x + viewport.width) return
if (event.y < viewport.y || event.y >= viewport.y + viewport.height) return
// Keep the focus-only click guard, but let the transcript handle its own wheel events.
event.stopPropagation()
sessionScroll.processMouseEvent(new MouseEvent(sessionScroll, event))
}}
// Consume the release before revealing permission buttons underneath.
onMouseUp={() => {
if (terminalResize.resizing() || resizeRelease) return
@@ -202,7 +213,11 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</box>
</Show>
<Show when={rightPane() === "terminal" && availableWidth() >= 3}>
<PaneResizeHandle resize={terminalResize} left={availableWidth() - terminalResize.size() - 1} highlight="right" />
<PaneResizeHandle
resize={terminalResize}
left={availableWidth() - terminalResize.size() - 1}
highlight="right"
/>
</Show>
<Show when={rightPane() === "sidebar" && !wide()}>
<box
@@ -155,6 +155,7 @@ function use() {
}
export function Session(props: {
scrollRef?: (scroll: ScrollBoxRenderable | undefined) => void
verticalTabsWidth: number
promptMuted?: boolean
sidebarVisible: boolean
@@ -377,6 +378,7 @@ export function Session(props: {
let awayTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
if (awayTimer) clearTimeout(awayTimer)
props.scrollRef?.(undefined)
prependHistory.cancel()
firstJump()?.()
if (!scroll || scroll.isDestroyed) return
@@ -1371,6 +1373,7 @@ export function Session(props: {
<scrollbox
ref={(r) => {
scroll = r
props.scrollRef?.(r)
scroll.verticalScrollBar.on("change", updateAwayFromBottom)
}}
viewportOptions={{
@@ -0,0 +1,173 @@
import { expect, test } from "bun:test"
import { BoxRenderable, EmbeddedTerminalRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test.each([80, 120, 180])("session wheel scrolling preserves pane focus at width %s", async (width) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width, height: 36, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const session = {
id: "ses_wheel",
title: "Wheel focus fixture",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const pty = {
id: "pty_fixture",
sessionID: session.id,
title: "Terminal",
command: "/bin/sh",
args: [],
cwd: directory,
status: "running",
pid: 1,
foregroundProcess: null,
size: { cols: 48, rows: 30 },
output: { head: 0, tail: 0 },
}
const text = Array.from({ length: 100 }, (_, index) => `Terminal line ${index}`).join("\r\n")
const messages = Array.from({ length: 20 }, (_, index) => ({
id: `message-${index}`,
type: "user",
text: `History message ${String(index).padStart(4, "0")}`,
time: { created: index },
}))
const calls = createFetch((url, request) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: messages.toReversed(), cursor: {} })
if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] })
if (url.pathname === `/api/session/${session.id}/permission`) return json({ data: [] })
if (url.pathname === `/api/experimental/session/${session.id}/terminal`)
return json({ data: request.method === "POST" ? pty : [pty] })
if (url.pathname === "/api/experimental/persistent-pty/pty_fixture/snapshot")
return json({
data: { info: pty, text, checkpoint: Buffer.from(text).toString("base64"), cursor: { x: 16, y: 29 } },
})
if (url.pathname === "/api/experimental/persistent-pty/pty_fixture/connect-token")
return json({ data: { ticket: "fixture" } })
return undefined
}, createEventStream())
const server = Bun.serve({
port: 0,
idleTimeout: 0,
fetch(request, server) {
if (new URL(request.url).pathname.endsWith("/connect") && server.upgrade(request)) return undefined
return calls.fetch(request)
},
websocket: {
open(socket) {
socket.send(JSON.stringify({ type: "attached", inputProtocol: 1, role: "controller", info: pty }))
socket.send(JSON.stringify({ type: "replay_complete" }))
},
message() {},
},
})
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, session: { terminal: true }, tabs: { enabled: false } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await setup.waitForFrame((frame) => frame.includes("History message 0019"))
await setup.mockInput.typeText("/terminal")
await setup.waitForFrame((frame) => frame.includes("New terminal"))
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Terminal line 99"))
setup.mockInput.pressKey("x", { ctrl: true })
setup.mockInput.pressArrow("left")
await setup.waitForVisualIdle()
expect(setup.renderer.currentFocusedRenderable instanceof EmbeddedTerminalRenderable).toBe(false)
const find = (root: Renderable): ScrollBoxRenderable | undefined =>
root instanceof ScrollBoxRenderable && root.getRenderable("message-19")
? root
: root.getChildren().map(find).find(Boolean)
const scroll = find(setup.renderer.root)
if (!scroll) throw new Error("Session scrollbox not found")
expect(scroll.scrollTop).toBeGreaterThan(0)
const before = scroll.scrollTop
await setup.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "up")
await setup.waitForVisualIdle()
expect(scroll.scrollTop).toBeLessThan(before)
scroll.scrollTo(Infinity)
await setup.waitForVisualIdle()
setup.mockInput.pressKey("x", { ctrl: true })
setup.mockInput.pressArrow("right")
const terminal = setup.renderer.currentFocusedRenderable
if (!(terminal instanceof EmbeddedTerminalRenderable)) throw new Error("Terminal was not focused")
await setup.waitForVisualIdle()
const focused = scroll.scrollTop
await setup.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "up")
await setup.waitForVisualIdle()
expect(scroll.scrollTop).toBeLessThan(focused)
expect(setup.renderer.currentFocusedRenderable).toBe(terminal)
expect(setup.captureCharFrame()).toContain("Jump to latest")
await setup.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "down")
await setup.waitForVisualIdle()
expect(scroll.scrollTop).toBe(focused)
expect(setup.renderer.currentFocusedRenderable).toBe(terminal)
await setup.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + scroll.viewport.height + 2, "up")
await setup.waitForVisualIdle()
expect(scroll.scrollTop).toBe(focused)
expect(setup.renderer.currentFocusedRenderable).toBe(terminal)
const clicks: number[] = []
scroll.onMouseUp = () => clicks.push(1)
await setup.mockMouse.click(scroll.viewport.x + 2, scroll.viewport.y + 2)
await setup.waitForVisualIdle()
expect(setup.renderer.currentFocusedRenderable).not.toBe(terminal)
expect(clicks).toHaveLength(0)
await setup.mockMouse.click(scroll.viewport.x + 2, scroll.viewport.y + 2)
await setup.waitForVisualIdle()
expect(clicks).toHaveLength(1)
setup.mockInput.pressEscape()
await setup.waitForVisualIdle()
const sessionFocus = setup.renderer.currentFocusedRenderable
const terminalFrame = () =>
setup
.captureCharFrame()
.split("\n")
.map((line) => line.slice(terminal.x))
.join("\n")
const terminalBefore = terminalFrame()
await setup.mockMouse.scroll(terminal.x + 2, terminal.y + 2, "up")
await setup.waitForVisualIdle()
expect(terminalFrame()).not.toBe(terminalBefore)
expect(setup.renderer.currentFocusedRenderable).toBe(sessionFocus)
setup.mockInput.pressKey("x", { ctrl: true })
setup.mockInput.pressArrow("right")
await setup.waitForVisualIdle()
const findHandle = (root: Renderable): BoxRenderable | undefined =>
root instanceof BoxRenderable && root.zIndex === 10 && root.width === 2
? root
: root.getChildren().map(findHandle).find(Boolean)
const handle = findHandle(setup.renderer.root)
if (!handle) throw new Error("Separator handle not found")
const start = handle.x
await setup.mockMouse.drag(start, 10, start + 4, 10)
await setup.waitForVisualIdle()
expect(handle.x).toBe(start + 4)
expect(setup.renderer.currentFocusedRenderable).toBe(terminal)
} finally {
setup.renderer.destroy()
await task
await server.stop()
}
})