diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx
index e55e128b82..67c9a89929 100644
--- a/packages/app/src/components/debug-bar.tsx
+++ b/packages/app/src/components/debug-bar.tsx
@@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
type Mem = Performance & {
memory?: {
@@ -107,8 +108,45 @@ function Cell(props: {
)
}
+function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
+ const content = () => (
+
+ )
+
+ if (props.inline) {
+ return (
+
+ {content()}
+
+ )
+ }
+
+ return (
+
+ {content()}
+
+ )
+}
+
export function DebugBar(props: { inline?: boolean } = {}) {
const language = useLanguage()
+ const platform = usePlatform()
const location = useLocation()
const routing = useIsRouting()
const [state, setState] = createStore({
@@ -116,6 +154,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
delay: undefined as number | undefined,
fps: undefined as number | undefined,
gap: undefined as number | undefined,
+ focus: false,
heap: {
limit: undefined as number | undefined,
used: undefined as number | undefined,
@@ -142,6 +181,16 @@ export function DebugBar(props: { inline?: boolean } = {}) {
}
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
+ const toggleFocus = async () => {
+ if (!platform.setForceFocus) return
+ const enabled = !state.focus
+ await platform.setForceFocus(enabled)
+ setState("focus", enabled)
+ }
+
+ onCleanup(() => {
+ if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
+ })
let prev = ""
let start = 0
@@ -490,8 +539,11 @@ export function DebugBar(props: { inline?: boolean } = {}) {
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
inline={props.inline}
- wide
+ wide={!platform.setForceFocus}
/>
+ {platform.setForceFocus && (
+ void toggleFocus()} />
+ )}
)
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index 1a061a6675..cf343bfa7e 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -115,6 +115,9 @@ type PlatformBase = {
/** Export collected diagnostic logs (desktop only) */
exportDebugLogs?(): Promise
+ /** Force focus styles on interactive elements through desktop devtools (desktop only) */
+ setForceFocus?(enabled: boolean): Promise
+
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise
}
diff --git a/packages/desktop/src/main/debug.ts b/packages/desktop/src/main/debug.ts
new file mode 100644
index 0000000000..373aec237f
--- /dev/null
+++ b/packages/desktop/src/main/debug.ts
@@ -0,0 +1,95 @@
+import type { WebContents } from "electron"
+
+const focusDebuggerOwners = new WeakSet()
+const forcedFocusNodes = new WeakMap()
+const focusableSelector = `
+ a[href],
+ button:not([disabled]),
+ input:not([disabled]),
+ select:not([disabled]),
+ textarea:not([disabled]),
+ summary,
+ [contenteditable="true"],
+ [tabindex]:not([tabindex="-1"])
+`
+
+export async function setForceFocus(contents: WebContents, enabled: boolean) {
+ const debuggerApi = contents.debugger
+ if (!debuggerApi.isAttached()) {
+ if (!enabled) {
+ focusDebuggerOwners.delete(contents)
+ forcedFocusNodes.delete(contents)
+ return
+ }
+ debuggerApi.attach("1.3")
+ focusDebuggerOwners.add(contents)
+ debuggerApi.once("detach", () => {
+ focusDebuggerOwners.delete(contents)
+ forcedFocusNodes.delete(contents)
+ })
+ }
+
+ if (!enabled) {
+ await Promise.allSettled(
+ (forcedFocusNodes.get(contents) ?? []).map((nodeId) =>
+ debuggerApi.sendCommand("CSS.forcePseudoState", {
+ nodeId,
+ forcedPseudoClasses: [],
+ }),
+ ),
+ )
+ forcedFocusNodes.delete(contents)
+ if (!focusDebuggerOwners.delete(contents)) return
+ debuggerApi.detach()
+ return
+ }
+
+ await debuggerApi.sendCommand("DOM.enable")
+ await debuggerApi.sendCommand("CSS.enable")
+ const document: unknown = await debuggerApi.sendCommand("DOM.getDocument", {
+ depth: -1,
+ pierce: true,
+ })
+ const nodes: unknown = await debuggerApi.sendCommand("DOM.querySelectorAll", {
+ nodeId: readDocumentNodeId(document),
+ selector: focusableSelector,
+ })
+ const nodeIds = readNodeIds(nodes)
+ forcedFocusNodes.set(contents, [...new Set([...(forcedFocusNodes.get(contents) ?? []), ...nodeIds])])
+ await Promise.allSettled(
+ nodeIds.map((nodeId) =>
+ debuggerApi.sendCommand("CSS.forcePseudoState", {
+ nodeId,
+ forcedPseudoClasses: ["focus", "focus-visible"],
+ }),
+ ),
+ )
+}
+
+function readDocumentNodeId(value: unknown) {
+ if (
+ !value ||
+ typeof value !== "object" ||
+ !("root" in value) ||
+ !value.root ||
+ typeof value.root !== "object" ||
+ !("nodeId" in value.root) ||
+ typeof value.root.nodeId !== "number"
+ ) {
+ throw new Error("Invalid DOM.getDocument response")
+ }
+ return value.root.nodeId
+}
+
+function readNodeIds(value: unknown) {
+ if (
+ !value ||
+ typeof value !== "object" ||
+ !("nodeIds" in value) ||
+ !Array.isArray(value.nodeIds) ||
+ !value.nodeIds.every((nodeId) => typeof nodeId === "number")
+ ) {
+ throw new Error("Invalid DOM.querySelectorAll response")
+ }
+ return value.nodeIds
+}
diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts
index 1103401c49..4563b688ac 100644
--- a/packages/desktop/src/main/ipc.ts
+++ b/packages/desktop/src/main/ipc.ts
@@ -7,6 +7,7 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
+import { setForceFocus } from "./debug"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore, removeStoreFileIfEmpty } from "./store"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
@@ -81,6 +82,9 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("updater-install", () => deps.updater.install())
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
+ ipcMain.handle("set-force-focus", (event: IpcMainInvokeEvent, enabled: boolean) =>
+ setForceFocus(event.sender, enabled),
+ )
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
deps.recordFatalRendererError(error),
)
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index d14dd0d00a..87eac1fa80 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/packages/desktop/src/preload/index.ts
@@ -121,6 +121,7 @@ const api: ElectronAPI = {
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
+ setForceFocus: (enabled) => ipcRenderer.invoke("set-force-focus", enabled),
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
}
diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts
index da10f7008d..5fbace72c7 100644
--- a/packages/desktop/src/preload/types.ts
+++ b/packages/desktop/src/preload/types.ts
@@ -104,5 +104,6 @@ export type ElectronAPI = {
runDesktopMenuAction: (action: DesktopMenuAction) => Promise
setBackgroundColor: (color: string) => Promise
exportDebugLogs: () => Promise
+ setForceFocus: (enabled: boolean) => Promise
recordFatalRendererError: (error: FatalRendererError) => Promise
}
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index 13c122cb2e..b7d2d9a74d 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -240,6 +240,8 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
exportDebugLogs: () => window.api.exportDebugLogs(),
+ setForceFocus: (enabled) => window.api.setForceFocus(enabled),
+
recordFatalRendererError: (error) => window.api.recordFatalRendererError(error),
restart: async () => {