From 38c955e1f94c3cf45623e3a608b136c19137a50a Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 8 Oct 2026 14:15:53 -0400 Subject: [PATCH] fix(tui): keep low verbosity summaries in place when toggled (#52357) --- .../tui/src/routes/session/group-view.tsx | 12 +- packages/tui/src/routes/session/index.tsx | 38 +++- .../tui/src/routes/session/message-parts.tsx | 4 +- .../tui/src/routes/session/render-context.tsx | 4 +- .../tui/test/session-activity-anchor.test.tsx | 179 ++++++++++++++++++ 5 files changed, 224 insertions(+), 13 deletions(-) create mode 100644 packages/tui/test/session-activity-anchor.test.tsx diff --git a/packages/tui/src/routes/session/group-view.tsx b/packages/tui/src/routes/session/group-view.tsx index 610aeaedb59..2e09fc5e849 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" @@ -126,7 +126,7 @@ function GroupContent(props: GroupProps) { ) return `${completed() ? "Explored" : "Exploring"}: ${names.join(", ")}` }) - const toggle = disclosure.toggle + const toggle = () => disclosure.toggle() const children = (mode: "normal" | "thought" | "tool") => ( ) @@ -216,7 +216,7 @@ function ActivityGroup(props: GroupProps) { spinner={!disclosure.expanded() && summary().active} onMouseOver={() => setHover(true)} onMouseOut={() => setHover(false)} - onMouseUp={disclosure.toggle} + onMouseUp={(event) => disclosure.toggle(event.currentTarget)} > {summary().label} @@ -253,7 +253,7 @@ function InstructionsGroup(props: GroupProps) { pending="" onMouseOver={() => setHover(true)} onMouseOut={() => setHover(false)} - onMouseUp={disclosure.toggle} + onMouseUp={() => disclosure.toggle()} > Instructions: {files()} {files() === 1 ? "file" : "files"} @@ -275,10 +275,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 8dbda95dcad..80e60d1f842 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -23,7 +23,15 @@ import { 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, @@ -118,7 +126,7 @@ import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, TextPart, toolDis import { defaultVerbosity, type GroupKind, type SessionEntry } from "./grouping/session" import { SessionGroupView } from "./group-view" import { useEntryAnchor } from "./anchor-view" -import { containsAnchor, createTimelineAnchors } from "./anchors" +import { containsAnchor, createTimelineAnchors, groupID } from "./anchors" import { rowsAfter, rowsBefore, rowWeight } from "./mount-budget" export { InlineToolRow } from "./message-parts" export { toolDisplay } from "./message-parts" @@ -370,6 +378,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() @@ -391,6 +400,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. @@ -1334,8 +1353,18 @@ export function Session(props: { value={{ anchors, groupExpanded, - setGroupExpanded: (groupID, expanded) => { - sessionTabs.setGroupExpanded(sessionID, groupID, expanded) + setGroupExpanded: (id, expanded, anchor) => { + // A group that ends the transcript would open off screen while the reader follows the bottom. + const last = rows.findLast((row) => row.type !== "assistant-footer" && row.type !== "turn-usage") + const ending = last?.type === "group" && groupID(last, 0) === id + if (anchor && !(ending && !isAwayFromBottom())) { + const hold = { node: anchor, top: layoutTop(anchor) - scroll.scrollTop } + held = hold + afterLayout(() => { + if (held === hold) held = undefined + }) + } + sessionTabs.setGroupExpanded(sessionID, id, expanded) afterLayout(saveScrollAnchor) }, get width() { @@ -1377,6 +1406,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..2b9a959050d --- /dev/null +++ b/packages/tui/test/session-activity-anchor.test.tsx @@ -0,0 +1,179 @@ +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() +// A turn still running its commands, so its summary ends the transcript. +const running: SessionMessageInfo[] = [ + { type: "user", id: "user-running", text: "Running prompt", time: { created: 100 } }, + { + type: "assistant", + id: "assistant-running", + agent: "build", + model, + time: { created: 101 }, + content: Array.from({ length: 1 }, (_, index) => ({ + type: "tool" as const, + id: `shell-running-${index}`, + name: "shell", + time: { created: 101, completed: 102 }, + state: { + status: "completed" as const, + input: { command: `echo running step ${index}` }, + content: [{ type: "text" as const, text: `running step ${index} output` }] as [{ type: "text"; text: string }], + metadata: {}, + }, + })), + }, +] + +async function withSession(run: (setup: Setup) => Promise, history = messages) { + 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: history.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(history === messages ? "Turn 5 answer" : "1 command")) + 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 below 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(expand.row + 1).join("\n")).toContain(`echo turn ${turn} step 0`) + expect(expanded.slice(0, expand.row).join("\n")).not.toContain(`echo turn ${turn} step`) + + const collapse = await toggle(setup, label) + expect(collapse.frames).toEqual([expand.row]) + expect(lines(setup.captureCharFrame())[expand.row]).toContain(`+ ${label}`) + }) + }, + 20000, +) + +test("a low activity summary that ends the transcript opens like v2 at the bottom", async () => { + await withSession( + async (setup) => { + const label = "1 command" + const expand = await toggle(setup, label) + const expanded = lines(setup.captureCharFrame()) + const row = expanded.findIndex((line) => line.includes(`− ${label}`)) + expect(row).toBeLessThan(expand.row) + expect(expanded.slice(row + 1).join("\n")).toContain("running step 0 output") + expect(expanded.join("\n")).not.toContain("Jump to latest") + + const collapse = await toggle(setup, label) + expect(collapse.frames.at(-1)).toBe(expand.row) + expect(lines(setup.captureCharFrame())[expand.row]).toContain(`+ ${label}`) + }, + [...messages, ...running], + ) +}, 20000)