feat(tui): add developer debug bar (#38359)

This commit is contained in:
James Long 2026-07-22 14:35:28 -04:00 committed by GitHub
commit 8a36abd328
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 554 additions and 193 deletions

View file

@ -0,0 +1,533 @@
import { TextAttributes } from "@opentui/core"
import { TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { open } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { monitorEventLoopDelay } from "node:perf_hooks"
import { createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type ParentProps } from "solid-js"
import { useClient } from "../context/client"
import { useConfig } from "../config"
import { useData } from "../context/data"
import { useLocation } from "../context/location"
import { useRoute } from "../context/route"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { DevTools } from "../devtools"
import { usePlugin } from "../plugin/context"
import { errorMessage } from "../util/error"
const graphWidth = 23
const sampleIntervalMilliseconds = 2_000
const sampleRetentionMilliseconds = 30_000
const statusWindowMilliseconds = 6_000
type Panel = "server" | "theme" | "tools" | "ui"
type ProcessSample = Readonly<{ cpu: number; memory: number; delay: number; time: number }>
export type RuntimeStatus = "normal" | "medium" | "high"
export function DevToolsBar() {
const client = useClient()
const config = useConfig()
const data = useData()
const location = useLocation()
const route = useRoute()
const plugins = usePlugin()
const theme = useTheme()
const keymap = Keymap.use()
const dimensions = useTerminalDimensions()
const { themeV2, mode, supports, setMode } = theme
const elevatedTheme = theme.contextual("elevated").themeV2
const [panel, setPanel] = createSignal<Panel>()
const [dumping, setDumping] = createSignal(false)
const [dumpPath, setDumpPath] = createSignal<string>()
const [dumpError, setDumpError] = createSignal<string>()
const [frontendSamples, setFrontendSamples] = createSignal<readonly ProcessSample[]>([])
const connected = createMemo(() => client.connection.status() === "connected")
const serverIndicator = createMemo(() => connectionIndicator(client.connection.status(), client.connection.attempt()))
const themePerformance = createMemo(
() => DevTools.data().find((group) => group.id === "theme-performance")?.entries ?? [],
)
const groups = createMemo(() => DevTools.data().filter((group) => group.id !== "theme-performance"))
const [server] = createResource(connected, async () => {
const [health, info] = await Promise.all([client.api.health.get(), client.api.server.get()])
return {
health,
address: info.urls[0] ? new URL(info.urls[0]).host : "Unknown",
}
})
const toggle = (next: Panel) => setPanel((current) => (current === next ? undefined : next))
const nextMode = () => (mode() === "dark" ? "light" : "dark")
const canSwitchMode = () => supports(nextMode())
const runtime = createMemo(() => runtimeStatus(frontendSamples()))
const timing = () => config.data.debug?.timing ?? false
const offEscape = keymap.intercept(
"key",
({ event }) => {
if (!panel() || event.name !== "escape") return
event.preventDefault()
event.stopPropagation()
setPanel()
},
{ priority: 10 },
)
onCleanup(offEscape)
onMount(() => {
const eventLoop = monitorEventLoopDelay({ resolution: 20 })
let frontendCPU = process.cpuUsage()
let frontendTime = performance.now()
let frontendReady = false
eventLoop.enable()
const sample = () => {
const now = performance.now()
const cpu = process.cpuUsage(frontendCPU)
frontendCPU = process.cpuUsage()
setFrontendSamples((samples) =>
[
...samples,
{
cpu: frontendReady ? cpuPercent(cpu.user + cpu.system, now - frontendTime) : 0,
memory: process.memoryUsage().rss,
delay: eventLoop.percentile(99) / 1_000_000,
time: now,
},
].filter((sample) => sample.time >= now - sampleRetentionMilliseconds),
)
eventLoop.reset()
frontendReady = true
frontendTime = now
}
sample()
const timer = setInterval(sample, sampleIntervalMilliseconds)
onCleanup(() => {
clearInterval(timer)
eventLoop.disable()
})
})
async function dump() {
setDumping(true)
setDumpPath()
setDumpError()
const routeData = route.data
const sessionID = routeData.type === "session" ? routeData.sessionID : undefined
const info = sessionID ? data.session.get(sessionID) : undefined
const sessionLocation =
info?.location ??
(location.current
? { directory: location.current.directory, workspaceID: location.current.workspaceID }
: undefined)
const details = server()
const backend = {
connected: connected(),
version: details?.health.version,
pid: details?.health.pid,
error: client.connection.error(),
}
const events = await (sessionID
? (async () => {
const events: { readonly created: number }[] = []
for await (const event of client.api.session.log({ sessionID, follow: false })) {
if (event.type !== "log.synced") events.push(event)
}
// Durable events stay in aggregate order even when their wall-clock timestamps differ.
client.connection.internal.history().forEach((event) => {
const index = events.findIndex((item) => item.created > event.created)
if (index === -1) {
events.push(event)
return
}
events.splice(index, 0, event)
})
return events.slice(-100)
})().catch(() => client.connection.internal.history())
: Promise.resolve([]))
const file = path.join(tmpdir(), `opencode-debug-${crypto.randomUUID()}.json`)
const output =
JSON.stringify(
{
backend,
session: sessionID
? {
...info,
id: sessionID,
projectID: info?.projectID ?? location.current?.project.id,
location: sessionLocation,
status: data.session.status(sessionID),
pending: data.session.pending.list(sessionID),
inputIDs: data.session.input.list(sessionID),
permissions: data.session.permission.list(sessionID) ?? [],
forms: data.session.form.list(sessionID) ?? [],
}
: undefined,
events,
mcp: {
servers: sessionLocation
? (data.location.mcp.server.list(sessionLocation) ?? [])
: (data.location.mcp.server.list() ?? []),
resources: sessionLocation
? (data.location.mcp.resource.list(sessionLocation) ?? [])
: (data.location.mcp.resource.list() ?? []),
},
plugins: {
ready: plugins.ready(),
list: plugins.list().map((plugin) => ({
name: "id" in plugin ? plugin.id : plugin.target,
...plugin,
})),
},
theme: {
name: theme.selected,
mode: theme.mode(),
},
},
null,
2,
) + "\n"
await open(file, "wx", 0o600)
.then((handle) => handle.writeFile(output).finally(() => handle.close()))
.then(
() => setDumpPath(file),
(error) => setDumpError(errorMessage(error)),
)
setDumping(false)
}
return (
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={themeV2.raise(themeV2.background.default)}>
<Show when={panel()}>
<box
position="absolute"
zIndex={2400}
left={0}
bottom={1}
width={dimensions().width}
height={Math.max(0, dimensions().height - 1)}
backgroundColor="transparent"
onMouseUp={() => setPanel()}
/>
</Show>
<BarItem active={panel() === "server"} onClick={() => toggle("server")}>
<text
fg={
panel() === "server"
? themeV2.text.action.primary.focused
: serverIndicator().state === "connected"
? themeV2.text.feedback.success.default
: serverIndicator().state === "disconnected"
? themeV2.text.feedback.error.default
: themeV2.text.default
}
>
{serverIndicator().icon}
</text>
<text
fg={
panel() === "server"
? themeV2.text.action.primary.focused
: serverIndicator().state === "disconnected"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
}
>
{" "}
Server
</text>
<Show when={panel() === "server"}>
<PanelBox>
<PanelTitle>Server</PanelTitle>
<Row label="Status" value={connected() ? "Connected" : client.connection.status()} />
<Show when={client.connection.attempt() > 0}>
<Row label="Reconnect" value={String(client.connection.attempt())} />
</Show>
<Show when={client.connection.error()}>{(error) => <Row label="Last error" value={error()} />}</Show>
<Show when={server()}>
{(value) => (
<>
<Row label="Version" value={value().health.version} />
<Row label="PID" value={String(value().health.pid)} />
<Row label="Address" value={value().address} />
</>
)}
</Show>
<Show when={server.error}>
<text fg={elevatedTheme.text.feedback.error.default}>Server details unavailable</text>
</Show>
</PanelBox>
</Show>
</BarItem>
<BarItem active={panel() === "ui"} onClick={() => toggle("ui")}>
<text
fg={
panel() === "ui"
? themeV2.text.action.primary.focused
: runtime() === "high"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
}
>
{statusIcon(runtime())}
</text>
<text
fg={
panel() === "ui"
? themeV2.text.action.primary.focused
: runtime() === "high"
? themeV2.text.feedback.error.default
: themeV2.text.subdued
}
>
{" "}
UI
</text>
<Show when={panel() === "ui"}>
<PanelBox>
<PanelTitle>UI</PanelTitle>
<Row label="Status" value={runtime()} />
<ProcessStat
label="Loop"
values={frontendSamples().map((sample) => sample.delay)}
unit=" ms"
decimals={1}
/>
<ProcessStat label="CPU" values={frontendSamples().map((sample) => sample.cpu)} unit="%" />
<ProcessStat
label="Memory"
values={frontendSamples().map((sample) => sample.memory / 1024 / 1024)}
unit=" MB"
decimals={0}
/>
</PanelBox>
</Show>
</BarItem>
<BarItem active={panel() === "theme"} onClick={() => toggle("theme")}>
<text fg={panel() === "theme" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Theme</text>
<Show when={panel() === "theme"}>
<PanelBox>
<PanelTitle>Theme</PanelTitle>
<Row label="Name" value={theme.selected} />
<Row label="Mode" value={mode()} />
<For each={themePerformance()}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
<Show when={canSwitchMode()}>
<Action onClick={() => setMode(nextMode())} hoverBackground>
Switch to {nextMode()}
</Action>
</Show>
</PanelBox>
</Show>
</BarItem>
<BarItem active={panel() === "tools"} onClick={() => toggle("tools")}>
<text fg={panel() === "tools" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Tools</text>
<Show when={panel() === "tools"}>
<PanelBox>
<PanelTitle>Tools</PanelTitle>
<Action onClick={() => void dump()} disabled={dumping()} hoverBackground>
{dumping() ? "Writing debug snapshot..." : "Write debug snapshot"}
</Action>
<Show when={dumpPath()}>
{(file) => (
<text fg={elevatedTheme.text.subdued} wrapMode="word">
{file()}
</text>
)}
</Show>
<Show when={dumpError()}>
{(error) => (
<text fg={elevatedTheme.text.feedback.error.default} wrapMode="word">
{error()}
</text>
)}
</Show>
<box marginTop={1}>
<text fg={elevatedTheme.text.default} attributes={TextAttributes.BOLD}>
Render
</text>
<Action
onClick={() =>
void config.update((draft) => {
draft.debug = { ...draft.debug, timing: !timing() }
})
}
hoverBackground
>
{timing() ? "[x]" : "[ ]"} Time to first draw
</Action>
</box>
<For each={groups()}>
{(group) => (
<box marginTop={1}>
<text fg={elevatedTheme.text.default} attributes={TextAttributes.BOLD}>
{group.title}
</text>
<For each={group.entries}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
</box>
)}
</For>
</PanelBox>
</Show>
</BarItem>
<box flexGrow={1} minWidth={0}>
<TimeToFirstDraw visible={timing()} width="100%" fg={themeV2.text.subdued} label="Time to first draw" />
</box>
</box>
)
}
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
const { themeV2 } = useTheme()
const renderer = useRenderer()
return (
<box
position="relative"
zIndex={props.active ? 2500 : undefined}
height={1}
flexDirection="row"
paddingLeft={1}
paddingRight={1}
backgroundColor={props.active ? themeV2.background.action.primary.focused : undefined}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
props.onClick()
}}
>
<box flexDirection="row">{props.children}</box>
</box>
)
}
function PanelBox(props: ParentProps) {
const { themeV2 } = useTheme().contextual("elevated")
const renderer = useRenderer()
return (
<box
position="absolute"
zIndex={2600}
bottom={1}
left={0}
width={42}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={themeV2.background.default}
flexDirection="column"
onMouseUp={(event) => {
if (renderer.getSelection()?.getSelectedText()) return
event.stopPropagation()
}}
>
{props.children}
</box>
)
}
function PanelTitle(props: ParentProps) {
const { themeV2 } = useTheme().contextual("elevated")
return (
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
{props.children}
</text>
)
}
function Row(props: { label: string; value: string }) {
const { themeV2 } = useTheme().contextual("elevated")
return (
<box flexDirection="row">
<text fg={themeV2.text.subdued}>{props.label}</text>
<box flexGrow={1} />
<text fg={themeV2.text.default}>{props.value}</text>
</box>
)
}
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
const { themeV2 } = useTheme().contextual("elevated")
const [hovered, setHovered] = createSignal(false)
return (
<box
backgroundColor={
props.hoverBackground && hovered() && !props.disabled ? themeV2.background.action.primary.hovered : undefined
}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseUp={(event) => {
event.stopPropagation()
if (!props.disabled) props.onClick()
}}
>
<text fg={props.disabled ? themeV2.text.subdued : themeV2.text.action.primary.default}>{props.children}</text>
</box>
)
}
function cpuPercent(microseconds: number, milliseconds: number) {
if (milliseconds <= 0) return 0
return Math.max(0, microseconds / (milliseconds * 10))
}
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
const { themeV2 } = useTheme().contextual("elevated")
const value = () => {
const value = props.values.at(-1)
if (value === undefined) return "--"
return `${value.toFixed(props.decimals ?? 1)}${props.unit}`
}
return (
<box flexDirection="row">
<box width={7}>
<text fg={themeV2.text.subdued}>{props.label}</text>
</box>
<box flexGrow={1}>
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{brailleGraph(props.values)}</text>
</box>
<box width={8} alignItems="flex-end">
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{value()}</text>
</box>
</box>
)
}
export function runtimeStatus(samples: readonly Readonly<{ delay: number; time: number }>[]): RuntimeStatus {
const latest = samples.at(-1)?.time
if (latest === undefined) return "normal"
const delay = Math.max(
0,
...samples.filter((sample) => sample.time > latest - statusWindowMilliseconds).map((sample) => sample.delay),
)
if (delay >= 100) return "high"
if (delay >= 20) return "medium"
return "normal"
}
export function statusIcon(status: RuntimeStatus) {
if (status === "high") return "●"
if (status === "medium") return "⦿"
return "○"
}
export function connectionIndicator(status: "connected" | "connecting" | "reconnecting", attempt: number) {
if (status === "connected") return { state: "connected" as const, icon: "✓" }
if (status === "reconnecting" && attempt >= 3) return { state: "disconnected" as const, icon: "×" }
return { state: "reconnecting" as const, icon: "↻" }
}
export function brailleGraph(values: readonly number[], width = graphWidth) {
const min = Math.min(...values)
const range = Math.max(...values) - min
const points = [...Array<number | undefined>(width * 2 - values.length).fill(values.at(0)), ...values].slice(
-width * 2,
)
const dots = [
[6, 2, 1, 0],
[7, 5, 4, 3],
]
return Array.from({ length: width }, (_, index) => {
const bits = [points[index * 2], points[index * 2 + 1]].reduce<number>((result, value, column) => {
if (value === undefined) return result
const height = 1 + Math.round((range === 0 ? 0 : (value - min) / range) * 3)
return dots[column].slice(0, height).reduce<number>((bits, dot) => bits | (1 << dot), result)
}, 0)
return String.fromCodePoint(0x2800 + bits)
}).join("")
}

View file

@ -1,65 +0,0 @@
import { TextAttributes } from "@opentui/core"
import { createSignal, For } from "solid-js"
import { useTheme } from "../context/theme"
import { DevTools } from "../devtools"
export function DevToolsSidebar() {
const { themeV2, mode, supports, setMode } = useTheme().contextual("elevated")
const [modeHovered, setModeHovered] = createSignal(false)
const nextMode = () => (mode() === "dark" ? "light" : "dark")
const canSwitchMode = () => supports(nextMode())
return (
<box
width={42}
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
backgroundColor={themeV2.background.default}
>
<box flexShrink={0} marginBottom={1}>
<box marginBottom={1}>
<text fg={themeV2.text.action.primary.default} attributes={TextAttributes.BOLD}>
Theme
</text>
</box>
<box flexDirection="row">
<text fg={themeV2.text.subdued}>Mode</text>
<box flexGrow={1} />
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={modeHovered() && canSwitchMode() ? themeV2.background.action.primary.hovered : undefined}
onMouseOver={() => setModeHovered(canSwitchMode())}
onMouseOut={() => setModeHovered(false)}
onMouseUp={canSwitchMode() ? () => setMode(nextMode()) : undefined}
>
<text fg={canSwitchMode() ? themeV2.text.default : themeV2.text.subdued}>{mode()}</text>
</box>
</box>
</box>
<For each={DevTools.data()}>
{(group) => (
<box flexShrink={0} marginBottom={1}>
<box marginBottom={1}>
<text fg={themeV2.text.action.primary.default} attributes={TextAttributes.BOLD}>
{group.title}
</text>
</box>
<For each={group.entries}>
{(entry) => (
<box flexDirection="row">
<text fg={themeV2.text.subdued}>{entry.key}</text>
<box flexGrow={1} />
<text fg={themeV2.text.default}>{String(entry.value)}</text>
</box>
)}
</For>
</box>
)}
</For>
</box>
)
}

View file

@ -35,7 +35,7 @@ const settings: Setting[] = [
title: "Animations",
category: "Appearance",
path: ["animations"],
default: true,
default: false,
values: [false, true],
labels: ["off", "on"],
},
@ -223,10 +223,10 @@ const settings: Setting[] = [
labels: ["off", "on"],
},
{
title: "Timing",
title: "DevTools: Timing",
category: "Debug",
path: ["debug", "timing"],
default: false,
default: true,
values: [false, true],
labels: ["off", "on"],
},