opencode/packages/tui/src/component/devtools-bar.tsx

572 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { TextAttributes, type Renderable } 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, useThemes } 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 themes = useThemes()
const keymap = Keymap.use()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { current: theme, mode, supports, setMode } = themes
const elevatedTheme = useTheme("elevated")
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[]>([])
let focus: Renderable | null
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 close = () => {
setPanel()
setTimeout(() => {
if (panel() || !focus || focus.isDestroyed) return
focus.focus()
focus = null
}, 1)
}
const toggle = (next: Panel) => {
if (panel() === next) return close()
if (!panel()) {
focus = renderer.currentFocusedRenderable
focus?.blur()
}
setPanel(next)
}
const nextMode = () => (mode() === "dark" ? "light" : "dark")
const canSwitchMode = () => supports(nextMode())
const runtime = createMemo(() => runtimeStatus(frontendSamples()))
const timing = () => config.data.debug?.timing ?? false
const turnTokens = () => config.data.debug?.turn_tokens ?? false
const verboseTurnTokens = () => turnTokens() === "verbose"
const offEscape = keymap.intercept(
"key",
({ event }) => {
if (!panel() || event.name !== "escape") return
event.preventDefault()
event.stopPropagation()
close()
},
{ 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: themes.selected,
mode: themes.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={theme.raise(theme.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={close}
/>
</Show>
<BarItem active={panel() === "server"} onClick={() => toggle("server")}>
<text
fg={
panel() === "server"
? theme.text.action.primary.focused
: serverIndicator().state === "connected"
? theme.text.feedback.success.default
: serverIndicator().state === "disconnected"
? theme.text.feedback.error.default
: theme.text.default
}
>
{serverIndicator().icon}
</text>
<text
fg={
panel() === "server"
? theme.text.action.primary.focused
: serverIndicator().state === "disconnected"
? theme.text.feedback.error.default
: theme.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"
? theme.text.action.primary.focused
: runtime() === "high"
? theme.text.feedback.error.default
: theme.text.subdued
}
>
{statusIcon(runtime())}
</text>
<text
fg={
panel() === "ui"
? theme.text.action.primary.focused
: runtime() === "high"
? theme.text.feedback.error.default
: theme.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" ? theme.text.action.primary.focused : theme.text.subdued}>Theme</text>
<Show when={panel() === "theme"}>
<PanelBox>
<PanelTitle>Theme</PanelTitle>
<Row label="Name" value={themes.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" ? theme.text.action.primary.focused : theme.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>
<Action
onClick={() =>
void config.update((draft) => {
draft.debug = { ...draft.debug, turn_tokens: !turnTokens() }
})
}
hoverBackground
>
{turnTokens() ? "[x]" : "[ ]"} Turn token usage
</Action>
<Action
onClick={() =>
void config.update((draft) => {
draft.debug = { ...draft.debug, turn_tokens: verboseTurnTokens() ? true : "verbose" }
})
}
hoverBackground
>
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
</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={theme.text.subdued} label="Time to first draw" />
</box>
</box>
)
}
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
const theme = useTheme()
const renderer = useRenderer()
return (
<box
position="relative"
zIndex={props.active ? 2500 : undefined}
height={1}
flexDirection="row"
paddingLeft={1}
paddingRight={1}
backgroundColor={props.active ? theme.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 theme = useTheme("elevated")
const renderer = useRenderer()
return (
<box
position="absolute"
zIndex={2600}
bottom={1}
left={-1}
width={42}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.background.default}
flexDirection="column"
onMouseUp={(event) => {
if (renderer.getSelection()?.getSelectedText()) return
event.stopPropagation()
}}
>
{props.children}
</box>
)
}
function PanelTitle(props: ParentProps) {
const theme = useTheme("elevated")
return (
<text fg={theme.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
{props.children}
</text>
)
}
function Row(props: { label: string; value: string }) {
const theme = useTheme("elevated")
return (
<box flexDirection="row">
<text fg={theme.text.subdued}>{props.label}</text>
<box flexGrow={1} />
<text fg={theme.text.default}>{props.value}</text>
</box>
)
}
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
const theme = useTheme("elevated")
const [hovered, setHovered] = createSignal(false)
return (
<box
backgroundColor={
props.hoverBackground && hovered() && !props.disabled ? theme.background.action.primary.hovered : undefined
}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseUp={(event) => {
event.stopPropagation()
if (!props.disabled) props.onClick()
}}
>
<text fg={props.disabled ? theme.text.subdued : theme.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 theme = useTheme("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={theme.text.subdued}>{props.label}</text>
</box>
<box flexGrow={1}>
<text fg={props.values.length ? theme.text.default : theme.text.subdued}>{brailleGraph(props.values)}</text>
</box>
<box width={8} alignItems="flex-end">
<text fg={props.values.length ? theme.text.default : theme.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("")
}