Parcourir la source

feat(tui): add debug info dialog with copy to clipboard (#35004)

Dustin Deus il y a 2 mois
Parent
commit
3adfb970bf

+ 11 - 0
packages/tui/src/app.tsx

@@ -42,6 +42,7 @@ import { DialogModel } from "./component/dialog-model"
 import { useConnected } from "./component/use-connected"
 import { DialogMcp } from "./component/dialog-mcp"
 import { DialogStatus } from "./component/dialog-status"
+import { DialogDebug } from "./component/dialog-debug"
 import { DialogThemeList } from "./component/dialog-theme-list"
 import { DialogHelp } from "./ui/dialog-help"
 import { DialogAgent } from "./component/dialog-agent"
@@ -115,6 +116,7 @@ const appBindingCommands = [
   "provider.connect",
   "console.org.switch",
   "opencode.status",
+  "opencode.debug",
   "theme.switch",
   "theme.switch_mode",
   "theme.mode.lock",
@@ -764,6 +766,15 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
         },
         category: "System",
       },
+      {
+        name: "opencode.debug",
+        title: "View debug info",
+        slashName: "debug",
+        run: () => {
+          dialog.replace(() => <DialogDebug />)
+        },
+        category: "System",
+      },
       {
         name: "theme.switch",
         title: "Switch theme",

+ 90 - 0
packages/tui/src/component/dialog-debug.tsx

@@ -0,0 +1,90 @@
+import { TextAttributes } from "@opentui/core"
+import { createMemo, createSignal, For } from "solid-js"
+import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
+import { useTheme } from "../context/theme"
+import { useDialog } from "../ui/dialog"
+import { useRoute } from "../context/route"
+import { useLocal } from "../context/local"
+import { useClipboard } from "../context/clipboard"
+import { useToast } from "../ui/toast"
+import { useBindings } from "../keymap"
+import { describeOS, describeTerminal } from "../util/system"
+
+export function DialogDebug() {
+  const { theme } = useTheme()
+  const dialog = useDialog()
+  const route = useRoute()
+  const local = useLocal()
+  const clipboard = useClipboard()
+  const toast = useToast()
+  const [copied, setCopied] = createSignal(false)
+
+  dialog.setSize("large")
+
+  const entries = createMemo(() => {
+    const model = local.model.current()
+    return [
+      { label: "Version", value: `${InstallationVersion} (${InstallationChannel})` },
+      { label: "Date", value: new Date().toISOString() },
+      { label: "OS", value: describeOS() },
+      { label: "Terminal", value: describeTerminal() },
+      { label: "Session ID", value: route.data.type === "session" ? route.data.sessionID : "n/a" },
+      { label: "Model", value: model ? `${model.providerID}/${model.modelID}` : "n/a" },
+    ]
+  })
+
+  const copy = () => {
+    const text = entries()
+      .map((entry) => `${entry.label}: ${entry.value}`)
+      .join("\n")
+    void clipboard
+      .write?.(text)
+      .then(() => {
+        setCopied(true)
+        toast.show({ message: "Debug info copied to clipboard", variant: "info" })
+      })
+      .catch(toast.error)
+  }
+
+  useBindings(() => ({
+    bindings: [{ key: "return", desc: "Copy debug info", group: "Dialog", cmd: copy }],
+  }))
+
+  return (
+    <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
+      <box flexDirection="row" justifyContent="space-between">
+        <text fg={theme.text} attributes={TextAttributes.BOLD}>
+          Debug
+        </text>
+        <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+          esc
+        </text>
+      </box>
+      {/* No click-to-copy here: releasing a mouse selection must trigger the
+          global copy-on-select so users can copy a single value, e.g. the session id. */}
+      <box>
+        <For each={entries()}>
+          {(entry) => (
+            <box flexDirection="row" gap={1}>
+              <text flexShrink={0} fg={theme.textMuted}>
+                {entry.label.padEnd(10)}
+              </text>
+              <text fg={theme.text} wrapMode="word">
+                {entry.value}
+              </text>
+            </box>
+          )}
+        </For>
+      </box>
+      <box flexDirection="row" justifyContent="space-between">
+        <text fg={theme.textMuted}>Share this when reporting an issue.</text>
+        <text onMouseUp={copy}>
+          <span style={{ fg: copied() ? theme.success : theme.text }}>
+            <b>{copied() ? "✓ copied" : "copy"}</b>{" "}
+          </span>
+          <span style={{ fg: theme.textMuted }}>enter</span>
+        </text>
+      </box>
+    </box>
+  )
+}

+ 1 - 20
packages/tui/src/component/error-component.tsx

@@ -1,4 +1,3 @@
-import { release } from "node:os"
 import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
 import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
 import { createSignal, For, Show } from "solid-js"
@@ -6,6 +5,7 @@ import { getScrollAcceleration } from "../util/scroll"
 import { useClipboard } from "../context/clipboard"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import { useExit } from "../context/exit"
+import { describeOS, describeTerminal } from "../util/system"
 
 export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
   const term = useTerminalDimensions()
@@ -238,22 +238,3 @@ function buildIssueURL(message: string, stack: string) {
   setBody(stack.slice(0, lo) + marker)
   return url
 }
-
-function describeOS() {
-  const name =
-    process.platform === "darwin"
-      ? "macOS"
-      : process.platform === "win32"
-        ? "Windows"
-        : process.platform === "linux"
-          ? "Linux"
-          : process.platform
-  return `${name} ${release()} (${process.arch})`
-}
-
-function describeTerminal() {
-  const program = process.env.TERM_PROGRAM || process.env.TERM || "unknown"
-  const version = process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""
-  const multiplexer = process.env.TMUX ? " in tmux" : process.env.STY ? " in screen" : ""
-  return `${program}${version}${multiplexer}`
-}

+ 2 - 0
packages/tui/src/config/keybind.ts

@@ -81,6 +81,7 @@ export const Definitions = {
   sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
   scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
   status_view: keybind("<leader>s", "View status"),
+  debug_view: keybind("none", "View debug info"),
 
   session_export: keybind("<leader>x", "Export session to editor"),
   session_copy: keybind("none", "Copy session transcript"),
@@ -288,6 +289,7 @@ export const CommandMap = {
   sidebar_toggle: "session.sidebar.toggle",
   scrollbar_toggle: "session.toggle.scrollbar",
   status_view: "opencode.status",
+  debug_view: "opencode.debug",
   session_export: "session.export",
   session_copy: "session.copy",
   session_move: "session.move",

+ 3 - 0
packages/tui/src/ui/dialog.tsx

@@ -49,6 +49,9 @@ export function Dialog(
     >
       <box
         onMouseUp={(e: { stopPropagation(): void }) => {
+          // A selection release must bubble up to the copy-on-select handler in
+          // DialogProvider; the backdrop's dismiss flag keeps it from closing the dialog.
+          if (renderer.getSelection()?.getSelectedText()) return
           dismiss = false
           e.stopPropagation()
         }}

+ 20 - 0
packages/tui/src/util/system.ts

@@ -0,0 +1,20 @@
+import { release } from "node:os"
+
+export function describeOS() {
+  const name =
+    process.platform === "darwin"
+      ? "macOS"
+      : process.platform === "win32"
+        ? "Windows"
+        : process.platform === "linux"
+          ? "Linux"
+          : process.platform
+  return `${name} ${release()} (${process.arch})`
+}
+
+export function describeTerminal() {
+  const program = process.env.TERM_PROGRAM || process.env.TERM || "unknown"
+  const version = process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""
+  const multiplexer = process.env.TMUX ? " in tmux" : process.env.STY ? " in screen" : ""
+  return `${program}${version}${multiplexer}`
+}