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
17 changes: 17 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
48 changes: 21 additions & 27 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
63 changes: 63 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -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), "")
})
})
49 changes: 49 additions & 0 deletions tests/notify.test.ts
Original file line number Diff line number Diff line change
@@ -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$/)
})
})
Loading