Forráskód Böngészése

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

James Long 3 hete
szülő
commit
8a36abd328

+ 9 - 22
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) {
                                                                 <EditorContextProvider>
                                                                   <PluginProvider packages={input.packages}>
                                                                     <App
-                                                                      started={appStarted}
                                                                       pair={
                                                                         input.server.endpoint.auth
                                                                           ? input.server.endpoint.auth
@@ -409,11 +403,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
   })
 })
 
-function App(props: { pair?: DialogPairCredentials; started: number }) {
+function App(props: { pair?: DialogPairCredentials }) {
   const log = useLog({ component: "app" })
+  const app = useTuiApp()
   const startup = useTuiStartup()
   const config = useConfig()
-  const devtools = createMemo(() => 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
       }
     >
-      <Show when={config.data.debug?.timing}>
-        <TimeToFirstDraw />
-      </Show>
       <box flexGrow={1} minHeight={0} flexDirection="row">
         <box flexGrow={1} minWidth={0} flexDirection="column">
           <Show when={plugins.ready()}>
@@ -1141,10 +1128,10 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
             <PluginSlot name="app" />
           </Show>
         </box>
-        <Show when={devtools()}>
-          <DevToolsSidebar />
-        </Show>
       </box>
+      <Show when={devtools()}>
+        <DevToolsBar />
+      </Show>
       <Show when={!startup.skipInitialLoading}>
         <StartupLoading ready={plugins.ready} />
       </Show>

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

+ 0 - 65
packages/tui/src/component/devtools-sidebar.tsx

@@ -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>
-  )
-}

+ 3 - 3
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"],
   },

+ 1 - 1
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" }),

+ 5 - 19
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 }
     </themeContext.context.Provider>
   )
 }
-
-function duration(milliseconds: number) {
-  return `${milliseconds.toFixed(2)} ms`
-}
-
 export function createSyntaxStyleMemo(factory: () => SyntaxStyle) {
   const renderer = useRenderer()
   const retained = new Set<SyntaxStyle>()

+ 0 - 17
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 {

+ 3 - 47
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) {
           </text>
         </box>
       </Show>
-      <Show when={store.format === "json"}>
-        <box
-          flexDirection="row"
-          gap={1}
-          backgroundColor={
-            store.active === "debug"
-              ? themeV2.background.formfield.focused
-              : store.debug
-                ? themeV2.background.formfield.selected
-                : themeV2.background.formfield.default
-          }
-          onMouseUp={() => {
-            setStore("active", "debug")
-            setStore("debug", !store.debug)
-          }}
-        >
-          <text
-            fg={
-              store.active === "debug"
-                ? themeV2.text.formfield.focused
-                : store.debug
-                  ? themeV2.text.formfield.selected
-                  : themeV2.text.formfield.default
-            }
-          >
-            {store.debug ? "[x]" : "[ ]"}
-          </text>
-          <text
-            fg={
-              store.active === "debug"
-                ? themeV2.text.formfield.focused
-                : store.debug
-                  ? themeV2.text.formfield.selected
-                  : themeV2.text.formfield.default
-            }
-          >
-            Events (debug)
-          </text>
-        </box>
-      </Show>
       <box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
         <box
           paddingLeft={4}
@@ -230,7 +187,6 @@ DialogExportOptions.show = (dialog: DialogContext, defaultThinking: boolean) =>
   return new Promise<{
     action: "copy" | "export"
     format: ExportFormat
-    debug: boolean
     thinking: boolean
   } | null>((resolve) => {
     dialog.replace(

+ 0 - 19
packages/tui/test/devtools.test.ts

@@ -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 },
-    ],
-  })
-})