diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c3f22..a9f1eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Click focuses Zed only when its CLI socket is live; otherwise focus a running OpenCode TUI +- Do not launch Zed when only the TUI is running - Learn child sessions from Task metadata, `parent_id`, subagent titles, durable event types, and the session list on start so child idle stays silent ## 0.3.0 diff --git a/README.md b/README.md index e50b718..914c313 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Linux, macOS, and Windows. Use **0.1.1 or later** — 0.1.0 does not load. A request popup already on screen is retracted when `permission.replied` arrives (Linux and Windows). macOS Notification Center cannot dismiss a posted banner from a script. -Clicking a notification focuses the running Zed window (`zed://`). Zed has no URL to switch to an existing ACP thread — `zed://agent` would start a new one, so this plugin does not send it. Override with `clickCommand` if you need a different handler. +Clicking a notification focuses running Zed (`zed://`), else a running OpenCode TUI. It does not start Zed and does not send `zed://agent`. Override with `clickCommand` if you need a different handler. The package ships TypeScript. OpenCode loads it with Bun; there is no `dist/` build. @@ -78,7 +78,7 @@ Do not copy only `src/index.ts` into `~/.config/opencode/plugins/` — the plugi 4. `MessageAbortedError` is ignored. It is not an `opencode error` popup. 5. After a user message or `session.status` busy, `session.status` idle / `session.idle` sends `opencode idle`. ESC, a real error, or an idle with no prior turn stays silent. Title or background work does not retract that popup or send a second one. A new user message starts the next turn. 6. Child sessions (`Session.parentID` set, Task metadata, or a `(@… subagent)` title) are skipped by default. Existing children are hydrated from the session list on start. Parent idle still notifies when the parent finishes. -7. Clicking a popup focuses Zed (`zed://`), using the GNOME/Wayland activation token when the compositor sends one. It does not open `zed://agent`, which would start a new thread. +7. Clicking a popup focuses running Zed (`zed://`), else a running OpenCode TUI. The GNOME/Wayland activation token is used when the compositor sends one; TUI raise is compositor best-effort (hyprctl / sway / niri / DBusActivatable terminal / wmctrl). It does not start Zed and does not open `zed://agent`. That covers `opencode --auto`, the TUI auto-approve toggle, and any other path that replies before you need to look. @@ -95,7 +95,7 @@ Optional `~/.config/opencode/opencode-smart-notify.json`. Plugin tuple options i | `notifyIdle` | `true` | Agent finished (`session.status` idle) | | `notifySubagents` | `false` | Task / child-session events | | `urgency` | `"critical"` | `low`, `normal`, or `critical` | -| `clickCommand` | *(auto)* | Argv run on click. `{sessionId}` is substituted. Default: focus Zed (`zed://`) | +| `clickCommand` | *(auto)* | Argv run on click. `{sessionId}` is substituted. Default: focus Zed if running, else focus OpenCode TUI | ```jsonc { diff --git a/src/activate.ts b/src/activate.ts index c48ea1e..8c76df9 100644 --- a/src/activate.ts +++ b/src/activate.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" +import { focusTui, focusZed, zedIsRunning } from "./focus" import type { SpawnSyncFn } from "./notify" export type ActivateTarget = { @@ -9,10 +10,13 @@ export type ActivateTarget = { activationToken?: string } -const CHANNELS = ["stable", "preview", "nightly", "dev"] as const +export type SpawnOpts = { + stdio: "ignore" + timeout: number + env?: NodeJS.ProcessEnv +} -const SOCKET_SCRIPT = - "import socket,sys;s=socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM);s.connect(sys.argv[1]);s.send(sys.argv[2].encode())" +const CHANNELS = ["stable", "preview", "nightly", "dev"] as const export function zedFocusUrl() { return "zed://" @@ -52,62 +56,21 @@ export function activate( if (cmd) spawn(cmd, args, opts) return } - const url = zedFocusUrl() - const attempts: Array<[string, string[]]> = [ - ["zed", ["-e", url]], - ["xdg-open", [url]], - ["gtk-launch", ["dev.zed.Zed"]], - ["open", [url]], - ["zed", []], - ["flatpak", ["run", "dev.zed.Zed"]], - ] - if (target.activationToken) { - for (const [cmd, args] of attempts) { - if (run(spawn, cmd, args, opts)) return - } - } - for (const sock of zedSocketCandidates(home, env)) { - if (exists(sock) && sendUnixDgram(sock, url, spawn, opts)) return - } - for (const [cmd, args] of attempts) { - if (run(spawn, cmd, args, opts)) return + + const sockets = zedSocketCandidates(home, env) + if (zedIsRunning(exists, sockets, spawn, opts)) { + focusZed(target, spawn, sockets, exists, opts) + return } + focusTui(target, spawn, opts) } catch { } } -function spawnOpts(env: NodeJS.ProcessEnv, token?: string) { +function spawnOpts(env: NodeJS.ProcessEnv, token?: string): SpawnOpts { return { - stdio: "ignore" as const, + stdio: "ignore", timeout: 5000, ...(token ? { env: { ...env, XDG_ACTIVATION_TOKEN: token } } : {}), } } - -function run( - spawn: SpawnSyncFn, - command: string, - args: string[], - opts: { stdio: "ignore"; timeout: number; env?: NodeJS.ProcessEnv }, -) { - try { - const result = spawn(command, args, opts) - return !result.error && (result.status === 0 || result.status == null) - } catch { - return false - } -} - -function sendUnixDgram( - path: string, - payload: string, - spawn: SpawnSyncFn, - opts: { stdio: "ignore"; timeout: number; env?: NodeJS.ProcessEnv }, -) { - try { - const result = spawn("python3", ["-c", SOCKET_SCRIPT, path, payload], { ...opts, timeout: 2000 }) - return !result.error && result.status === 0 - } catch { - return false - } -} diff --git a/src/focus.ts b/src/focus.ts new file mode 100644 index 0000000..3bd9167 --- /dev/null +++ b/src/focus.ts @@ -0,0 +1,281 @@ +import type { ActivateTarget, SpawnOpts } from "./activate" +import type { SpawnSyncFn } from "./notify" + +export type Proc = { pid: number; ppid: number; comm: string; args: string } +export type TerminalApp = { dest: string; path: string } + +const CONNECT_SCRIPT = + "import socket,sys;s=socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM);s.connect(sys.argv[1])" + +const SOCKET_SCRIPT = + "import socket,sys;s=socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM);s.connect(sys.argv[1]);s.send(sys.argv[2].encode())" + +const NON_TUI = new Set([ + "acp", + "serve", + "run", + "web", + "mcp", + "debug", + "providers", + "auth", + "agent", + "upgrade", + "uninstall", + "models", + "stats", + "export", + "import", + "github", + "pr", + "session", + "plugin", + "plug", + "db", + "completion", +]) + +const TERMINALS: Record = { + ptyxis: { dest: "org.gnome.Ptyxis", path: "/org/gnome/Ptyxis" }, + kgx: { dest: "org.gnome.Console", path: "/org/gnome/Console" }, + ghostty: { dest: "com.mitchellh.ghostty", path: "/com/mitchellh/ghostty" }, + wezterm: { dest: "org.wezfurlong.wezterm", path: "/org/wezfurlong/wezterm" }, + "wezterm-gui": { dest: "org.wezfurlong.wezterm", path: "/org/wezfurlong/wezterm" }, +} + +export function parsePs(stdout: string): Proc[] { + const out: Proc[] = [] + for (const line of stdout.split(/\r?\n/)) { + const match = line.trimStart().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/) + if (!match) continue + out.push({ + pid: Number(match[1]), + ppid: Number(match[2]), + comm: match[3] ?? "", + args: match[4] ?? "", + }) + } + return out +} + +export function isOpencodeTuiArgs(args: string): boolean { + const tokens = args.trim().split(/\s+/) + const idx = tokens.findIndex((token) => /(^|\/)opencode2?$/.test(token)) + if (idx < 0) return false + const first = tokens.slice(idx + 1).find((token) => !token.startsWith("-")) + if (!first || first === "tui" || first === "attach") return true + return !NON_TUI.has(first) +} + +export function tuiPids(procs: Proc[]): number[] { + return procs.filter((proc) => isOpencodeTuiArgs(proc.args)).map((proc) => proc.pid) +} + +export function terminalAppForPid(pid: number, procs: Proc[]): TerminalApp | undefined { + const byPid = new Map(procs.map((proc) => [proc.pid, proc])) + const seen = new Set() + let current = byPid.get(pid) + while (current && !seen.has(current.pid)) { + seen.add(current.pid) + const app = TERMINALS[current.comm] + if (app) return app + current = byPid.get(current.ppid) + } +} + +export function socketConnectable(path: string, spawn: SpawnSyncFn, opts: SpawnOpts): boolean { + try { + const result = spawn("python3", ["-c", CONNECT_SCRIPT, path], { ...opts, timeout: 2000 }) + return !result.error && result.status === 0 + } catch { + return false + } +} + +export function zedIsRunning( + exists: (path: string) => boolean, + sockets: string[], + spawn: SpawnSyncFn, + opts: SpawnOpts, +): boolean { + try { + for (const sock of sockets) { + if (exists(sock) && socketConnectable(sock, spawn, opts)) return true + } + return false + } catch { + return false + } +} + +export function focusZed( + target: ActivateTarget, + spawn: SpawnSyncFn, + sockets: string[], + exists: (path: string) => boolean, + opts: SpawnOpts, +): void { + try { + if (target.activationToken) { + if (run(spawn, "zed", ["-e", "zed://"], opts)) return + } + for (const sock of sockets) { + if (exists(sock) && sendUnixDgram(sock, "zed://", spawn, opts)) return + } + } catch { + } +} + +export function focusTui(target: ActivateTarget, spawn: SpawnSyncFn, opts: SpawnOpts): void { + try { + const listed = capture(spawn, "ps", ["ax", "-o", "pid=,ppid=,comm=,args="], opts) + if (listed === undefined) return + const procs = parsePs(listed) + const pids = tuiPids(procs) + let niriWindows: unknown + let niriTried = false + for (const pid of pids) { + if (run(spawn, "hyprctl", ["dispatch", "focuswindow", `pid:${pid}`], opts)) return + if (run(spawn, "swaymsg", [`[pid=${pid}]`, "focus"], opts)) return + if (!niriTried) { + niriTried = true + const raw = capture(spawn, "niri", ["msg", "--json", "windows"], opts) + niriWindows = raw === undefined ? undefined : parseJson(raw) + } + const niriId = niriWindowId(niriWindows, pid) + if ( + niriId !== undefined && + run(spawn, "niri", ["msg", "action", "focus-window", "--id", String(niriId)], opts) + ) { + return + } + const app = terminalAppForPid(pid, procs) + if (app && nameHasOwner(spawn, app.dest, opts)) { + if ( + run( + spawn, + "gdbus", + [ + "call", + "--session", + "--dest", + app.dest, + "--object-path", + app.path, + "--method", + "org.freedesktop.Application.Activate", + platformData(target.activationToken), + ], + opts, + ) + ) { + return + } + } + if (focusWmctrl(spawn, pid, procs, opts)) return + } + } catch { + } +} + +function run(spawn: SpawnSyncFn, command: string, args: string[], opts: SpawnOpts) { + try { + const result = spawn(command, args, opts) + return !result.error && (result.status === 0 || result.status == null) + } catch { + return false + } +} + +function capture(spawn: SpawnSyncFn, command: string, args: string[], opts: SpawnOpts) { + try { + const result = spawn(command, args, { encoding: "utf8", timeout: opts.timeout, env: opts.env }) + if (result.error || (result.status !== 0 && result.status != null)) return + return String(result.stdout ?? "") + } catch { + return + } +} + +function sendUnixDgram(path: string, payload: string, spawn: SpawnSyncFn, opts: SpawnOpts) { + try { + const result = spawn("python3", ["-c", SOCKET_SCRIPT, path, payload], { ...opts, timeout: 2000 }) + return !result.error && result.status === 0 + } catch { + return false + } +} + +function parseJson(text: string) { + try { + return JSON.parse(text) as unknown + } catch { + return + } +} + +function niriWindowId(data: unknown, pid: number) { + const windows = Array.isArray(data) ? data : [] + for (const win of windows) { + if (!win || typeof win !== "object") continue + const rec = win as { id?: unknown; pid?: unknown } + if (rec.pid !== pid) continue + if (typeof rec.id === "number" || typeof rec.id === "string") return rec.id + } +} + +function nameHasOwner(spawn: SpawnSyncFn, dest: string, opts: SpawnOpts) { + const stdout = capture( + spawn, + "gdbus", + [ + "call", + "--session", + "--dest", + "org.freedesktop.DBus", + "--object-path", + "/org/freedesktop/DBus", + "--method", + "org.freedesktop.DBus.NameHasOwner", + dest, + ], + opts, + ) + return stdout !== undefined && stdout.includes("(true,)") +} + +function platformData(token?: string) { + if (!token) return "{}" + return `{'activation-token': <${gvariantString(token)}>}` +} + +function gvariantString(value: string) { + return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'` +} + +function focusWmctrl(spawn: SpawnSyncFn, pid: number, procs: Proc[], opts: SpawnOpts) { + const listed = capture(spawn, "wmctrl", ["-lp"], opts) + if (listed === undefined) return false + const ids = lineage(pid, procs) + for (const line of listed.split(/\r?\n/)) { + const match = line.trim().match(/^(\S+)\s+\S+\s+(\d+)\b/) + if (!match) continue + const wpid = Number(match[2]) + if (!ids.has(wpid)) continue + if (run(spawn, "wmctrl", ["-ia", match[1] ?? ""], opts)) return true + } + return false +} + +function lineage(pid: number, procs: Proc[]) { + const byPid = new Map(procs.map((proc) => [proc.pid, proc])) + const ids = new Set() + const seen = new Set() + let current = byPid.get(pid) + while (current && !seen.has(current.pid)) { + seen.add(current.pid) + ids.add(current.pid) + current = byPid.get(current.ppid) + } + return ids +} diff --git a/src/index.ts b/src/index.ts index 599f984..d6105ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,17 @@ import { createNotifier } from "./notify" export { activate, expandClickCommand, zedFocusUrl, zedSocketCandidates } from "./activate" export type { ActivateTarget } from "./activate" +export { + focusTui, + focusZed, + isOpencodeTuiArgs, + parsePs, + socketConnectable, + terminalAppForPid, + tuiPids, + zedIsRunning, +} from "./focus" +export type { Proc, TerminalApp } from "./focus" export { SETTLE_MS, URGENCIES, defaults, loadFileConfig, parseOptions, resolveProjectName } from "./config" export type { Options, Urgency } from "./config" export { createEngine, isAbortedError, requestIds, sessionIdOf } from "./engine" diff --git a/test/activate.test.ts b/test/activate.test.ts index 766e5c6..8a08b97 100644 --- a/test/activate.test.ts +++ b/test/activate.test.ts @@ -4,7 +4,31 @@ import type { SpawnSyncFn } from "../src/notify" type Call = { command: string; args: string[]; env?: NodeJS.ProcessEnv } -function fakeSpawn(handler: (command: string, args: string[]) => { status?: number | null; error?: Error; stdout?: string } = () => ({ status: 0 })) { +const HOME = "/home/gab" +const ENV: NodeJS.ProcessEnv = { XDG_DATA_HOME: "/tmp/xdg", PATH: "/usr/bin" } +const SOCK = "/tmp/xdg/zed/zed-stable.sock" +const TOKEN = "gnome-shell/1/token" +const LAUNCHERS = ["xdg-open", "gtk-launch", "flatpak", "xdotool"] + +const TUI_PS = [ + " 100 1 opencode /home/gab/.opencode/bin/opencode", + " 101 1 opencode /home/gab/.opencode/bin/opencode /tmp/demo", + " 102 1 opencode opencode attach", +].join("\n") + +const ACP_PS = [ + " 200 1 opencode opencode acp", + " 201 1 opencode2 opencode2 serve --service", + " 202 1 python opencode-acp-smooth foo opencode acp", +].join("\n") + +const PTYXIS_PS = [" 100 300 opencode /home/gab/.opencode/bin/opencode", " 300 1 ptyxis /usr/bin/ptyxis"].join("\n") + +function fakeSpawn( + handler: (command: string, args: string[]) => { status?: number | null; error?: Error; stdout?: string } = () => ({ + status: 0, + }), +) { const calls: Call[] = [] const spawn: SpawnSyncFn = (command, args, options) => { calls.push({ command, args, ...(options?.env ? { env: options.env } : {}) }) @@ -13,6 +37,22 @@ function fakeSpawn(handler: (command: string, args: string[]) => { status?: numb return { spawn, calls } } +function isConnect(command: string, args: string[]) { + return command === "python3" && args[0] === "-c" && args.length === 3 && !args[1]?.includes("s.send") +} + +function isSend(command: string, args: string[]) { + return command === "python3" && args[0] === "-c" && args.length === 4 && Boolean(args[1]?.includes("s.send")) +} + +function commands(calls: Call[]) { + return calls.map((call) => call.command) +} + +function expectNoLaunchers(calls: Call[]) { + expect(calls.some((call) => LAUNCHERS.includes(call.command))).toBe(false) +} + describe("zedFocusUrl", () => { test("focuses the running Zed window", () => { expect(zedFocusUrl()).toBe("zed://") @@ -40,47 +80,160 @@ describe("expandClickCommand", () => { describe("activate", () => { test("runs clickCommand when configured", () => { const { spawn, calls } = fakeSpawn() - activate({ sessionId: "ses_1", clickCommand: ["zed", "-e", "{sessionId}"] }, spawn) + activate( + { sessionId: "ses_1", clickCommand: ["zed", "-e", "{sessionId}"] }, + spawn, + () => true, + HOME, + ENV, + ) expect(calls).toEqual([{ command: "zed", args: ["-e", "ses_1"] }]) + expect(calls.some((call) => isConnect(call.command, call.args) || call.command === "ps")).toBe(false) }) - test("sends zed:// to an existing socket so it does not open a new thread", () => { - const { spawn, calls } = fakeSpawn() - activate({ sessionId: "ses_1" }, spawn, (path) => path === "/tmp/xdg/zed/zed-stable.sock", "/home/gab", { - XDG_DATA_HOME: "/tmp/xdg", + test("probes a live socket then sends zed:// without launching a CLI", () => { + const { spawn, calls } = fakeSpawn((command, args) => { + if (isConnect(command, args) || isSend(command, args)) return { status: 0 } + return { status: 1 } }) - expect(calls[0]?.command).toBe("python3") - expect(calls[0]?.args.at(-2)).toBe("/tmp/xdg/zed/zed-stable.sock") - expect(calls[0]?.args.at(-1)).toBe("zed://") + activate({ sessionId: "ses_1" }, spawn, (path) => path === SOCK, HOME, ENV) + expect(isConnect(calls[0]?.command ?? "", calls[0]?.args ?? [])).toBe(true) + expect(calls[0]?.args.at(-1)).toBe(SOCK) + expect(isSend(calls[1]?.command ?? "", calls[1]?.args ?? [])).toBe(true) + expect(calls[1]?.args.at(-2)).toBe(SOCK) + expect(calls[1]?.args.at(-1)).toBe("zed://") + expect(commands(calls)).not.toContain("ps") + expect(commands(calls).some((command) => ["zed", "hyprctl", "gdbus", ...LAUNCHERS].includes(command))).toBe(false) }) - test("uses an XDG activation token before the socket so Wayland can focus", () => { - const { spawn, calls } = fakeSpawn() - activate( - { sessionId: "ses_1", activationToken: "gnome-shell/1/token" }, - spawn, - (path) => path === "/tmp/xdg/zed/zed-stable.sock", - "/home/gab", - { XDG_DATA_HOME: "/tmp/xdg", PATH: "/usr/bin" }, - ) - expect(calls[0]?.command).toBe("zed") - expect(calls[0]?.args).toEqual(["-e", "zed://"]) - expect(calls[0]?.env?.XDG_ACTIVATION_TOKEN).toBe("gnome-shell/1/token") + test("uses an XDG activation token after a live connect", () => { + const { spawn, calls } = fakeSpawn((command, args) => { + if (isConnect(command, args) || command === "zed") return { status: 0 } + return { status: 1 } + }) + activate({ sessionId: "ses_1", activationToken: TOKEN }, spawn, (path) => path === SOCK, HOME, ENV) + expect(isConnect(calls[0]?.command ?? "", calls[0]?.args ?? [])).toBe(true) + expect(calls[0]?.args.at(-1)).toBe(SOCK) + expect(calls[1]?.command).toBe("zed") + expect(calls[1]?.args).toEqual(["-e", "zed://"]) + expect(calls[1]?.env?.XDG_ACTIVATION_TOKEN).toBe(TOKEN) + expect(calls.some((call) => isSend(call.command, call.args))).toBe(false) }) - test("falls back to the zed CLI when no socket exists", () => { - const { spawn, calls } = fakeSpawn() - activate({ sessionId: "ses_1" }, spawn, () => false, "/home/gab", {}) - expect(calls[0]).toEqual({ command: "zed", args: ["-e", "zed://"] }) + test("treats a stale socket as not running and does not launch Zed", () => { + const { spawn, calls } = fakeSpawn((command, args) => { + if (isConnect(command, args)) return { status: 1 } + if (command === "ps") return { status: 0, stdout: TUI_PS } + if (command === "hyprctl") return { status: 0 } + return { status: 1 } + }) + activate({ sessionId: "ses_1" }, spawn, (path) => path === SOCK, HOME, ENV) + expect(calls.some((call) => isConnect(call.command, call.args))).toBe(true) + expect(commands(calls)).toContain("ps") + expect(commands(calls)).toContain("hyprctl") + expect(commands(calls)).not.toContain("zed") + expect(calls.some((call) => isSend(call.command, call.args))).toBe(false) + expectNoLaunchers(calls) + }) + + test("focuses the TUI when no socket exists and opencode is in ps", () => { + const { spawn, calls } = fakeSpawn((command) => { + if (command === "ps") return { status: 0, stdout: TUI_PS } + if (command === "hyprctl") return { status: 0 } + return { status: 1 } + }) + activate({ sessionId: "ses_1" }, spawn, () => false, HOME, ENV) + expect(calls.some((call) => isConnect(call.command, call.args))).toBe(false) + expect(calls[0]).toEqual({ command: "ps", args: ["ax", "-o", "pid=,ppid=,comm=,args="] }) + expect(calls.some((call) => call.command === "hyprctl" && call.args.includes("pid:100"))).toBe(true) + expect(commands(calls).some((command) => ["zed", ...LAUNCHERS].includes(command))).toBe(false) + }) + + test("does not launch Zed for acp or serve-only processes", () => { + const { spawn, calls } = fakeSpawn((command) => { + if (command === "ps") return { status: 0, stdout: ACP_PS } + return { status: 1 } + }) + activate({ sessionId: "ses_1" }, spawn, () => false, HOME, ENV) + expect(commands(calls)).toEqual(["ps"]) + expect(commands(calls).some((command) => ["zed", "hyprctl", ...LAUNCHERS].includes(command))).toBe(false) }) - test("falls back to launching zed with no args", () => { + test("stops after ps when neither Zed nor a TUI is running", () => { + const { spawn, calls } = fakeSpawn((command) => { + if (command === "ps") return { status: 0, stdout: " 1 0 systemd /sbin/init\n" } + return { status: 1 } + }) + activate({ sessionId: "ses_1" }, spawn, () => false, HOME, ENV) + expect(commands(calls)).toEqual(["ps"]) + expect(commands(calls).some((command) => ["zed", ...LAUNCHERS].includes(command))).toBe(false) + }) + + test("returns after a live Zed probe even if the raise fails", () => { const { spawn, calls } = fakeSpawn((command, args) => { - if (args.includes("zed://")) return { status: 1 } - if (command === "python3" || command === "gtk-launch") return { status: 1 } + if (isConnect(command, args)) return { status: 0 } + return { status: 1 } + }) + activate({ sessionId: "ses_1", activationToken: TOKEN }, spawn, (path) => path === SOCK, HOME, ENV) + expect(isConnect(calls[0]?.command ?? "", calls[0]?.args ?? [])).toBe(true) + expect(calls.some((call) => call.command === "zed" && call.args.includes("zed://"))).toBe(true) + expect(calls.some((call) => isSend(call.command, call.args))).toBe(true) + expect(commands(calls)).not.toContain("ps") + expect(commands(calls)).not.toContain("hyprctl") + }) + + test("forwards the activation token on TUI compositor and gdbus spawns", () => { + const { spawn, calls } = fakeSpawn((command, args) => { + if (command === "ps") return { status: 0, stdout: PTYXIS_PS } + if (command === "gdbus" && args.includes("org.freedesktop.DBus.NameHasOwner")) { + return { status: 0, stdout: "(true,)\n" } + } + if (command === "gdbus" && args.includes("org.freedesktop.Application.Activate")) return { status: 0 } + return { status: 1 } + }) + activate({ sessionId: "ses_1", activationToken: TOKEN }, spawn, () => false, HOME, ENV) + const focused = calls.filter((call) => ["ps", "hyprctl", "swaymsg", "niri", "gdbus"].includes(call.command)) + expect(focused.length).toBeGreaterThan(0) + for (const call of focused) { + expect(call.env?.XDG_ACTIVATION_TOKEN).toBe(TOKEN) + } + const activateCall = calls.find((call) => call.args.includes("org.freedesktop.Application.Activate")) + expect(activateCall?.args).toContain("{'activation-token': <'gnome-shell/1/token'>}") + expect(commands(calls).some((command) => ["zed", ...LAUNCHERS].includes(command))).toBe(false) + }) + + test("skips Activate when NameHasOwner is false", () => { + const { spawn, calls } = fakeSpawn((command, args) => { + if (command === "ps") return { status: 0, stdout: PTYXIS_PS } + if (command === "gdbus" && args.includes("org.freedesktop.DBus.NameHasOwner")) { + return { status: 0, stdout: "(false,)\n" } + } + return { status: 1 } + }) + activate({ sessionId: "ses_1" }, spawn, () => false, HOME, ENV) + expect(calls.some((call) => call.args.includes("org.freedesktop.DBus.NameHasOwner"))).toBe(true) + expect(calls.some((call) => call.args.includes("org.freedesktop.Application.Activate"))).toBe(false) + }) + + test("does not try sway, niri, or gdbus after hyprctl succeeds", () => { + const { spawn, calls } = fakeSpawn((command) => { + if (command === "ps") return { status: 0, stdout: TUI_PS } return { status: 0 } }) - activate({ sessionId: "ses_1" }, spawn, () => false, "/home/gab", {}) - expect(calls.some((call) => call.command === "zed" && call.args.length === 0)).toBe(true) + activate({ sessionId: "ses_1" }, spawn, () => false, HOME, ENV) + expect(commands(calls)).toEqual(["ps", "hyprctl"]) + expect(calls[1]?.args).toEqual(["dispatch", "focuswindow", "pid:100"]) + }) + + test("never uses xdotool", () => { + const { spawn, calls } = fakeSpawn((command, args) => { + if (command === "ps") return { status: 0, stdout: PTYXIS_PS } + if (command === "gdbus" && args.includes("org.freedesktop.DBus.NameHasOwner")) { + return { status: 0, stdout: "(true,)\n" } + } + return { status: 1 } + }) + activate({ sessionId: "ses_1" }, spawn, () => false, HOME, ENV) + expect(commands(calls)).not.toContain("xdotool") }) }) diff --git a/test/focus.test.ts b/test/focus.test.ts new file mode 100644 index 0000000..ce942da --- /dev/null +++ b/test/focus.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test" +import { + isOpencodeTuiArgs, + parsePs, + socketConnectable, + terminalAppForPid, + tuiPids, + zedIsRunning, +} from "../src/focus" +import type { SpawnOpts } from "../src/activate" +import type { SpawnSyncFn } from "../src/notify" + +type Call = { command: string; args: string[] } + +const OPTS: SpawnOpts = { stdio: "ignore", timeout: 5000 } +const SOCK = "/tmp/xdg/zed/zed-stable.sock" + +const TUI_ARGS = [ + "opencode", + "/home/gab/.opencode/bin/opencode", + "opencode /tmp/demo", + "opencode --mini", + "opencode tui", + "opencode attach", + "opencode2", +] + +const NON_TUI_ARGS = [ + "opencode acp", + "opencode2 serve --service", + "opencode-acp-smooth foo opencode acp", + "opencode serve", + "opencode run", + "opencode web", + "opencode mcp", + "opencode auth", + "opencode models", +] + +function fakeSpawn( + handler: (command: string, args: string[]) => { status?: number | null; error?: Error; stdout?: string } = () => ({ + status: 0, + }), +) { + const calls: Call[] = [] + const spawn: SpawnSyncFn = (command, args) => { + calls.push({ command, args }) + return handler(command, args) + } + return { spawn, calls } +} + +function isConnect(command: string, args: string[]) { + return command === "python3" && args[0] === "-c" && args.length === 3 && !args[1]?.includes("s.send") +} + +describe("parsePs", () => { + test("handles leading spaces", () => { + expect(parsePs(" 100 1 opencode /home/gab/.opencode/bin/opencode")).toEqual([ + { pid: 100, ppid: 1, comm: "opencode", args: "/home/gab/.opencode/bin/opencode" }, + ]) + }) + + test("skips unparseable lines", () => { + expect(parsePs("\nheader\n 200 1 ptyxis /usr/bin/ptyxis\n")).toEqual([ + { pid: 200, ppid: 1, comm: "ptyxis", args: "/usr/bin/ptyxis" }, + ]) + }) +}) + +describe("isOpencodeTuiArgs", () => { + test("treats a bare, attach, or project opencode process as a TUI", () => { + for (const args of TUI_ARGS) { + expect(isOpencodeTuiArgs(args)).toBe(true) + } + }) + + test("rejects acp, serve, and other non-TUI commands", () => { + for (const args of NON_TUI_ARGS) { + expect(isOpencodeTuiArgs(args)).toBe(false) + } + }) +}) + +describe("tuiPids", () => { + test("returns only TUI pids", () => { + const procs = parsePs( + [ + " 100 1 opencode /home/gab/.opencode/bin/opencode", + " 200 1 opencode opencode acp", + " 300 100 ptyxis /usr/bin/ptyxis", + ].join("\n"), + ) + expect(tuiPids(procs)).toEqual([100]) + }) +}) + +describe("terminalAppForPid", () => { + test("walks ppid to a known terminal", () => { + const cases = [ + ["ptyxis", { dest: "org.gnome.Ptyxis", path: "/org/gnome/Ptyxis" }], + ["kgx", { dest: "org.gnome.Console", path: "/org/gnome/Console" }], + ["ghostty", { dest: "com.mitchellh.ghostty", path: "/com/mitchellh/ghostty" }], + ["wezterm", { dest: "org.wezfurlong.wezterm", path: "/org/wezfurlong/wezterm" }], + ["wezterm-gui", { dest: "org.wezfurlong.wezterm", path: "/org/wezfurlong/wezterm" }], + ] as const + for (const [comm, app] of cases) { + const procs = parsePs(` 100 300 opencode /home/gab/.opencode/bin/opencode\n 300 1 ${comm} /usr/bin/${comm}`) + expect(terminalAppForPid(100, procs)).toEqual(app) + } + }) + + test("does not map gnome-terminal", () => { + const procs = parsePs(" 100 200 opencode /home/gab/.opencode/bin/opencode\n 200 1 gnome-terminal /usr/bin/gnome-terminal") + expect(terminalAppForPid(100, procs)).toBeUndefined() + }) +}) + +describe("socketConnectable", () => { + test("is true when python3 connect returns status 0", () => { + const { spawn, calls } = fakeSpawn(() => ({ status: 0 })) + expect(socketConnectable(SOCK, spawn, OPTS)).toBe(true) + expect(isConnect(calls[0]?.command ?? "", calls[0]?.args ?? [])).toBe(true) + expect(calls[0]?.args[1]).toContain("s.connect") + expect(calls[0]?.args.at(-1)).toBe(SOCK) + }) + + test("is false when python3 connect returns status 1", () => { + const { spawn, calls } = fakeSpawn((command, args) => { + if (isConnect(command, args)) return { status: 1 } + return { status: 0 } + }) + expect(socketConnectable(SOCK, spawn, OPTS)).toBe(false) + expect(calls).toHaveLength(1) + }) + + test("is false when python3 throws", () => { + const spawn: SpawnSyncFn = () => { + throw new Error("missing") + } + expect(socketConnectable(SOCK, spawn, OPTS)).toBe(false) + }) +}) + +describe("zedIsRunning", () => { + test("is false when no socket exists", () => { + const { spawn, calls } = fakeSpawn() + expect(zedIsRunning(() => false, [SOCK], spawn, OPTS)).toBe(false) + expect(calls).toEqual([]) + }) + + test("is false when the socket exists but connect fails", () => { + const { spawn, calls } = fakeSpawn((command, args) => { + if (isConnect(command, args)) return { status: 1 } + return { status: 0 } + }) + expect(zedIsRunning((path) => path === SOCK, [SOCK], spawn, OPTS)).toBe(false) + expect(isConnect(calls[0]?.command ?? "", calls[0]?.args ?? [])).toBe(true) + }) + + test("is true when the socket exists and connect succeeds", () => { + const { spawn } = fakeSpawn((command, args) => { + if (isConnect(command, args)) return { status: 0 } + return { status: 1 } + }) + expect(zedIsRunning((path) => path === SOCK, [SOCK], spawn, OPTS)).toBe(true) + }) +})