diff --git a/NotchBuddy/Sources/App/PillCatalog.swift b/NotchBuddy/Sources/App/PillCatalog.swift index bf4c9c8a8..7c873262d 100644 --- a/NotchBuddy/Sources/App/PillCatalog.swift +++ b/NotchBuddy/Sources/App/PillCatalog.swift @@ -37,6 +37,7 @@ struct PillDefinition { case "integration_claude": return "Claude Code" case "agent_cursor": return "Cursor" case "agent_codex": return "Codex" + case "agent_opencode": return "opencode" default: return "Agent" } } @@ -59,6 +60,10 @@ enum PillCatalog { // ── Agents ─────────────────────────────────────────────────────────── .init(id: "agent_gemini", name: "Gemini CLI", color: "#8AB4F8", category: .agent, subtitle: "Agent", source: .agent, githubOnly: true), + // No githubOnly: the bridge is an external plugin spawning the relay + // directly, so it works in every build with no hook installer. + .init(id: "agent_opencode", name: "opencode", color: "#EAB308", + category: .agent, subtitle: "Agent", source: .agent), // ── AI for the chat ────────────────────────────────────────────────── .init(id: "ai_anthropic", name: "Anthropic", color: ChatProvider.anthropic.accentHex, category: .ai, subtitle: "Chat", source: .n8n), diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 055a9afae..a40d205e1 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -99,7 +99,7 @@ A **declared pill** is a catalog entry (`PillCatalog.swift`) that has been enabl A catalog pill that is not checked in Settings behaves like any other agent: it gets an automatic pill when a session starts, and that pill is removed when the session ends. -The GitHub build exposes Gemini CLI (`agent_gemini`) and Antigravity (`agent_antigravity`) in Settings → Active pills. Cursor (`agent_cursor`) and Codex (`agent_codex`, GitHub build only) are there too — their pills can be declared and set as the main pill; session support is coming in a future version. +The GitHub build exposes Gemini CLI (`agent_gemini`) and Antigravity (`agent_antigravity`) in Settings → Active pills. Cursor (`agent_cursor`) and Codex (`agent_codex`, GitHub build only) are there too — their pills can be declared and set as the main pill; session support is coming in a future version. opencode (`agent_opencode`, every build) is there as well — it needs no hook installer: its bridge is an external plugin that spawns the relay with `--agent opencode` directly. ## Real-world examples diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..51e0a6c4d 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -59,6 +59,10 @@ const task = ( /** AgentTask.integrationAgents — same ids, names and colours as macOS. */ export const INTEGRATION_AGENTS: AgentTask[] = [ task("integration_claude", "VS Code", "#F5F6F8", "claudeCode"), + // Third-party coding agent bridged over the hook relay with coucou_agent. + // Declared so it can be pinned in Settings → Active pills; sessions still + // arrive as dynamic events exactly like undeclared agents. + task("agent_opencode", "opencode", "#EAB308", "agent"), task("integration_resend", "Resend", "#22C55E", "n8n"), task("integration_n8n", "n8n", "#F29B38", "n8n"), task("integration_vercel", "Vercel", "#7C5CFF", "n8n"), @@ -71,6 +75,7 @@ export const INTEGRATION_AGENTS: AgentTask[] = [ export const TOGGLEABLE_INTEGRATION_IDS = [ "integration_resend", "integration_n8n", "integration_vercel", "integration_github", "integration_notion", "integration_calcom", "integration_stripe", + "agent_opencode", ]; /** What an integration poller last reported. */ @@ -231,6 +236,22 @@ class AppState { removeTask(id: string) { const idx = this.tasks.findIndex((t) => t.id === id); if (idx < 0) return; + // Declared + active: reset, never remove (mirror of AppState.removeTask on + // macOS). The user pinned this pill in Settings → Active pills; a finished + // session parks it as idle instead of taking it away. + const isActiveDecl = + INTEGRATION_AGENTS.some((t) => t.id === id) && this.settings.activeIntegrations.includes(id); + if (isActiveDecl) { + const t = this.tasks[idx]; + t.state = "idle"; + t.steps = []; + t.stepIndex = 0; + t.pillBadge = null; + const catalogName = INTEGRATION_AGENTS.find((t) => t.id === id)?.name; + if (catalogName) t.name = catalogName; + this.notify(); + return; + } this.tasks.splice(idx, 1); if (this.focusId === id) this.focusId = this.tasks[0]?.id ?? "integration_claude"; this.notify(); diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..969a06e4f 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -4,7 +4,7 @@ import "./settings.css"; import { Bridge, onEvent, type HookStatus } from "../core/bridge"; -import { DEFAULT_SETTINGS, type Settings } from "../core/state"; +import { DEFAULT_SETTINGS, INTEGRATION_AGENTS, type Settings } from "../core/state"; import { h, clear } from "../views/dom"; let settings: Settings = { ...DEFAULT_SETTINGS }; @@ -358,6 +358,49 @@ function integrationsSection(present: Record): HTMLElement { return h("section", {}, h("h2", {}, h("span", { text: "Integrations" })), note, list); } +// ── Agents section ──────────────────────────────────────────────────────────── + +function agentsSection(): HTMLElement { + // Third-party coding agents get a pill automatically while a session sends + // hook events. Pinning one here keeps it after the session ends (idle) + // instead of removing it. Shares the 4-pill budget with Integrations. + const defs = INTEGRATION_AGENTS.filter((t) => t.source === "agent"); + const list = h("div", { style: "display:flex;flex-direction:column;gap:14px" }); + + for (const def of defs) { + const active = settings.activeIntegrations.includes(def.id); + const sw = h("button", { class: active ? "switch on" : "switch" }); + sw.addEventListener("click", () => { + const on = settings.activeIntegrations.includes(def.id); + if (on) { + settings.activeIntegrations = settings.activeIntegrations.filter((x) => x !== def.id); + } else { + if (settings.activeIntegrations.length >= MAX_ACTIVE) return; + settings.activeIntegrations = [...settings.activeIntegrations, def.id]; + } + sw.classList.toggle("on", !on); + void save(); + }); + + list.append( + h("div", { style: "display:flex;gap:12px;align-items:flex-start" }, + h("div", { style: "display:flex;align-items:center;gap:8px;min-width:132px;padding-top:4px" }, + sw, + h("i", { class: "dot", style: `background:${def.color}` }), + h("span", { style: "font-size:12.5px", text: def.name }), + ), + h("div", { class: "hint", style: "padding-top:4px", text: "No Coucou setup needed — events arrive over the hook relay." }), + ), + ); + } + + return h("section", {}, + h("h2", {}, h("span", { text: "Agents" })), + h("div", { class: "hint", text: "Agent pills appear on their own while a session is active. Pin one to keep it afterwards." }), + list, + ); +} + // ── General section ─────────────────────────────────────────────────────────── function generalSection(): HTMLElement { @@ -444,6 +487,7 @@ async function main() { claudeSection(status), apiSection(hasKey), integrationsSection(present), + agentsSection(), generalSection(), h("div", { class: "hint",