diff --git a/packages/tui/src/routes/session/group-view.tsx b/packages/tui/src/routes/session/group-view.tsx
index 91d7eb10d88..acf7dac0b5e 100644
--- a/packages/tui/src/routes/session/group-view.tsx
+++ b/packages/tui/src/routes/session/group-view.tsx
@@ -1,5 +1,5 @@
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
-import { RGBA } from "@opentui/core"
+import { RGBA, type Renderable } from "@opentui/core"
import { useRenderer, type JSX } from "@opentui/solid"
import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode/client"
import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
@@ -128,7 +128,7 @@ function GroupContent(props: GroupProps) {
return `${completed() ? "Explored" : "Exploring"}: ${names.join(", ")}`
})
const failed = () => tools().some((part) => part.state.status === "error")
- const toggle = disclosure.toggle
+ const toggle = () => disclosure.toggle()
const children = (mode: "normal" | "thought" | "tool") => (
)
@@ -200,7 +200,10 @@ function GroupContent(props: GroupProps) {
)
}
-/** Low verbosity: one summary for a run of tools, thoughts and instruction loads. */
+/**
+ * Low verbosity: one summary for a run of tools, thoughts and instruction loads.
+ * Details open above the summary, which stays on the row that was clicked.
+ */
function ActivityGroup(props: GroupProps) {
const theme = useTheme()
const disclosure = useDisclosure(props)
@@ -210,6 +213,11 @@ function ActivityGroup(props: GroupProps) {
return (
+
+
+
+
+
setHover(true)}
onMouseOut={() => setHover(false)}
- onMouseUp={disclosure.toggle}
+ onMouseUp={(event) => disclosure.toggle(event.currentTarget)}
>
{summary().label}
-
-
-
-
-
@@ -255,7 +258,7 @@ function InstructionsGroup(props: GroupProps) {
pending=""
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
- onMouseUp={disclosure.toggle}
+ onMouseUp={() => disclosure.toggle()}
>
Instructions: {files()} {files() === 1 ? "file" : "files"}
@@ -277,10 +280,10 @@ function useDisclosure(props: GroupProps) {
return {
id,
expanded,
- toggle() {
+ toggle(anchor?: Renderable | null) {
if (renderer.getSelection()?.getSelectedText()) return
const key = id()
- if (key) ctx.setGroupExpanded(key, !expanded())
+ if (key) ctx.setGroupExpanded(key, !expanded(), anchor)
},
}
}
diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx
index d20d92f42ae..8b46381ec05 100644
--- a/packages/tui/src/routes/session/index.tsx
+++ b/packages/tui/src/routes/session/index.tsx
@@ -24,7 +24,15 @@ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff"
import { useTheme, useThemes } from "../../context/theme"
-import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
+import {
+ BoxRenderable,
+ ScrollBoxRenderable,
+ addDefaultParsers,
+ TextAttributes,
+ RGBA,
+ MouseEvent,
+ type Renderable,
+} from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
SessionMessageInfo,
@@ -376,6 +384,7 @@ export function Session(props: {
firstJump()?.()
if (!scroll || scroll.isDestroyed) return
scroll.verticalScrollBar.off("change", updateAwayFromBottom)
+ scroll.content.off("resize", holdAnchor)
saveScrollAnchor(true)
})
const [prompt, setPrompt] = createSignal()
@@ -397,6 +406,16 @@ export function Session(props: {
})
}
+ // Keeps a toggled disclosure's header on its viewport row. The content resize fires inside the
+ // layout pass, before rows take their screen positions, so the correction lands in the same frame.
+ // Renderable positions are still stale at that point; read the computed layout instead.
+ let held: { node: Renderable; top: number } | undefined
+ const layoutTop = (node: Renderable): number =>
+ node === scroll.content || !node.parent ? 0 : node.getLayoutNode().getComputedTop() + layoutTop(node.parent)
+ const holdAnchor = () => {
+ if (held && !held.node.isDestroyed) scroll.scrollTo(layoutTop(held.node) - held.top)
+ }
+
// Tail-first transcript mounting: only the newest rows mount when the session opens. Older rows
// mount on demand near the top, keeping inactive tabs cheap to tear down. While the reader stays
// at the bottom the hidden span follows appends; leaving the bottom pins it to preserve the viewport.
@@ -1348,7 +1367,15 @@ export function Session(props: {
value={{
anchors,
groupExpanded,
- setGroupExpanded: (groupID, expanded) => {
+ setGroupExpanded: (groupID, expanded, anchor) => {
+ // At the bottom, sticky scrolling already keeps rows in place when only content above them changes.
+ if (anchor && isAwayFromBottom()) {
+ const hold = { node: anchor, top: layoutTop(anchor) - scroll.scrollTop }
+ held = hold
+ afterLayout(() => {
+ if (held === hold) held = undefined
+ })
+ }
sessionTabs.setGroupExpanded(sessionID, groupID, expanded)
afterLayout(saveScrollAnchor)
},
@@ -1391,6 +1418,7 @@ export function Session(props: {
scroll = r
props.scrollRef?.(r)
scroll.verticalScrollBar.on("change", updateAwayFromBottom)
+ scroll.content.on("resize", holdAnchor)
}}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
diff --git a/packages/tui/src/routes/session/message-parts.tsx b/packages/tui/src/routes/session/message-parts.tsx
index fd50e5d1909..f63ba65250e 100644
--- a/packages/tui/src/routes/session/message-parts.tsx
+++ b/packages/tui/src/routes/session/message-parts.tsx
@@ -1,5 +1,5 @@
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
-import { RGBA, TextAttributes } from "@opentui/core"
+import { RGBA, TextAttributes, type MouseEvent } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import type {
SessionMessageAssistant,
@@ -212,7 +212,7 @@ export function InlineToolRow(props: {
children: JSX.Element
onMouseOver?: () => void
onMouseOut?: () => void
- onMouseUp?: () => void
+ onMouseUp?: (event: MouseEvent) => void
}) {
return (
diff --git a/packages/tui/src/routes/session/render-context.tsx b/packages/tui/src/routes/session/render-context.tsx
index e5712bc572e..0fe106d5fe4 100644
--- a/packages/tui/src/routes/session/render-context.tsx
+++ b/packages/tui/src/routes/session/render-context.tsx
@@ -1,4 +1,5 @@
import { createContext, useContext } from "solid-js"
+import type { Renderable } from "@opentui/core"
import type { ModelInfo } from "@opencode/client"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { useConfig } from "../../config"
@@ -21,7 +22,8 @@ export const context = createContext<{
anchors: ReturnType
/** Saved disclosure, falling back to the verbosity default for the group kind. */
groupExpanded: (groupID: string, kind: GroupKind) => boolean
- setGroupExpanded: (groupID: string, expanded: boolean) => void
+ /** An anchor keeps its viewport row through the layout change the toggle causes. */
+ setGroupExpanded: (groupID: string, expanded: boolean, anchor?: Renderable | null) => void
thinkingMode: () => ThinkingMode
markdownMode: () => "source" | "rendered"
groupExploration: () => boolean
diff --git a/packages/tui/test/session-activity-anchor.test.tsx b/packages/tui/test/session-activity-anchor.test.tsx
new file mode 100644
index 00000000000..61c311e12c6
--- /dev/null
+++ b/packages/tui/test/session-activity-anchor.test.tsx
@@ -0,0 +1,137 @@
+import { expect, test } from "bun:test"
+import { createTestRenderer, TestRecorder } from "@opentui/core/testing"
+import { Effect, FileSystem } from "effect"
+import { Global } from "@opencode/util/global"
+import type { SessionMessageInfo } from "@opencode/client"
+import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
+import { tmpdir } from "./fixture/fixture"
+
+type Setup = Awaited>
+
+const session = {
+ id: "ses_activity_anchor",
+ title: "Activity anchor",
+ projectID: "proj_test",
+ location: { directory },
+ cost: 0,
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ time: { created: 0, updated: 0 },
+}
+const model = { providerID: "fixture", id: "fixture" }
+// Turn N runs N + 2 commands, so every Low summary label is unique.
+const messages: SessionMessageInfo[] = Array.from({ length: 6 }, (_, turn) => [
+ { type: "user" as const, id: `user-${turn}`, text: `Turn ${turn} prompt`, time: { created: turn * 10 } },
+ {
+ type: "assistant" as const,
+ id: `assistant-${turn}`,
+ agent: "build",
+ model,
+ finish: "stop" as const,
+ time: { created: turn * 10 + 1, completed: turn * 10 + 3 },
+ content: [
+ ...Array.from({ length: turn + 2 }, (_, index) => ({
+ type: "tool" as const,
+ id: `shell-${turn}-${index}`,
+ name: "shell",
+ time: { created: turn * 10 + 1, completed: turn * 10 + 2 },
+ state: {
+ status: "completed" as const,
+ input: { command: `echo turn ${turn} step ${index}` },
+ content: [{ type: "text" as const, text: `turn ${turn} step ${index} output` }] as [
+ { type: "text"; text: string },
+ ],
+ metadata: {},
+ },
+ })),
+ { type: "text" as const, text: `Turn ${turn} answer` },
+ ],
+ },
+]).flat()
+
+async function withSession(run: (setup: Setup) => Promise) {
+ await using state = await tmpdir()
+ const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
+ setup.renderer.start()
+ const calls = createFetch((url) => {
+ 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` || url.pathname === `/api/session/${session.id}/permission`)
+ return json({ data: [] })
+ }, createEventStream())
+ const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
+ const { run: runApp } = await import("../src/app")
+ const task = Effect.runPromise(
+ runApp({
+ app: { name: "test", version: "test", channel: "test" },
+ server: { endpoint: { url: server.url.toString() } },
+ config: {
+ get: async () => ({ animations: false, tabs: { enabled: false }, session: { verbosity: "low" } }),
+ update: async () => ({}),
+ },
+ packages: { prepare: async () => ({ directory: "" }) },
+ 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("Turn 5 answer"))
+ await setup.waitForVisualIdle({ quietFrames: 3 })
+ await run(setup)
+ } finally {
+ setup.renderer.destroy()
+ await task
+ await server.stop()
+ }
+}
+
+const lines = (frame: string) => frame.split("\n")
+const rowOf = (frame: string, label: string) => lines(frame).findIndex((line) => line.includes(label))
+
+/** Clicks the summary and returns its row in every frame rendered afterwards. */
+async function toggle(setup: Setup, label: string) {
+ const row = rowOf(setup.captureCharFrame(), label)
+ expect(row).toBeGreaterThan(0)
+ // Stay outside OpenTUI's 500ms multi-click window so a second toggle is not a word selection.
+ await Bun.sleep(550)
+ const recorder = new TestRecorder(setup.renderer)
+ recorder.rec()
+ await setup.mockMouse.click(8, row)
+ await setup.waitForVisualIdle({ quietFrames: 3 })
+ recorder.stop()
+ expect(recorder.recordedFrames.length).toBeGreaterThan(0)
+ return { row, frames: [...new Set(recorder.recordedFrames.map((frame) => rowOf(frame.frame, label)))] }
+}
+
+test.each([0, 1, 3])(
+ "low activity details open above a summary that keeps its row, %i wheel steps from the bottom",
+ async (steps) => {
+ await withSession(async (setup) => {
+ await Array.from({ length: steps }).reduce>(
+ (previous) => previous.then(() => setup.mockMouse.scroll(30, 10, "up")),
+ Promise.resolve(),
+ )
+ await setup.waitForVisualIdle({ quietFrames: 3 })
+ const commands = [7, 6, 5, 4].find((count) => {
+ const row = rowOf(setup.captureCharFrame(), `${count} commands`)
+ return row > 4 && row < 18
+ })
+ expect(commands).toBeDefined()
+ const label = `${commands} commands`
+ const turn = commands! - 2
+
+ const expand = await toggle(setup, label)
+ expect(expand.frames).toEqual([expand.row])
+ const expanded = lines(setup.captureCharFrame())
+ expect(expanded[expand.row]).toContain(`− ${label}`)
+ expect(expanded.slice(0, expand.row).join("\n")).toContain(`turn ${turn} step ${commands! - 1} output`)
+ expect(expanded.slice(expand.row).join("\n")).toContain(`Turn ${turn} answer`)
+
+ const collapse = await toggle(setup, label)
+ expect(collapse.frames).toEqual([expand.row])
+ expect(lines(setup.captureCharFrame())[expand.row]).toContain(`+ ${label}`)
+ })
+ },
+ 20000,
+)