diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..290f4c3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: Tests +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - name: Typecheck + run: bun run typecheck + - name: Tests + run: bun test diff --git a/src/index.ts b/src/index.ts index 551148e..745211a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,12 +7,12 @@ import { warpNotify } from "./notify" const PLUGIN_VERSION = "0.1.0" const NOTIFICATION_TITLE = "warp://cli-agent" -function truncate(str: string, maxLen: number): string { +export function truncate(str: string, maxLen: number): string { if (str.length <= maxLen) return str return str.slice(0, maxLen - 3) + "..." } -function extractTextFromParts(parts: Part[]): string { +export function extractTextFromParts(parts: Part[]): string { return parts .filter((p): p is Part & { type: "text"; text: string } => p.type === "text" && "text" in p && Boolean(p.text), @@ -125,31 +125,10 @@ export const WarpPlugin: Plugin = async ({ client, directory }) => { return } - case "message.updated": { - const message = event.properties.info - if (message.role !== "user") return - - const sessionId = message.sessionID - - // message.updated doesn't carry parts directly — fetch the message - let queryText = "" - try { - const result = await client.session.message({ - path: { id: sessionId, messageID: message.id }, - }) - if (result.data) { - queryText = extractTextFromParts(result.data.parts) - } - } catch { - // Fall back to using summary title if available - queryText = message.summary?.title ?? "" - } - - if (!queryText) return - - const body = buildPayload("prompt_submit", sessionId, cwd, { - query: truncate(queryText, 200), - }) + case "permission.replied": { + const { sessionID, response } = event.properties + if (response === "reject") return + const body = buildPayload("permission_replied", sessionID, cwd) warpNotify(NOTIFICATION_TITLE, body) return } @@ -164,6 +143,21 @@ export const WarpPlugin: Plugin = async ({ client, directory }) => { } }, + // Fires once per new user message — used to send the prompt_submit hook. + // (We avoid the generic message.updated event because OpenCode fires it + // multiple times per message, and a late duplicate can clobber the + // completion notification.) + "chat.message": async (input, output) => { + const cwd = directory || "" + const queryText = extractTextFromParts(output.parts) + if (!queryText) return + + const body = buildPayload("prompt_submit", input.sessionID, cwd, { + query: truncate(queryText, 200), + }) + warpNotify(NOTIFICATION_TITLE, body) + }, + // Tool completion — fires after every tool call "tool.execute.after": async (input) => { const toolName = input.tool diff --git a/tests/index.test.ts b/tests/index.test.ts new file mode 100644 index 0000000..74cb408 --- /dev/null +++ b/tests/index.test.ts @@ -0,0 +1,63 @@ +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { truncate, extractTextFromParts } from "../src/index" + +describe("truncate", () => { + it("returns string unchanged when under maxLen", () => { + assert.strictEqual(truncate("hello", 10), "hello") + }) + + it("returns string unchanged when exactly maxLen", () => { + assert.strictEqual(truncate("hello", 5), "hello") + }) + + it("truncates and adds ellipsis when over maxLen", () => { + assert.strictEqual(truncate("hello world", 8), "hello...") + }) + + it("handles maxLen of 3 (minimum for ellipsis)", () => { + assert.strictEqual(truncate("hello", 3), "...") + }) + + it("handles empty string", () => { + assert.strictEqual(truncate("", 10), "") + }) +}) + +describe("extractTextFromParts", () => { + it("extracts text from text parts", () => { + const parts = [ + { type: "text" as const, text: "hello" }, + { type: "text" as const, text: "world" }, + ] + assert.strictEqual(extractTextFromParts(parts), "hello world") + }) + + it("skips non-text parts", () => { + const parts = [ + { type: "text" as const, text: "hello" }, + { type: "tool_use" as const, id: "1", name: "bash", input: {} }, + { type: "text" as const, text: "world" }, + ] as any[] + assert.strictEqual(extractTextFromParts(parts), "hello world") + }) + + it("skips text parts with empty text", () => { + const parts = [ + { type: "text" as const, text: "" }, + { type: "text" as const, text: "hello" }, + ] + assert.strictEqual(extractTextFromParts(parts), "hello") + }) + + it("returns empty string for no parts", () => { + assert.strictEqual(extractTextFromParts([]), "") + }) + + it("returns empty string when all parts are non-text", () => { + const parts = [ + { type: "tool_use" as const, id: "1", name: "bash", input: {} }, + ] as any[] + assert.strictEqual(extractTextFromParts(parts), "") + }) +}) diff --git a/tests/notify.test.ts b/tests/notify.test.ts new file mode 100644 index 0000000..358edbd --- /dev/null +++ b/tests/notify.test.ts @@ -0,0 +1,49 @@ +import { describe, it, afterEach, mock } from "bun:test" +import { expect } from "bun:test" +import fs from "fs" + +const writeSpy = mock(() => {}) +mock.module("fs", () => ({ + ...fs, + writeFileSync: writeSpy, +})) + +const { warpNotify } = await import("../src/notify") + +describe("warpNotify", () => { + const originalTermProgram = process.env.TERM_PROGRAM + + afterEach(() => { + writeSpy.mockClear() + if (originalTermProgram === undefined) { + delete process.env.TERM_PROGRAM + } else { + process.env.TERM_PROGRAM = originalTermProgram + } + }) + + it("skips when TERM_PROGRAM is not set", () => { + delete process.env.TERM_PROGRAM + warpNotify("title", "body") + expect(writeSpy).not.toHaveBeenCalled() + }) + + it("skips for other terminal programs", () => { + process.env.TERM_PROGRAM = "iTerm.app" + warpNotify("title", "body") + expect(writeSpy).not.toHaveBeenCalled() + }) + + it("writes OSC 777 sequence when inside Warp", () => { + process.env.TERM_PROGRAM = "WarpTerminal" + warpNotify("warp://cli-agent", '{"event":"stop"}') + expect(writeSpy).toHaveBeenCalledTimes(1) + + const [path, data] = writeSpy.mock.calls[0] as [string, string] + expect(path).toBe("/dev/tty") + expect(data).toContain("warp://cli-agent") + expect(data).toContain('{"event":"stop"}') + expect(data).toMatch(/^\x1b\]777;notify;/) + expect(data).toMatch(/\x07$/) + }) +})