Skip to content

Commit 422c40a

Browse files
authored
Merge pull request #1 from warpdotdev/harry/make-permissions-hooks-more-specific
switch to more specific message sent and permission replied hooks
1 parent f091369 commit 422c40a

File tree

4 files changed

+150
-27
lines changed

4 files changed

+150
-27
lines changed

.github/workflows/test.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: Tests
2+
on:
3+
pull_request:
4+
push:
5+
branches: [main]
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: oven-sh/setup-bun@v2
13+
- run: bun install --frozen-lockfile
14+
- name: Typecheck
15+
run: bun run typecheck
16+
- name: Tests
17+
run: bun test

src/index.ts

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import { warpNotify } from "./notify"
77
const PLUGIN_VERSION = "0.1.0"
88
const NOTIFICATION_TITLE = "warp://cli-agent"
99

10-
function truncate(str: string, maxLen: number): string {
10+
export function truncate(str: string, maxLen: number): string {
1111
if (str.length <= maxLen) return str
1212
return str.slice(0, maxLen - 3) + "..."
1313
}
1414

15-
function extractTextFromParts(parts: Part[]): string {
15+
export function extractTextFromParts(parts: Part[]): string {
1616
return parts
1717
.filter((p): p is Part & { type: "text"; text: string } =>
1818
p.type === "text" && "text" in p && Boolean(p.text),
@@ -125,31 +125,10 @@ export const WarpPlugin: Plugin = async ({ client, directory }) => {
125125
return
126126
}
127127

128-
case "message.updated": {
129-
const message = event.properties.info
130-
if (message.role !== "user") return
131-
132-
const sessionId = message.sessionID
133-
134-
// message.updated doesn't carry parts directly — fetch the message
135-
let queryText = ""
136-
try {
137-
const result = await client.session.message({
138-
path: { id: sessionId, messageID: message.id },
139-
})
140-
if (result.data) {
141-
queryText = extractTextFromParts(result.data.parts)
142-
}
143-
} catch {
144-
// Fall back to using summary title if available
145-
queryText = message.summary?.title ?? ""
146-
}
147-
148-
if (!queryText) return
149-
150-
const body = buildPayload("prompt_submit", sessionId, cwd, {
151-
query: truncate(queryText, 200),
152-
})
128+
case "permission.replied": {
129+
const { sessionID, response } = event.properties
130+
if (response === "reject") return
131+
const body = buildPayload("permission_replied", sessionID, cwd)
153132
warpNotify(NOTIFICATION_TITLE, body)
154133
return
155134
}
@@ -164,6 +143,21 @@ export const WarpPlugin: Plugin = async ({ client, directory }) => {
164143
}
165144
},
166145

146+
// Fires once per new user message — used to send the prompt_submit hook.
147+
// (We avoid the generic message.updated event because OpenCode fires it
148+
// multiple times per message, and a late duplicate can clobber the
149+
// completion notification.)
150+
"chat.message": async (input, output) => {
151+
const cwd = directory || ""
152+
const queryText = extractTextFromParts(output.parts)
153+
if (!queryText) return
154+
155+
const body = buildPayload("prompt_submit", input.sessionID, cwd, {
156+
query: truncate(queryText, 200),
157+
})
158+
warpNotify(NOTIFICATION_TITLE, body)
159+
},
160+
167161
// Tool completion — fires after every tool call
168162
"tool.execute.after": async (input) => {
169163
const toolName = input.tool

tests/index.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it } from "node:test"
2+
import assert from "node:assert/strict"
3+
import { truncate, extractTextFromParts } from "../src/index"
4+
5+
describe("truncate", () => {
6+
it("returns string unchanged when under maxLen", () => {
7+
assert.strictEqual(truncate("hello", 10), "hello")
8+
})
9+
10+
it("returns string unchanged when exactly maxLen", () => {
11+
assert.strictEqual(truncate("hello", 5), "hello")
12+
})
13+
14+
it("truncates and adds ellipsis when over maxLen", () => {
15+
assert.strictEqual(truncate("hello world", 8), "hello...")
16+
})
17+
18+
it("handles maxLen of 3 (minimum for ellipsis)", () => {
19+
assert.strictEqual(truncate("hello", 3), "...")
20+
})
21+
22+
it("handles empty string", () => {
23+
assert.strictEqual(truncate("", 10), "")
24+
})
25+
})
26+
27+
describe("extractTextFromParts", () => {
28+
it("extracts text from text parts", () => {
29+
const parts = [
30+
{ type: "text" as const, text: "hello" },
31+
{ type: "text" as const, text: "world" },
32+
]
33+
assert.strictEqual(extractTextFromParts(parts), "hello world")
34+
})
35+
36+
it("skips non-text parts", () => {
37+
const parts = [
38+
{ type: "text" as const, text: "hello" },
39+
{ type: "tool_use" as const, id: "1", name: "bash", input: {} },
40+
{ type: "text" as const, text: "world" },
41+
] as any[]
42+
assert.strictEqual(extractTextFromParts(parts), "hello world")
43+
})
44+
45+
it("skips text parts with empty text", () => {
46+
const parts = [
47+
{ type: "text" as const, text: "" },
48+
{ type: "text" as const, text: "hello" },
49+
]
50+
assert.strictEqual(extractTextFromParts(parts), "hello")
51+
})
52+
53+
it("returns empty string for no parts", () => {
54+
assert.strictEqual(extractTextFromParts([]), "")
55+
})
56+
57+
it("returns empty string when all parts are non-text", () => {
58+
const parts = [
59+
{ type: "tool_use" as const, id: "1", name: "bash", input: {} },
60+
] as any[]
61+
assert.strictEqual(extractTextFromParts(parts), "")
62+
})
63+
})

tests/notify.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, it, afterEach, mock } from "bun:test"
2+
import { expect } from "bun:test"
3+
import fs from "fs"
4+
5+
const writeSpy = mock(() => {})
6+
mock.module("fs", () => ({
7+
...fs,
8+
writeFileSync: writeSpy,
9+
}))
10+
11+
const { warpNotify } = await import("../src/notify")
12+
13+
describe("warpNotify", () => {
14+
const originalTermProgram = process.env.TERM_PROGRAM
15+
16+
afterEach(() => {
17+
writeSpy.mockClear()
18+
if (originalTermProgram === undefined) {
19+
delete process.env.TERM_PROGRAM
20+
} else {
21+
process.env.TERM_PROGRAM = originalTermProgram
22+
}
23+
})
24+
25+
it("skips when TERM_PROGRAM is not set", () => {
26+
delete process.env.TERM_PROGRAM
27+
warpNotify("title", "body")
28+
expect(writeSpy).not.toHaveBeenCalled()
29+
})
30+
31+
it("skips for other terminal programs", () => {
32+
process.env.TERM_PROGRAM = "iTerm.app"
33+
warpNotify("title", "body")
34+
expect(writeSpy).not.toHaveBeenCalled()
35+
})
36+
37+
it("writes OSC 777 sequence when inside Warp", () => {
38+
process.env.TERM_PROGRAM = "WarpTerminal"
39+
warpNotify("warp://cli-agent", '{"event":"stop"}')
40+
expect(writeSpy).toHaveBeenCalledTimes(1)
41+
42+
const [path, data] = writeSpy.mock.calls[0] as [string, string]
43+
expect(path).toBe("/dev/tty")
44+
expect(data).toContain("warp://cli-agent")
45+
expect(data).toContain('{"event":"stop"}')
46+
expect(data).toMatch(/^\x1b\]777;notify;/)
47+
expect(data).toMatch(/\x07$/)
48+
})
49+
})

0 commit comments

Comments
 (0)