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, +)