diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 70433297795..5e600b65e84 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1,4 +1,4 @@ -import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { render, useRenderer, useTerminalDimensions } from "@opentui/solid" import { registerOpencodeSpinner } from "./component/register-spinner" import { Deferred, Effect } from "effect" import { Service, type Endpoint } from "@opencode-ai/client/effect/service" @@ -36,6 +36,7 @@ import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, + useTuiApp, useTuiStartup, type TuiApp, } from "./context/runtime" @@ -47,8 +48,7 @@ import { EditorContextProvider } from "./context/editor" import { useEvent } from "./context/event" import { ClientProvider, useClient } from "./context/client" import { StartupLoading } from "./component/startup-loading" -import { DevToolsSidebar } from "./component/devtools-sidebar" -import { DevTools } from "./devtools" +import { DevToolsBar } from "./component/devtools-bar" import { Reconnecting } from "./component/reconnecting" import { DataProvider, useData } from "./context/data" import { LocationProvider, useLocation } from "./context/location" @@ -88,8 +88,6 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" -const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" }) - registerOpencodeSpinner() const appGlobalBindingCommands = [ @@ -257,12 +255,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const pluginRuntime = createPluginRuntime() yield* Effect.tryPromise(async () => { - const appStarted = performance.now() // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. void renderer.getPalette({ size: 16 }).catch(() => undefined) - const modeStarted = performance.now() const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark" - themePerformance.set("Detect light/dark mode", `${(performance.now() - modeStarted).toFixed(2)} ms`) if (renderer.isDestroyed) return await render(() => { @@ -351,7 +346,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { config.data.debug?.devtools ?? false) + const devtools = createMemo(() => config.data.debug?.devtools ?? app.channel === "local") const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() @@ -433,11 +428,6 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { const plugins = usePlugin() const clipboard = useClipboard() - createEffect(() => { - if (!themeState.ready) return - themePerformance.set("Total", `${(performance.now() - props.started).toFixed(2)} ms`) - }) - // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act, // without having to open the status panel. Tracking the last alerted status avoids re-toasting // the same problem on every refresh while still re-alerting if the state changes. @@ -1110,9 +1100,6 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { : undefined } > - - - @@ -1141,10 +1128,10 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { - - - + + + diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx new file mode 100644 index 00000000000..fc168260a20 --- /dev/null +++ b/packages/tui/src/component/devtools-bar.tsx @@ -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() + const [dumping, setDumping] = createSignal(false) + const [dumpPath, setDumpPath] = createSignal() + const [dumpError, setDumpError] = createSignal() + const [frontendSamples, setFrontendSamples] = createSignal([]) + 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 ( + + + setPanel()} + /> + + toggle("server")}> + + {serverIndicator().icon} + + + {" "} + Server + + + + Server + + 0}> + + + {(error) => } + + {(value) => ( + <> + + + + + )} + + + Server details unavailable + + + + + toggle("ui")}> + + {statusIcon(runtime())} + + + {" "} + UI + + + + UI + + sample.delay)} + unit=" ms" + decimals={1} + /> + sample.cpu)} unit="%" /> + sample.memory / 1024 / 1024)} + unit=" MB" + decimals={0} + /> + + + + toggle("theme")}> + Theme + + + Theme + + + {(entry) => } + + setMode(nextMode())} hoverBackground> + Switch to {nextMode()} + + + + + + toggle("tools")}> + Tools + + + Tools + void dump()} disabled={dumping()} hoverBackground> + {dumping() ? "Writing debug snapshot..." : "Write debug snapshot"} + + + {(file) => ( + + {file()} + + )} + + + {(error) => ( + + {error()} + + )} + + + + Render + + + void config.update((draft) => { + draft.debug = { ...draft.debug, timing: !timing() } + }) + } + hoverBackground + > + {timing() ? "[x]" : "[ ]"} Time to first draw + + + + {(group) => ( + + + {group.title} + + {(entry) => } + + )} + + + + + + + + + ) +} + +function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) { + const { themeV2 } = useTheme() + const renderer = useRenderer() + return ( + { + if (renderer.getSelection()?.getSelectedText()) return + props.onClick() + }} + > + {props.children} + + ) +} + +function PanelBox(props: ParentProps) { + const { themeV2 } = useTheme().contextual("elevated") + const renderer = useRenderer() + return ( + { + if (renderer.getSelection()?.getSelectedText()) return + event.stopPropagation() + }} + > + {props.children} + + ) +} + +function PanelTitle(props: ParentProps) { + const { themeV2 } = useTheme().contextual("elevated") + return ( + + {props.children} + + ) +} + +function Row(props: { label: string; value: string }) { + const { themeV2 } = useTheme().contextual("elevated") + return ( + + {props.label} + + {props.value} + + ) +} + +function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) { + const { themeV2 } = useTheme().contextual("elevated") + const [hovered, setHovered] = createSignal(false) + return ( + setHovered(true)} + onMouseOut={() => setHovered(false)} + onMouseUp={(event) => { + event.stopPropagation() + if (!props.disabled) props.onClick() + }} + > + {props.children} + + ) +} + +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 ( + + + {props.label} + + + {brailleGraph(props.values)} + + + {value()} + + + ) +} + +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(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((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((bits, dot) => bits | (1 << dot), result) + }, 0) + return String.fromCodePoint(0x2800 + bits) + }).join("") +} diff --git a/packages/tui/src/component/devtools-sidebar.tsx b/packages/tui/src/component/devtools-sidebar.tsx deleted file mode 100644 index cca19652329..00000000000 --- a/packages/tui/src/component/devtools-sidebar.tsx +++ /dev/null @@ -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 ( - - - - - Theme - - - - Mode - - setModeHovered(canSwitchMode())} - onMouseOut={() => setModeHovered(false)} - onMouseUp={canSwitchMode() ? () => setMode(nextMode()) : undefined} - > - {mode()} - - - - - {(group) => ( - - - - {group.title} - - - - {(entry) => ( - - {entry.key} - - {String(entry.value)} - - )} - - - )} - - - ) -} diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index bdacb53b457..ab84fdf6233 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -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"], }, diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 6f5b78185e1..3d668183900 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -151,7 +151,7 @@ export const Info = Schema.Struct({ ).annotate({ description: "In-product guidance settings" }), debug: Schema.optional( Schema.Struct({ - devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools sidebar" }), + devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }), timing: Schema.optional(Schema.Boolean).annotate({ description: "Show time-to-first-draw diagnostics" }), }), ).annotate({ description: "Debugging settings" }), diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index a29c25501cf..88f91cb50de 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -162,7 +162,6 @@ const themeContext = createSimpleContext({ let systemThemeMode: "dark" | "light" | undefined let hasResolvedSystemTheme = false function resolveSystemTheme(mode: "dark" | "light" = store.mode) { - const started = performance.now() return renderer .getPalette({ size: 16 }) .then((colors: TerminalColors) => { @@ -186,7 +185,6 @@ const themeContext = createSimpleContext({ setSystemTheme(undefined) if (store.active === "system") setStore("active", "opencode") }) - .finally(() => themePerformance.set("Resolve system palette", duration(performance.now() - started))) } let systemRefreshRunning = false @@ -272,14 +270,10 @@ const themeContext = createSimpleContext({ themeRefreshTimeouts.length = 0 }) + const initStarted = performance.now() const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode) const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode")) - const file = createMemo(() => { - const started = performance.now() - const result = migrateV1(source()) - themePerformance.set("Convert V1 to V2", duration(performance.now() - started)) - return result - }) + const file = createMemo(() => migrateV1(source())) const modes = createMemo(() => themeModes(file())) const mode = () => { const supported = modes() @@ -287,12 +281,9 @@ const themeContext = createSimpleContext({ return supported[0] ?? store.mode } const values = createMemo(() => resolveTheme(source(), mode())) - const valuesV2 = createMemo(() => { - const resolveStarted = performance.now() - const result = resolveThemeFile(file(), mode(), sourceName()) - themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted)) - return result - }) + const valuesV2 = createMemo(() => resolveThemeFile(file(), mode(), sourceName())) + valuesV2() + themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`) const themeV2 = createComponentTheme(valuesV2, mode) const contextsV2 = { elevated: createComponentTheme(() => { @@ -374,11 +365,6 @@ export function ThemeContextProvider(props: ParentProps<{ context: ContextName } ) } - -function duration(milliseconds: number) { - return `${milliseconds.toFixed(2)} ms` -} - export function createSyntaxStyleMemo(factory: () => SyntaxStyle) { const renderer = useRenderer() const retained = new Set() diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index ca776bb2281..69c9682ec15 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -762,23 +762,6 @@ export function Session() { options.format === "markdown" ? formatSessionTranscript(sessionData, messages(), options.thinking) : await (async () => { - if (options.debug) { - const events: { readonly created: number }[] = [] - for await (const event of client.api.session.log({ sessionID: sessionData.id, 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 JSON.stringify({ info: sessionData, events }, null, 2) + EOL - } - const messages: unknown[] = [] let cursor: string | undefined do { diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx index 104aa04be2d..13d67d58086 100644 --- a/packages/tui/src/ui/dialog-export-options.tsx +++ b/packages/tui/src/ui/dialog-export-options.tsx @@ -9,11 +9,11 @@ export type ExportFormat = "markdown" | "json" export type DialogExportOptionsProps = { defaultThinking: boolean - onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; debug: boolean; thinking: boolean }) => void + onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; thinking: boolean }) => void onCancel?: () => void } -type Active = ExportFormat | "debug" | "thinking" | "copy" | "export" +type Active = ExportFormat | "thinking" | "copy" | "export" export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() @@ -21,7 +21,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { const { themeV2: overlayTheme } = useTheme().contextual("overlay") const [store, setStore] = createStore({ format: "markdown" as ExportFormat, - debug: false, thinking: props.defaultThinking, active: "markdown" as Active, }) @@ -30,7 +29,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { props.onConfirm?.({ action, format: store.format, - debug: store.debug, thinking: store.thinking, }) @@ -39,7 +37,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { setStore("format", store.active) return } - if (store.active === "debug") setStore("debug", !store.debug) if (store.active === "thinking") setStore("thinking", !store.thinking) if (store.active === "copy" || store.active === "export") confirm(store.active) } @@ -55,7 +52,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { const order: Active[] = store.format === "markdown" ? ["markdown", "json", "thinking", "copy", "export"] - : ["markdown", "json", "debug", "copy", "export"] + : ["markdown", "json", "copy", "export"] setStore("active", order[(order.indexOf(store.active) + 1) % order.length]) }, }, @@ -156,46 +153,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { - - { - setStore("active", "debug") - setStore("debug", !store.debug) - }} - > - - {store.debug ? "[x]" : "[ ]"} - - - Events (debug) - - - return new Promise<{ action: "copy" | "export" format: ExportFormat - debug: boolean thinking: boolean } | null>((resolve) => { dialog.replace( diff --git a/packages/tui/test/devtools.test.ts b/packages/tui/test/devtools.test.ts deleted file mode 100644 index 5997c8aa4e9..00000000000 --- a/packages/tui/test/devtools.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { expect, test } from "bun:test" -import { DevTools } from "../src/devtools" - -test("registers and updates grouped DevTools data", () => { - const group = DevTools.register({ id: "test", title: "Test data" }) - - group.set("Duration", "1.00 ms") - group.set("Duration", "2.00 ms") - group.set("Count", 2) - - expect(DevTools.data().find((item) => item.id === "test")).toEqual({ - id: "test", - title: "Test data", - entries: [ - { key: "Duration", value: "2.00 ms" }, - { key: "Count", value: 2 }, - ], - }) -})