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 @@ -6,6 +6,8 @@
- Stay silent on idle after ESC / `MessageAbortedError`, a real error, or an idle with no prior busy turn
- Do not retract or re-send an idle popup when title or background work sets the session busy
- Start the next idle turn on a new user message, not on generic busy
- Treat a new user message as a busy turn so idle still fires if `session.status` busy is missing
- Focus Zed on click under GNOME/Wayland: use the activation token, line-buffer `gdbus monitor`, and do not let `desktop-entry` swallow the click

## 0.2.0

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ Do not copy only `src/index.ts` into `~/.config/opencode/plugins/` — the plugi
2. `permission.replied` cancels that timer, records the ID (so a late ask stays silent), and retracts a popup already on screen.
3. If the timer fires, the request is still waiting on you, so a notification is sent.
4. `MessageAbortedError` is ignored. It is not an `opencode error` popup.
5. After a session was 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. Clicking a popup focuses Zed (`zed://`). It does not open `zed://agent`, which would start a new thread.
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. 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.

That covers `opencode --auto`, the TUI auto-approve toggle, and any other path that replies before you need to look.

Expand Down
53 changes: 42 additions & 11 deletions src/activate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { SpawnSyncFn } from "./notify"
export type ActivateTarget = {
sessionId?: string
clickCommand?: string[]
activationToken?: string
}

const CHANNELS = ["stable", "preview", "nightly", "dev"] as const
Expand Down Expand Up @@ -45,36 +46,66 @@ export function activate(
env: NodeJS.ProcessEnv = process.env,
) {
try {
const opts = spawnOpts(env, target.activationToken)
if (target.clickCommand?.length) {
const [cmd, ...args] = expandClickCommand(target.clickCommand, target.sessionId)
if (cmd) spawn(cmd, args, { stdio: "ignore", timeout: 5000 })
if (cmd) spawn(cmd, args, opts)
return
}
const url = zedFocusUrl()
for (const sock of zedSocketCandidates(home, env)) {
if (exists(sock) && sendUnixDgram(sock, url, spawn)) return
}
const attempts: Array<[string, string[]]> = [
["zed", ["-e", url]],
["xdg-open", [url]],
["gtk-launch", ["dev.zed.Zed"]],
["open", [url]],
["zed", []],
["flatpak", ["run", "dev.zed.Zed"]],
]
for (const [cmd, args] of attempts) {
try {
const result = spawn(cmd, args, { stdio: "ignore", timeout: 5000 })
if (!result.error && (result.status === 0 || result.status == null)) return
} catch {
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
}
} catch {
}
}

function spawnOpts(env: NodeJS.ProcessEnv, token?: string) {
return {
stdio: "ignore" as const,
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) {
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], { stdio: "ignore", timeout: 2000 })
const result = spawn("python3", ["-c", SOCKET_SCRIPT, path, payload], { ...opts, timeout: 2000 })
return !result.error && result.status === 0
} catch {
return false
Expand Down
1 change: 1 addition & 0 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export function createEngine(input: EngineInput): Engine {
function beginTurn(sessionId: string) {
suppressIdle.delete(sessionId)
idleNotified.delete(sessionId)
active.set(sessionId, true)
}

function markBusy(sessionId?: string) {
Expand Down
86 changes: 61 additions & 25 deletions src/notify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { activate, type ActivateTarget } from "./activate"
export type SpawnSyncFn = (
command: string,
args: string[],
options?: { encoding?: BufferEncoding; stdio?: "ignore"; timeout?: number },
options?: { encoding?: BufferEncoding; stdio?: "ignore"; timeout?: number; env?: NodeJS.ProcessEnv },
) => { status?: number | null; error?: Error; stdout?: string | Buffer }

export type SpawnFn = (
Expand Down Expand Up @@ -35,7 +35,8 @@ export type NotifierInput = {
platform?: string
}

const ACTION_RE = /ActionInvoked \(uint32 (\d+),\s*'([^']*)'\)/g
const ACTION_RE = /ActionInvoked \(uint32 (\d+),\s*['"]([^'"]*)['"]\)/g
const TOKEN_RE = /ActivationToken \(uint32 (\d+),\s*['"]([^'"]*)['"]\)/g
const WIN_APP_ID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe"
const WIN_GROUP = "opencode-smart-notify"

Expand Down Expand Up @@ -69,15 +70,16 @@ function createLinuxNotifier(options: NotifierInput): Notifier {
const spawn = options.spawn ?? spawnSync
const watch = options.watch ?? nodeSpawn
const clicks = new Map<number, string | undefined>()
const tokens = new Map<number, string>()
let watching = false

function runActivate(sessionId?: string) {
function runActivate(sessionId?: string, activationToken?: string) {
try {
if (options.activate) {
options.activate({ sessionId, clickCommand: options.clickCommand })
options.activate({ sessionId, clickCommand: options.clickCommand, activationToken })
return
}
activate({ sessionId, clickCommand: options.clickCommand }, spawn)
activate({ sessionId, clickCommand: options.clickCommand, activationToken }, spawn)
} catch {
}
}
Expand All @@ -88,33 +90,68 @@ function createLinuxNotifier(options: NotifierInput): Notifier {
extra?.onId?.(id)
}

function ensureWatch() {
if (watching) return
watching = true
function fire(id: number) {
if (!clicks.has(id)) return
const sessionId = clicks.get(id)
clicks.delete(id)
const activationToken = tokens.get(id)
tokens.delete(id)
runActivate(sessionId, activationToken)
}

function consume(text: string) {
TOKEN_RE.lastIndex = 0
ACTION_RE.lastIndex = 0
const ids = new Set<number>()
let match: RegExpExecArray | null
while ((match = TOKEN_RE.exec(text))) {
const id = Number.parseInt(match[1] ?? "", 10)
if (!Number.isFinite(id) || !clicks.has(id)) continue
tokens.set(id, match[2] ?? "")
ids.add(id)
}
while ((match = ACTION_RE.exec(text))) {
const id = Number.parseInt(match[1] ?? "", 10)
if (!Number.isFinite(id) || !clicks.has(id)) continue
ids.add(id)
}
for (const id of ids) fire(id)
}

function takeWatch(command: string, args: string[], onError?: () => void) {
try {
const child = watch("gdbus", ["monitor", "--session", "--dest", "org.freedesktop.Notifications"], {
encoding: "utf8",
const child = watch(command, args, { encoding: "utf8" })
let dropped = false
child.stdout?.on("data", (chunk) => consume(String(chunk)))
child.on?.("error", () => {
if (dropped) return
dropped = true
if (onError) onError()
else watching = false
})
child.stdout?.on("data", (chunk) => {
const text = String(chunk)
ACTION_RE.lastIndex = 0
let match: RegExpExecArray | null
while ((match = ACTION_RE.exec(text))) {
const id = Number.parseInt(match[1] ?? "", 10)
if (!Number.isFinite(id) || !clicks.has(id)) continue
const sessionId = clicks.get(id)
clicks.delete(id)
runActivate(sessionId)
}
child.on?.("exit", () => {
if (dropped) return
watching = false
})
child.on?.("error", () => {})
return true
} catch {
return false
}
}

function ensureWatch() {
if (watching) return
watching = true
const dest = ["monitor", "--session", "--dest", "org.freedesktop.Notifications"]
const takeGdbus = () => {
if (!takeWatch("gdbus", dest)) watching = false
}
if (!takeWatch("stdbuf", ["-oL", "gdbus", ...dest], takeGdbus)) takeGdbus()
}

return {
send(title: string, body: string, urgency = "normal", extra?: SendExtra) {
const hints = `{'urgency': <byte ${urgencyByte(urgency)}>, 'desktop-entry': <'dev.zed.Zed'>}`
const hints = `{'urgency': <byte ${urgencyByte(urgency)}>}`
try {
ensureWatch()
const printed = spawn(
Expand Down Expand Up @@ -153,8 +190,6 @@ function createLinuxNotifier(options: NotifierInput): Notifier {
"opencode",
"-i",
"dialog-information-symbolic",
"-h",
"string:desktop-entry:dev.zed.Zed",
title,
body,
]
Expand All @@ -171,6 +206,7 @@ function createLinuxNotifier(options: NotifierInput): Notifier {
},
close(id: number) {
clicks.delete(id)
tokens.delete(id)
const attempts: Array<[string, string[]]> = [
[
"gdbus",
Expand Down
22 changes: 18 additions & 4 deletions test/activate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { describe, expect, test } from "bun:test"
import { activate, expandClickCommand, zedFocusUrl, zedSocketCandidates } from "../src/activate"
import type { SpawnSyncFn } from "../src/notify"

type Call = { command: string; args: string[] }
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 calls: Call[] = []
const spawn: SpawnSyncFn = (command, args) => {
calls.push({ command, args })
const spawn: SpawnSyncFn = (command, args, options) => {
calls.push({ command, args, ...(options?.env ? { env: options.env } : {}) })
return handler(command, args)
}
return { spawn, calls }
Expand Down Expand Up @@ -54,6 +54,20 @@ describe("activate", () => {
expect(calls[0]?.args.at(-1)).toBe("zed://")
})

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("falls back to the zed CLI when no socket exists", () => {
const { spawn, calls } = fakeSpawn()
activate({ sessionId: "ses_1" }, spawn, () => false, "/home/gab", {})
Expand All @@ -63,7 +77,7 @@ describe("activate", () => {
test("falls back to launching zed with no args", () => {
const { spawn, calls } = fakeSpawn((command, args) => {
if (args.includes("zed://")) return { status: 1 }
if (command === "python3") return { status: 1 }
if (command === "python3" || command === "gtk-launch") return { status: 1 }
return { status: 0 }
})
activate({ sessionId: "ses_1" }, spawn, () => false, "/home/gab", {})
Expand Down
10 changes: 10 additions & 0 deletions test/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,16 @@ describe("createEngine", () => {
expect(sent).toEqual([])
})

test("treats a new user message as the start of a busy turn", () => {
const { engine, sent } = setup()
engine.handle({
type: "message.updated",
properties: { sessionID: "ses_1", info: { id: "msg_1", role: "user" } },
})
engine.handle({ type: "session.idle", properties: { sessionID: "ses_1" } })
expect(sent).toEqual([{ title: "opencode idle", body: "demo: finished", urgency: "critical", sessionId: "ses_1" }])
})

test("skips idle after MessageAbortedError", () => {
const { engine, sent } = setup()
engine.handle({ type: "session.status", properties: { sessionID: "ses_1", status: { type: "busy" } } })
Expand Down
Loading
Loading