fix(tui): keep low verbosity summaries in place when toggled (#52357)

This commit is contained in:
James Long
2026-10-08 14:15:53 -04:00
committed by GitHub
Unverified
parent 0bd95d0e52
commit 38c955e1f9
5 changed files with 224 additions and 13 deletions
@@ -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") => (
<Children {...props} nodes={props.node.children} mode={mode} />
)
@@ -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}
</InlineToolRow>
@@ -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"}
</InlineToolRow>
@@ -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)
},
}
}
+34 -4
View File
@@ -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<PromptRef>()
@@ -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,
@@ -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 (
<box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
@@ -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<typeof createTimelineAnchors>
/** 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
@@ -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<ReturnType<typeof createTestRenderer>>
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<void>, 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<Promise<void>>(
(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)