fix(tui): prevent sidebar scrollbar flash (#40056)

This commit is contained in:
Kit Langton 2026-08-01 11:12:37 -04:00 committed by GitHub
parent e872bd3c8f
commit d5f6c088f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 94 additions and 18 deletions

View file

@ -7,7 +7,7 @@ const money = new Intl.NumberFormat("en-US", {
currency: "USD",
})
function View(props: { context: Plugin.Context; sessionID: string }) {
export function SidebarContext(props: { context: Plugin.Context; sessionID: string }) {
const theme = props.context.theme
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
const session = createMemo(() => props.context.data.session.get(props.sessionID))
@ -18,28 +18,32 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
)
return (
<box>
<text fg={theme.text.default}>
<b>Context</b>
</text>
<Show when={state()} fallback={<text fg={theme.text.subdued}>Not measured</text>}>
{(value) => (
<>
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
<Show when={value().percent !== undefined}>
<text fg={theme.text.subdued}>{value().percent}% used</text>
</Show>
</>
)}
</Show>
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
</box>
<Show when={state() || cost() > 0}>
<box>
<text fg={theme.text.default}>
<b>Context</b>
</text>
<Show when={state()}>
{(value) => (
<>
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
<Show when={value().percent !== undefined}>
<text fg={theme.text.subdued}>{value().percent}% used</text>
</Show>
</>
)}
</Show>
<Show when={cost() > 0}>
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
</Show>
</box>
</Show>
)
}
export default Plugin.define({
id: "internal:sidebar-context",
setup(context) {
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
context.ui.slot("sidebar.content", (props) => <SidebarContext context={context} sessionID={props.sessionID} />)
},
})

View file

@ -27,9 +27,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
position={props.overlay ? "absolute" : "relative"}
>
<scrollbox
ref={(scroll) => queueMicrotask(() => scroll.verticalScrollBar.resetVisibilityControl())}
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
visible: false,
trackOptions: {
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,

View file

@ -0,0 +1,70 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { Context } from "@opencode-ai/plugin/tui/context"
import { SidebarContext } from "../../src/feature-plugins/sidebar/context"
function context(options?: { cost?: number; tokens?: number }) {
const color = RGBA.fromInts(200, 200, 200)
return {
theme: { text: { default: color, subdued: color } },
data: {
session: {
get: () => ({ location: { directory: "/workspace" } }),
cost: () => options?.cost ?? 0,
message: {
list: () =>
options?.tokens
? [
{
id: "message",
type: "assistant",
model: { providerID: "provider", id: "model" },
tokens: {
input: options.tokens,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
]
: [],
},
},
location: {
model: { list: () => [] },
},
},
} as unknown as Context
}
test("sidebar omits context before usage is available", async () => {
const app = await testRender(() => <SidebarContext context={context()} sessionID="session" />, {
width: 42,
height: 8,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("Context")
expect(app.captureCharFrame()).not.toContain("Not measured")
} finally {
app.renderer.destroy()
}
})
test("sidebar shows available context usage", async () => {
const app = await testRender(() => <SidebarContext context={context({ tokens: 1234 })} sessionID="session" />, {
width: 42,
height: 8,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Context")
expect(app.captureCharFrame()).toContain("1,234 tokens")
} finally {
app.renderer.destroy()
}
})