Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions packages/tui/src/routes/session/group-view.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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} />
)
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand All @@ -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)
},
}
}
Expand Down
38 changes: 34 additions & 4 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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>()
Expand All @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/tui/src/routes/session/message-parts.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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}>
Expand Down
4 changes: 3 additions & 1 deletion packages/tui/src/routes/session/render-context.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down
179 changes: 179 additions & 0 deletions packages/tui/test/session-activity-anchor.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
Loading