|
@@ -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("")
|
|
|
|
|
+}
|