mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 02:24:10 +08:00
fix(tui): keep low verbosity summaries in place when toggled
This commit is contained in:
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user