feat(tui): add developer debug bar (#38359)
This commit is contained in:
parent
532292b5f3
commit
8a36abd328
9 changed files with 554 additions and 193 deletions
|
|
@ -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 { registerOpencodeSpinner } from "./component/register-spinner"
|
||||||
import { Deferred, Effect } from "effect"
|
import { Deferred, Effect } from "effect"
|
||||||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
TuiPathsProvider,
|
TuiPathsProvider,
|
||||||
TuiStartupProvider,
|
TuiStartupProvider,
|
||||||
TuiTerminalEnvironmentProvider,
|
TuiTerminalEnvironmentProvider,
|
||||||
|
useTuiApp,
|
||||||
useTuiStartup,
|
useTuiStartup,
|
||||||
type TuiApp,
|
type TuiApp,
|
||||||
} from "./context/runtime"
|
} from "./context/runtime"
|
||||||
|
|
@ -47,8 +48,7 @@ import { EditorContextProvider } from "./context/editor"
|
||||||
import { useEvent } from "./context/event"
|
import { useEvent } from "./context/event"
|
||||||
import { ClientProvider, useClient } from "./context/client"
|
import { ClientProvider, useClient } from "./context/client"
|
||||||
import { StartupLoading } from "./component/startup-loading"
|
import { StartupLoading } from "./component/startup-loading"
|
||||||
import { DevToolsSidebar } from "./component/devtools-sidebar"
|
import { DevToolsBar } from "./component/devtools-bar"
|
||||||
import { DevTools } from "./devtools"
|
|
||||||
import { Reconnecting } from "./component/reconnecting"
|
import { Reconnecting } from "./component/reconnecting"
|
||||||
import { DataProvider, useData } from "./context/data"
|
import { DataProvider, useData } from "./context/data"
|
||||||
import { LocationProvider, useLocation } from "./context/location"
|
import { LocationProvider, useLocation } from "./context/location"
|
||||||
|
|
@ -88,8 +88,6 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi
|
||||||
import { destroyRenderer } from "./util/renderer"
|
import { destroyRenderer } from "./util/renderer"
|
||||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||||
|
|
||||||
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
|
|
||||||
|
|
||||||
registerOpencodeSpinner()
|
registerOpencodeSpinner()
|
||||||
|
|
||||||
const appGlobalBindingCommands = [
|
const appGlobalBindingCommands = [
|
||||||
|
|
@ -257,12 +255,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||||
const pluginRuntime = createPluginRuntime()
|
const pluginRuntime = createPluginRuntime()
|
||||||
|
|
||||||
yield* Effect.tryPromise(async () => {
|
yield* Effect.tryPromise(async () => {
|
||||||
const appStarted = performance.now()
|
|
||||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||||
const modeStarted = performance.now()
|
|
||||||
const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
|
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
|
if (renderer.isDestroyed) return
|
||||||
|
|
||||||
await render(() => {
|
await render(() => {
|
||||||
|
|
@ -351,7 +346,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||||
<EditorContextProvider>
|
<EditorContextProvider>
|
||||||
<PluginProvider packages={input.packages}>
|
<PluginProvider packages={input.packages}>
|
||||||
<App
|
<App
|
||||||
started={appStarted}
|
|
||||||
pair={
|
pair={
|
||||||
input.server.endpoint.auth
|
input.server.endpoint.auth
|
||||||
? 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 log = useLog({ component: "app" })
|
||||||
|
const app = useTuiApp()
|
||||||
const startup = useTuiStartup()
|
const startup = useTuiStartup()
|
||||||
const config = useConfig()
|
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 route = useRoute()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
@ -433,11 +428,6 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||||
const plugins = usePlugin()
|
const plugins = usePlugin()
|
||||||
const clipboard = useClipboard()
|
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,
|
// 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
|
// 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.
|
// 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
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show when={config.data.debug?.timing}>
|
|
||||||
<TimeToFirstDraw />
|
|
||||||
</Show>
|
|
||||||
<box flexGrow={1} minHeight={0} flexDirection="row">
|
<box flexGrow={1} minHeight={0} flexDirection="row">
|
||||||
<box flexGrow={1} minWidth={0} flexDirection="column">
|
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||||
<Show when={plugins.ready()}>
|
<Show when={plugins.ready()}>
|
||||||
|
|
@ -1141,10 +1128,10 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||||
<PluginSlot name="app" />
|
<PluginSlot name="app" />
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<Show when={devtools()}>
|
|
||||||
<DevToolsSidebar />
|
|
||||||
</Show>
|
|
||||||
</box>
|
</box>
|
||||||
|
<Show when={devtools()}>
|
||||||
|
<DevToolsBar />
|
||||||
|
</Show>
|
||||||
<Show when={!startup.skipInitialLoading}>
|
<Show when={!startup.skipInitialLoading}>
|
||||||
<StartupLoading ready={plugins.ready} />
|
<StartupLoading ready={plugins.ready} />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
533
packages/tui/src/component/devtools-bar.tsx
Normal file
533
packages/tui/src/component/devtools-bar.tsx
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
import { TextAttributes } from "@opentui/core"
|
||||||
|
import { TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
|
import { open } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { monitorEventLoopDelay } from "node:perf_hooks"
|
||||||
|
import { createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type ParentProps } from "solid-js"
|
||||||
|
import { useClient } from "../context/client"
|
||||||
|
import { useConfig } from "../config"
|
||||||
|
import { useData } from "../context/data"
|
||||||
|
import { useLocation } from "../context/location"
|
||||||
|
import { useRoute } from "../context/route"
|
||||||
|
import { Keymap } from "../context/keymap"
|
||||||
|
import { useTheme } from "../context/theme"
|
||||||
|
import { DevTools } from "../devtools"
|
||||||
|
import { usePlugin } from "../plugin/context"
|
||||||
|
import { errorMessage } from "../util/error"
|
||||||
|
|
||||||
|
const graphWidth = 23
|
||||||
|
const sampleIntervalMilliseconds = 2_000
|
||||||
|
const sampleRetentionMilliseconds = 30_000
|
||||||
|
const statusWindowMilliseconds = 6_000
|
||||||
|
type Panel = "server" | "theme" | "tools" | "ui"
|
||||||
|
type ProcessSample = Readonly<{ cpu: number; memory: number; delay: number; time: number }>
|
||||||
|
export type RuntimeStatus = "normal" | "medium" | "high"
|
||||||
|
|
||||||
|
export function DevToolsBar() {
|
||||||
|
const client = useClient()
|
||||||
|
const config = useConfig()
|
||||||
|
const data = useData()
|
||||||
|
const location = useLocation()
|
||||||
|
const route = useRoute()
|
||||||
|
const plugins = usePlugin()
|
||||||
|
const theme = useTheme()
|
||||||
|
const keymap = Keymap.use()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
|
const { themeV2, mode, supports, setMode } = theme
|
||||||
|
const elevatedTheme = theme.contextual("elevated").themeV2
|
||||||
|
const [panel, setPanel] = createSignal<Panel>()
|
||||||
|
const [dumping, setDumping] = createSignal(false)
|
||||||
|
const [dumpPath, setDumpPath] = createSignal<string>()
|
||||||
|
const [dumpError, setDumpError] = createSignal<string>()
|
||||||
|
const [frontendSamples, setFrontendSamples] = createSignal<readonly ProcessSample[]>([])
|
||||||
|
const connected = createMemo(() => client.connection.status() === "connected")
|
||||||
|
const serverIndicator = createMemo(() => connectionIndicator(client.connection.status(), client.connection.attempt()))
|
||||||
|
const themePerformance = createMemo(
|
||||||
|
() => DevTools.data().find((group) => group.id === "theme-performance")?.entries ?? [],
|
||||||
|
)
|
||||||
|
const groups = createMemo(() => DevTools.data().filter((group) => group.id !== "theme-performance"))
|
||||||
|
const [server] = createResource(connected, async () => {
|
||||||
|
const [health, info] = await Promise.all([client.api.health.get(), client.api.server.get()])
|
||||||
|
return {
|
||||||
|
health,
|
||||||
|
address: info.urls[0] ? new URL(info.urls[0]).host : "Unknown",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const toggle = (next: Panel) => setPanel((current) => (current === next ? undefined : next))
|
||||||
|
const nextMode = () => (mode() === "dark" ? "light" : "dark")
|
||||||
|
const canSwitchMode = () => supports(nextMode())
|
||||||
|
const runtime = createMemo(() => runtimeStatus(frontendSamples()))
|
||||||
|
const timing = () => config.data.debug?.timing ?? false
|
||||||
|
|
||||||
|
const offEscape = keymap.intercept(
|
||||||
|
"key",
|
||||||
|
({ event }) => {
|
||||||
|
if (!panel() || event.name !== "escape") return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
setPanel()
|
||||||
|
},
|
||||||
|
{ priority: 10 },
|
||||||
|
)
|
||||||
|
onCleanup(offEscape)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const eventLoop = monitorEventLoopDelay({ resolution: 20 })
|
||||||
|
let frontendCPU = process.cpuUsage()
|
||||||
|
let frontendTime = performance.now()
|
||||||
|
let frontendReady = false
|
||||||
|
eventLoop.enable()
|
||||||
|
const sample = () => {
|
||||||
|
const now = performance.now()
|
||||||
|
const cpu = process.cpuUsage(frontendCPU)
|
||||||
|
frontendCPU = process.cpuUsage()
|
||||||
|
setFrontendSamples((samples) =>
|
||||||
|
[
|
||||||
|
...samples,
|
||||||
|
{
|
||||||
|
cpu: frontendReady ? cpuPercent(cpu.user + cpu.system, now - frontendTime) : 0,
|
||||||
|
memory: process.memoryUsage().rss,
|
||||||
|
delay: eventLoop.percentile(99) / 1_000_000,
|
||||||
|
time: now,
|
||||||
|
},
|
||||||
|
].filter((sample) => sample.time >= now - sampleRetentionMilliseconds),
|
||||||
|
)
|
||||||
|
eventLoop.reset()
|
||||||
|
frontendReady = true
|
||||||
|
frontendTime = now
|
||||||
|
}
|
||||||
|
sample()
|
||||||
|
const timer = setInterval(sample, sampleIntervalMilliseconds)
|
||||||
|
onCleanup(() => {
|
||||||
|
clearInterval(timer)
|
||||||
|
eventLoop.disable()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function dump() {
|
||||||
|
setDumping(true)
|
||||||
|
setDumpPath()
|
||||||
|
setDumpError()
|
||||||
|
const routeData = route.data
|
||||||
|
const sessionID = routeData.type === "session" ? routeData.sessionID : undefined
|
||||||
|
const info = sessionID ? data.session.get(sessionID) : undefined
|
||||||
|
const sessionLocation =
|
||||||
|
info?.location ??
|
||||||
|
(location.current
|
||||||
|
? { directory: location.current.directory, workspaceID: location.current.workspaceID }
|
||||||
|
: undefined)
|
||||||
|
const details = server()
|
||||||
|
const backend = {
|
||||||
|
connected: connected(),
|
||||||
|
version: details?.health.version,
|
||||||
|
pid: details?.health.pid,
|
||||||
|
error: client.connection.error(),
|
||||||
|
}
|
||||||
|
const events = await (sessionID
|
||||||
|
? (async () => {
|
||||||
|
const events: { readonly created: number }[] = []
|
||||||
|
for await (const event of client.api.session.log({ sessionID, follow: false })) {
|
||||||
|
if (event.type !== "log.synced") events.push(event)
|
||||||
|
}
|
||||||
|
// Durable events stay in aggregate order even when their wall-clock timestamps differ.
|
||||||
|
client.connection.internal.history().forEach((event) => {
|
||||||
|
const index = events.findIndex((item) => item.created > event.created)
|
||||||
|
if (index === -1) {
|
||||||
|
events.push(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
events.splice(index, 0, event)
|
||||||
|
})
|
||||||
|
return events.slice(-100)
|
||||||
|
})().catch(() => client.connection.internal.history())
|
||||||
|
: Promise.resolve([]))
|
||||||
|
const file = path.join(tmpdir(), `opencode-debug-${crypto.randomUUID()}.json`)
|
||||||
|
const output =
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
backend,
|
||||||
|
session: sessionID
|
||||||
|
? {
|
||||||
|
...info,
|
||||||
|
id: sessionID,
|
||||||
|
projectID: info?.projectID ?? location.current?.project.id,
|
||||||
|
location: sessionLocation,
|
||||||
|
status: data.session.status(sessionID),
|
||||||
|
pending: data.session.pending.list(sessionID),
|
||||||
|
inputIDs: data.session.input.list(sessionID),
|
||||||
|
permissions: data.session.permission.list(sessionID) ?? [],
|
||||||
|
forms: data.session.form.list(sessionID) ?? [],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
events,
|
||||||
|
mcp: {
|
||||||
|
servers: sessionLocation
|
||||||
|
? (data.location.mcp.server.list(sessionLocation) ?? [])
|
||||||
|
: (data.location.mcp.server.list() ?? []),
|
||||||
|
resources: sessionLocation
|
||||||
|
? (data.location.mcp.resource.list(sessionLocation) ?? [])
|
||||||
|
: (data.location.mcp.resource.list() ?? []),
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
ready: plugins.ready(),
|
||||||
|
list: plugins.list().map((plugin) => ({
|
||||||
|
name: "id" in plugin ? plugin.id : plugin.target,
|
||||||
|
...plugin,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
name: theme.selected,
|
||||||
|
mode: theme.mode(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + "\n"
|
||||||
|
await open(file, "wx", 0o600)
|
||||||
|
.then((handle) => handle.writeFile(output).finally(() => handle.close()))
|
||||||
|
.then(
|
||||||
|
() => setDumpPath(file),
|
||||||
|
(error) => setDumpError(errorMessage(error)),
|
||||||
|
)
|
||||||
|
setDumping(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={themeV2.raise(themeV2.background.default)}>
|
||||||
|
<Show when={panel()}>
|
||||||
|
<box
|
||||||
|
position="absolute"
|
||||||
|
zIndex={2400}
|
||||||
|
left={0}
|
||||||
|
bottom={1}
|
||||||
|
width={dimensions().width}
|
||||||
|
height={Math.max(0, dimensions().height - 1)}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
onMouseUp={() => setPanel()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<BarItem active={panel() === "server"} onClick={() => toggle("server")}>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
panel() === "server"
|
||||||
|
? themeV2.text.action.primary.focused
|
||||||
|
: serverIndicator().state === "connected"
|
||||||
|
? themeV2.text.feedback.success.default
|
||||||
|
: serverIndicator().state === "disconnected"
|
||||||
|
? themeV2.text.feedback.error.default
|
||||||
|
: themeV2.text.default
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{serverIndicator().icon}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
panel() === "server"
|
||||||
|
? themeV2.text.action.primary.focused
|
||||||
|
: serverIndicator().state === "disconnected"
|
||||||
|
? themeV2.text.feedback.error.default
|
||||||
|
: themeV2.text.subdued
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
Server
|
||||||
|
</text>
|
||||||
|
<Show when={panel() === "server"}>
|
||||||
|
<PanelBox>
|
||||||
|
<PanelTitle>Server</PanelTitle>
|
||||||
|
<Row label="Status" value={connected() ? "Connected" : client.connection.status()} />
|
||||||
|
<Show when={client.connection.attempt() > 0}>
|
||||||
|
<Row label="Reconnect" value={String(client.connection.attempt())} />
|
||||||
|
</Show>
|
||||||
|
<Show when={client.connection.error()}>{(error) => <Row label="Last error" value={error()} />}</Show>
|
||||||
|
<Show when={server()}>
|
||||||
|
{(value) => (
|
||||||
|
<>
|
||||||
|
<Row label="Version" value={value().health.version} />
|
||||||
|
<Row label="PID" value={String(value().health.pid)} />
|
||||||
|
<Row label="Address" value={value().address} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={server.error}>
|
||||||
|
<text fg={elevatedTheme.text.feedback.error.default}>Server details unavailable</text>
|
||||||
|
</Show>
|
||||||
|
</PanelBox>
|
||||||
|
</Show>
|
||||||
|
</BarItem>
|
||||||
|
<BarItem active={panel() === "ui"} onClick={() => toggle("ui")}>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
panel() === "ui"
|
||||||
|
? themeV2.text.action.primary.focused
|
||||||
|
: runtime() === "high"
|
||||||
|
? themeV2.text.feedback.error.default
|
||||||
|
: themeV2.text.subdued
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{statusIcon(runtime())}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
panel() === "ui"
|
||||||
|
? themeV2.text.action.primary.focused
|
||||||
|
: runtime() === "high"
|
||||||
|
? themeV2.text.feedback.error.default
|
||||||
|
: themeV2.text.subdued
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
UI
|
||||||
|
</text>
|
||||||
|
<Show when={panel() === "ui"}>
|
||||||
|
<PanelBox>
|
||||||
|
<PanelTitle>UI</PanelTitle>
|
||||||
|
<Row label="Status" value={runtime()} />
|
||||||
|
<ProcessStat
|
||||||
|
label="Loop"
|
||||||
|
values={frontendSamples().map((sample) => sample.delay)}
|
||||||
|
unit=" ms"
|
||||||
|
decimals={1}
|
||||||
|
/>
|
||||||
|
<ProcessStat label="CPU" values={frontendSamples().map((sample) => sample.cpu)} unit="%" />
|
||||||
|
<ProcessStat
|
||||||
|
label="Memory"
|
||||||
|
values={frontendSamples().map((sample) => sample.memory / 1024 / 1024)}
|
||||||
|
unit=" MB"
|
||||||
|
decimals={0}
|
||||||
|
/>
|
||||||
|
</PanelBox>
|
||||||
|
</Show>
|
||||||
|
</BarItem>
|
||||||
|
<BarItem active={panel() === "theme"} onClick={() => toggle("theme")}>
|
||||||
|
<text fg={panel() === "theme" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Theme</text>
|
||||||
|
<Show when={panel() === "theme"}>
|
||||||
|
<PanelBox>
|
||||||
|
<PanelTitle>Theme</PanelTitle>
|
||||||
|
<Row label="Name" value={theme.selected} />
|
||||||
|
<Row label="Mode" value={mode()} />
|
||||||
|
<For each={themePerformance()}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
|
||||||
|
<Show when={canSwitchMode()}>
|
||||||
|
<Action onClick={() => setMode(nextMode())} hoverBackground>
|
||||||
|
Switch to {nextMode()}
|
||||||
|
</Action>
|
||||||
|
</Show>
|
||||||
|
</PanelBox>
|
||||||
|
</Show>
|
||||||
|
</BarItem>
|
||||||
|
<BarItem active={panel() === "tools"} onClick={() => toggle("tools")}>
|
||||||
|
<text fg={panel() === "tools" ? themeV2.text.action.primary.focused : themeV2.text.subdued}>Tools</text>
|
||||||
|
<Show when={panel() === "tools"}>
|
||||||
|
<PanelBox>
|
||||||
|
<PanelTitle>Tools</PanelTitle>
|
||||||
|
<Action onClick={() => void dump()} disabled={dumping()} hoverBackground>
|
||||||
|
{dumping() ? "Writing debug snapshot..." : "Write debug snapshot"}
|
||||||
|
</Action>
|
||||||
|
<Show when={dumpPath()}>
|
||||||
|
{(file) => (
|
||||||
|
<text fg={elevatedTheme.text.subdued} wrapMode="word">
|
||||||
|
{file()}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={dumpError()}>
|
||||||
|
{(error) => (
|
||||||
|
<text fg={elevatedTheme.text.feedback.error.default} wrapMode="word">
|
||||||
|
{error()}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<box marginTop={1}>
|
||||||
|
<text fg={elevatedTheme.text.default} attributes={TextAttributes.BOLD}>
|
||||||
|
Render
|
||||||
|
</text>
|
||||||
|
<Action
|
||||||
|
onClick={() =>
|
||||||
|
void config.update((draft) => {
|
||||||
|
draft.debug = { ...draft.debug, timing: !timing() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hoverBackground
|
||||||
|
>
|
||||||
|
{timing() ? "[x]" : "[ ]"} Time to first draw
|
||||||
|
</Action>
|
||||||
|
</box>
|
||||||
|
<For each={groups()}>
|
||||||
|
{(group) => (
|
||||||
|
<box marginTop={1}>
|
||||||
|
<text fg={elevatedTheme.text.default} attributes={TextAttributes.BOLD}>
|
||||||
|
{group.title}
|
||||||
|
</text>
|
||||||
|
<For each={group.entries}>{(entry) => <Row label={entry.key} value={String(entry.value)} />}</For>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</PanelBox>
|
||||||
|
</Show>
|
||||||
|
</BarItem>
|
||||||
|
<box flexGrow={1} minWidth={0}>
|
||||||
|
<TimeToFirstDraw visible={timing()} width="100%" fg={themeV2.text.subdued} label="Time to first draw" />
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) {
|
||||||
|
const { themeV2 } = useTheme()
|
||||||
|
const renderer = useRenderer()
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
position="relative"
|
||||||
|
zIndex={props.active ? 2500 : undefined}
|
||||||
|
height={1}
|
||||||
|
flexDirection="row"
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={props.active ? themeV2.background.action.primary.focused : undefined}
|
||||||
|
onMouseUp={() => {
|
||||||
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
|
props.onClick()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<box flexDirection="row">{props.children}</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelBox(props: ParentProps) {
|
||||||
|
const { themeV2 } = useTheme().contextual("elevated")
|
||||||
|
const renderer = useRenderer()
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
position="absolute"
|
||||||
|
zIndex={2600}
|
||||||
|
bottom={1}
|
||||||
|
left={0}
|
||||||
|
width={42}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
backgroundColor={themeV2.background.default}
|
||||||
|
flexDirection="column"
|
||||||
|
onMouseUp={(event) => {
|
||||||
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelTitle(props: ParentProps) {
|
||||||
|
const { themeV2 } = useTheme().contextual("elevated")
|
||||||
|
return (
|
||||||
|
<text fg={themeV2.text.default} attributes={TextAttributes.BOLD} marginBottom={1}>
|
||||||
|
{props.children}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row(props: { label: string; value: string }) {
|
||||||
|
const { themeV2 } = useTheme().contextual("elevated")
|
||||||
|
return (
|
||||||
|
<box flexDirection="row">
|
||||||
|
<text fg={themeV2.text.subdued}>{props.label}</text>
|
||||||
|
<box flexGrow={1} />
|
||||||
|
<text fg={themeV2.text.default}>{props.value}</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Action(props: ParentProps<{ onClick: () => void; disabled?: boolean; hoverBackground?: boolean }>) {
|
||||||
|
const { themeV2 } = useTheme().contextual("elevated")
|
||||||
|
const [hovered, setHovered] = createSignal(false)
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
backgroundColor={
|
||||||
|
props.hoverBackground && hovered() && !props.disabled ? themeV2.background.action.primary.hovered : undefined
|
||||||
|
}
|
||||||
|
onMouseOver={() => setHovered(true)}
|
||||||
|
onMouseOut={() => setHovered(false)}
|
||||||
|
onMouseUp={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!props.disabled) props.onClick()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={props.disabled ? themeV2.text.subdued : themeV2.text.action.primary.default}>{props.children}</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cpuPercent(microseconds: number, milliseconds: number) {
|
||||||
|
if (milliseconds <= 0) return 0
|
||||||
|
return Math.max(0, microseconds / (milliseconds * 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProcessStat(props: { label: string; values: readonly number[]; unit: string; decimals?: number }) {
|
||||||
|
const { themeV2 } = useTheme().contextual("elevated")
|
||||||
|
const value = () => {
|
||||||
|
const value = props.values.at(-1)
|
||||||
|
if (value === undefined) return "--"
|
||||||
|
return `${value.toFixed(props.decimals ?? 1)}${props.unit}`
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<box flexDirection="row">
|
||||||
|
<box width={7}>
|
||||||
|
<text fg={themeV2.text.subdued}>{props.label}</text>
|
||||||
|
</box>
|
||||||
|
<box flexGrow={1}>
|
||||||
|
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{brailleGraph(props.values)}</text>
|
||||||
|
</box>
|
||||||
|
<box width={8} alignItems="flex-end">
|
||||||
|
<text fg={props.values.length ? themeV2.text.default : themeV2.text.subdued}>{value()}</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeStatus(samples: readonly Readonly<{ delay: number; time: number }>[]): RuntimeStatus {
|
||||||
|
const latest = samples.at(-1)?.time
|
||||||
|
if (latest === undefined) return "normal"
|
||||||
|
const delay = Math.max(
|
||||||
|
0,
|
||||||
|
...samples.filter((sample) => sample.time > latest - statusWindowMilliseconds).map((sample) => sample.delay),
|
||||||
|
)
|
||||||
|
if (delay >= 100) return "high"
|
||||||
|
if (delay >= 20) return "medium"
|
||||||
|
return "normal"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusIcon(status: RuntimeStatus) {
|
||||||
|
if (status === "high") return "●"
|
||||||
|
if (status === "medium") return "⦿"
|
||||||
|
return "○"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectionIndicator(status: "connected" | "connecting" | "reconnecting", attempt: number) {
|
||||||
|
if (status === "connected") return { state: "connected" as const, icon: "✓" }
|
||||||
|
if (status === "reconnecting" && attempt >= 3) return { state: "disconnected" as const, icon: "×" }
|
||||||
|
return { state: "reconnecting" as const, icon: "↻" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brailleGraph(values: readonly number[], width = graphWidth) {
|
||||||
|
const min = Math.min(...values)
|
||||||
|
const range = Math.max(...values) - min
|
||||||
|
const points = [...Array<number | undefined>(width * 2 - values.length).fill(values.at(0)), ...values].slice(
|
||||||
|
-width * 2,
|
||||||
|
)
|
||||||
|
const dots = [
|
||||||
|
[6, 2, 1, 0],
|
||||||
|
[7, 5, 4, 3],
|
||||||
|
]
|
||||||
|
return Array.from({ length: width }, (_, index) => {
|
||||||
|
const bits = [points[index * 2], points[index * 2 + 1]].reduce<number>((result, value, column) => {
|
||||||
|
if (value === undefined) return result
|
||||||
|
const height = 1 + Math.round((range === 0 ? 0 : (value - min) / range) * 3)
|
||||||
|
return dots[column].slice(0, height).reduce<number>((bits, dot) => bits | (1 << dot), result)
|
||||||
|
}, 0)
|
||||||
|
return String.fromCodePoint(0x2800 + bits)
|
||||||
|
}).join("")
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -35,7 +35,7 @@ const settings: Setting[] = [
|
||||||
title: "Animations",
|
title: "Animations",
|
||||||
category: "Appearance",
|
category: "Appearance",
|
||||||
path: ["animations"],
|
path: ["animations"],
|
||||||
default: true,
|
default: false,
|
||||||
values: [false, true],
|
values: [false, true],
|
||||||
labels: ["off", "on"],
|
labels: ["off", "on"],
|
||||||
},
|
},
|
||||||
|
|
@ -223,10 +223,10 @@ const settings: Setting[] = [
|
||||||
labels: ["off", "on"],
|
labels: ["off", "on"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Timing",
|
title: "DevTools: Timing",
|
||||||
category: "Debug",
|
category: "Debug",
|
||||||
path: ["debug", "timing"],
|
path: ["debug", "timing"],
|
||||||
default: false,
|
default: true,
|
||||||
values: [false, true],
|
values: [false, true],
|
||||||
labels: ["off", "on"],
|
labels: ["off", "on"],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ export const Info = Schema.Struct({
|
||||||
).annotate({ description: "In-product guidance settings" }),
|
).annotate({ description: "In-product guidance settings" }),
|
||||||
debug: Schema.optional(
|
debug: Schema.optional(
|
||||||
Schema.Struct({
|
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" }),
|
timing: Schema.optional(Schema.Boolean).annotate({ description: "Show time-to-first-draw diagnostics" }),
|
||||||
}),
|
}),
|
||||||
).annotate({ description: "Debugging settings" }),
|
).annotate({ description: "Debugging settings" }),
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,6 @@ const themeContext = createSimpleContext({
|
||||||
let systemThemeMode: "dark" | "light" | undefined
|
let systemThemeMode: "dark" | "light" | undefined
|
||||||
let hasResolvedSystemTheme = false
|
let hasResolvedSystemTheme = false
|
||||||
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
||||||
const started = performance.now()
|
|
||||||
return renderer
|
return renderer
|
||||||
.getPalette({ size: 16 })
|
.getPalette({ size: 16 })
|
||||||
.then((colors: TerminalColors) => {
|
.then((colors: TerminalColors) => {
|
||||||
|
|
@ -186,7 +185,6 @@ const themeContext = createSimpleContext({
|
||||||
setSystemTheme(undefined)
|
setSystemTheme(undefined)
|
||||||
if (store.active === "system") setStore("active", "opencode")
|
if (store.active === "system") setStore("active", "opencode")
|
||||||
})
|
})
|
||||||
.finally(() => themePerformance.set("Resolve system palette", duration(performance.now() - started)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let systemRefreshRunning = false
|
let systemRefreshRunning = false
|
||||||
|
|
@ -272,14 +270,10 @@ const themeContext = createSimpleContext({
|
||||||
themeRefreshTimeouts.length = 0
|
themeRefreshTimeouts.length = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const initStarted = performance.now()
|
||||||
const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode)
|
const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode)
|
||||||
const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode"))
|
const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode"))
|
||||||
const file = createMemo(() => {
|
const file = createMemo(() => migrateV1(source()))
|
||||||
const started = performance.now()
|
|
||||||
const result = migrateV1(source())
|
|
||||||
themePerformance.set("Convert V1 to V2", duration(performance.now() - started))
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
const modes = createMemo(() => themeModes(file()))
|
const modes = createMemo(() => themeModes(file()))
|
||||||
const mode = () => {
|
const mode = () => {
|
||||||
const supported = modes()
|
const supported = modes()
|
||||||
|
|
@ -287,12 +281,9 @@ const themeContext = createSimpleContext({
|
||||||
return supported[0] ?? store.mode
|
return supported[0] ?? store.mode
|
||||||
}
|
}
|
||||||
const values = createMemo(() => resolveTheme(source(), mode()))
|
const values = createMemo(() => resolveTheme(source(), mode()))
|
||||||
const valuesV2 = createMemo(() => {
|
const valuesV2 = createMemo(() => resolveThemeFile(file(), mode(), sourceName()))
|
||||||
const resolveStarted = performance.now()
|
valuesV2()
|
||||||
const result = resolveThemeFile(file(), mode(), sourceName())
|
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
||||||
themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted))
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
const themeV2 = createComponentTheme(valuesV2, mode)
|
const themeV2 = createComponentTheme(valuesV2, mode)
|
||||||
const contextsV2 = {
|
const contextsV2 = {
|
||||||
elevated: createComponentTheme(() => {
|
elevated: createComponentTheme(() => {
|
||||||
|
|
@ -374,11 +365,6 @@ export function ThemeContextProvider(props: ParentProps<{ context: ContextName }
|
||||||
</themeContext.context.Provider>
|
</themeContext.context.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function duration(milliseconds: number) {
|
|
||||||
return `${milliseconds.toFixed(2)} ms`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSyntaxStyleMemo(factory: () => SyntaxStyle) {
|
export function createSyntaxStyleMemo(factory: () => SyntaxStyle) {
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const retained = new Set<SyntaxStyle>()
|
const retained = new Set<SyntaxStyle>()
|
||||||
|
|
|
||||||
|
|
@ -762,23 +762,6 @@ export function Session() {
|
||||||
options.format === "markdown"
|
options.format === "markdown"
|
||||||
? formatSessionTranscript(sessionData, messages(), options.thinking)
|
? formatSessionTranscript(sessionData, messages(), options.thinking)
|
||||||
: await (async () => {
|
: 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[] = []
|
const messages: unknown[] = []
|
||||||
let cursor: string | undefined
|
let cursor: string | undefined
|
||||||
do {
|
do {
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ export type ExportFormat = "markdown" | "json"
|
||||||
|
|
||||||
export type DialogExportOptionsProps = {
|
export type DialogExportOptionsProps = {
|
||||||
defaultThinking: boolean
|
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
|
onCancel?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type Active = ExportFormat | "debug" | "thinking" | "copy" | "export"
|
type Active = ExportFormat | "thinking" | "copy" | "export"
|
||||||
|
|
||||||
export function DialogExportOptions(props: DialogExportOptionsProps) {
|
export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
@ -21,7 +21,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
format: "markdown" as ExportFormat,
|
format: "markdown" as ExportFormat,
|
||||||
debug: false,
|
|
||||||
thinking: props.defaultThinking,
|
thinking: props.defaultThinking,
|
||||||
active: "markdown" as Active,
|
active: "markdown" as Active,
|
||||||
})
|
})
|
||||||
|
|
@ -30,7 +29,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||||
props.onConfirm?.({
|
props.onConfirm?.({
|
||||||
action,
|
action,
|
||||||
format: store.format,
|
format: store.format,
|
||||||
debug: store.debug,
|
|
||||||
thinking: store.thinking,
|
thinking: store.thinking,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -39,7 +37,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||||
setStore("format", store.active)
|
setStore("format", store.active)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.active === "debug") setStore("debug", !store.debug)
|
|
||||||
if (store.active === "thinking") setStore("thinking", !store.thinking)
|
if (store.active === "thinking") setStore("thinking", !store.thinking)
|
||||||
if (store.active === "copy" || store.active === "export") confirm(store.active)
|
if (store.active === "copy" || store.active === "export") confirm(store.active)
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +52,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||||
const order: Active[] =
|
const order: Active[] =
|
||||||
store.format === "markdown"
|
store.format === "markdown"
|
||||||
? ["markdown", "json", "thinking", "copy", "export"]
|
? ["markdown", "json", "thinking", "copy", "export"]
|
||||||
: ["markdown", "json", "debug", "copy", "export"]
|
: ["markdown", "json", "copy", "export"]
|
||||||
setStore("active", order[(order.indexOf(store.active) + 1) % order.length])
|
setStore("active", order[(order.indexOf(store.active) + 1) % order.length])
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -156,46 +153,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</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 flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
|
||||||
<box
|
<box
|
||||||
paddingLeft={4}
|
paddingLeft={4}
|
||||||
|
|
@ -230,7 +187,6 @@ DialogExportOptions.show = (dialog: DialogContext, defaultThinking: boolean) =>
|
||||||
return new Promise<{
|
return new Promise<{
|
||||||
action: "copy" | "export"
|
action: "copy" | "export"
|
||||||
format: ExportFormat
|
format: ExportFormat
|
||||||
debug: boolean
|
|
||||||
thinking: boolean
|
thinking: boolean
|
||||||
} | null>((resolve) => {
|
} | null>((resolve) => {
|
||||||
dialog.replace(
|
dialog.replace(
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue