Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand All @@ -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
{
Expand Down
67 changes: 15 additions & 52 deletions src/activate.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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://"
Expand Down Expand Up @@ -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
}
}
Loading
Loading