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

This commit is contained in:
James Long
2026-09-30 18:59:52 +00:00
parent 7878744505
commit 70a78ccd6a
5 changed files with 187 additions and 17 deletions
+15 -12
View File
@@ -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") => (
<Children {...props} nodes={props.node.children} mode={mode} />
)
@@ -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 (
<GroupAnchor groupID={disclosure.id()} active={summary().label !== ""}>
<Show when={summary().label}>
<Show when={disclosure.expanded()}>
<box flexDirection="column" gap={1} marginBottom={1}>
<Children {...props} nodes={props.node.children} mode="normal" />
</box>
</Show>
<InlineToolRow
icon={disclosure.expanded() ? "−" : "+"}
color={hover() ? theme.text.base : theme.text.muted}
@@ -218,15 +226,10 @@ 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>
<Show when={disclosure.expanded()}>
<box flexDirection="column" gap={1} marginTop={1}>
<Children {...props} nodes={props.node.children} mode="normal" />
</box>
</Show>
</Show>
<PendingEntries {...props} entries={entries()} />
</GroupAnchor>
@@ -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"}
</InlineToolRow>
@@ -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)
},
}
}
+30 -2
View File
@@ -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<PromptRef>()
@@ -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,
@@ -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,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<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()
async function withSession(run: (setup: Setup) => Promise<void>) {
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<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(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,
)