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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Privacy-friendly by design: no system notifications, no message content, no exte
```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-bell@0.1.0"]
"plugin": ["opencode-bell@0.1.1"]
}
```

Expand All @@ -32,6 +32,12 @@ The plugin triggers a terminal bell for these events:
- `session.idle`
- `session.error`

Behavior details:

- Rings only when `process.stdout.isTTY` is true.
- Debounces repeated rings for the same key within 1200ms.
- Uses per-session keys when `event.properties.sessionID` is present.

## Verify

- Trigger a permission request (e.g., any tool that requires approval).
Expand Down
110 changes: 110 additions & 0 deletions test/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,47 @@ import assert from "node:assert/strict"

import { OpencodeBellPlugin } from "../index.js"

const withStdoutStub = async ({ isTTY = true }, run) => {
const originalWrite = process.stdout.write
const originalDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY")
const writes = []

process.stdout.write = (chunk) => {
writes.push(chunk)
return true
}
Object.defineProperty(process.stdout, "isTTY", {
configurable: true,
value: isTTY
})

try {
await run(writes)
} finally {
process.stdout.write = originalWrite
if (originalDescriptor) {
Object.defineProperty(process.stdout, "isTTY", originalDescriptor)
}
}
}

const withMockedNow = async (times, run) => {
const originalNow = Date.now
let index = 0

Date.now = () => {
const value = times[index]
index += 1
return value
}

try {
await run()
} finally {
Date.now = originalNow
}
}

test("does not throw when event.properties is missing", async () => {
const plugin = await OpencodeBellPlugin()
await assert.doesNotReject(
Expand All @@ -11,3 +52,72 @@ test("does not throw when event.properties is missing", async () => {
})
)
})

test("rings once for each supported event type", async () => {
const plugin = await OpencodeBellPlugin()

await withStdoutStub({ isTTY: true }, async (writes) => {
await plugin.event({ event: { type: "permission.asked" } })
await plugin.event({ event: { type: "question.asked" } })
await plugin.event({ event: { type: "session.idle" } })
await plugin.event({ event: { type: "session.error" } })

assert.equal(writes.length, 4)
assert.deepEqual(writes, ["\x07", "\x07", "\x07", "\x07"])
})
})

test("does not ring for unsupported event types", async () => {
const plugin = await OpencodeBellPlugin()

await withStdoutStub({ isTTY: true }, async (writes) => {
await plugin.event({ event: { type: "session.started" } })
assert.equal(writes.length, 0)
})
})

test("does not ring when stdout is not a TTY", async () => {
const plugin = await OpencodeBellPlugin()

await withStdoutStub({ isTTY: false }, async (writes) => {
await plugin.event({ event: { type: "session.idle" } })
assert.equal(writes.length, 0)
})
})

test("debounces repeated events within 1200ms for the same key", async () => {
const plugin = await OpencodeBellPlugin()

await withStdoutStub({ isTTY: true }, async (writes) => {
await withMockedNow([2000, 2500, 3301], async () => {
await plugin.event({ event: { type: "permission.asked" } })
await plugin.event({ event: { type: "permission.asked" } })
await plugin.event({ event: { type: "permission.asked" } })
})

assert.equal(writes.length, 2)
})
})

test("uses session-specific debounce keys", async () => {
const plugin = await OpencodeBellPlugin()

await withStdoutStub({ isTTY: true }, async (writes) => {
await withMockedNow([2000, 2100], async () => {
await plugin.event({
event: {
type: "permission.asked",
properties: { sessionID: "A" }
}
})
await plugin.event({
event: {
type: "permission.asked",
properties: { sessionID: "B" }
}
})
})

assert.equal(writes.length, 2)
})
})