From 3e5d91f28775b2f2df21ef3f0ec3bd799610a447 Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Thu, 15 Jan 2026 17:41:56 -0800 Subject: [PATCH] feat(coding-agent): add input event for extension input interception (#761) * feat(coding-agent): add input event for extension input interception Extensions can now intercept, transform, or handle user input before the agent processes it. Three result types: continue (pass through), transform (modify text/images), handled (respond without LLM). Handlers chain transforms and short-circuit on handled. Source field identifies origin. * fix: make source public, use if/else over ternary * fix: remove response field, extension handles own UI --- packages/coding-agent/CHANGELOG.md | 5 + packages/coding-agent/docs/extensions.md | 25 ++++ .../examples/extensions/input-transform.ts | 43 +++++++ .../coding-agent/src/core/agent-session.ts | 32 ++++- .../coding-agent/src/core/extensions/index.ts | 4 + .../src/core/extensions/runner.ts | 34 ++++++ .../coding-agent/src/core/extensions/types.ts | 26 ++++ packages/coding-agent/src/index.ts | 3 + packages/coding-agent/src/modes/print-mode.ts | 2 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 1 + .../test/extensions-input-event.test.ts | 112 ++++++++++++++++++ 11 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/examples/extensions/input-transform.ts create mode 100644 packages/coding-agent/test/extensions-input-event.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e5848501..40ee6cde 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- New `input` event in extension system for intercepting, transforming, or handling user input before the agent processes it. Supports three result types: `continue` (pass through), `transform` (modify text/images), `handled` (respond without LLM). Handlers chain transforms and short-circuit on handled. +- Extension example: `input-transform.ts` demonstrating input interception patterns (quick mode, instant commands, source routing) + ### Fixed - Session tree now preserves branch connectors and indentation when filters hide intermediate entries so descendants attach to the nearest visible ancestor and sibling branches align. Fixed in both TUI and HTML export ([#739](https://github.com/badlogic/pi-mono/pull/739) by [@w-winter](https://github.com/w-winter)) diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index e6b2e965..b128a35d 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -256,6 +256,7 @@ pi starts ▼ user sends prompt ─────────────────────────────────────────┐ │ │ + ├─► input (can transform or handle completely) │ ├─► before_agent_start (can inject message, modify system prompt) ├─► agent_start │ │ │ @@ -574,6 +575,30 @@ pi.on("user_bash", (event, ctx) => { **Examples:** [ssh.ts](../examples/extensions/ssh.ts), [interactive-shell.ts](../examples/extensions/interactive-shell.ts) +### Input Events + +#### input + +Fired when user input is received, before agent processing. Can transform or handle completely. + +```typescript +pi.on("input", async (event, ctx) => { + // event.text, event.images, event.source ("interactive" | "rpc" | "extension") + + if (event.text.startsWith("?quick ")) + return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` }; + + if (event.text === "ping") { + ctx.ui.notify("pong", "info"); // Extension handles its own feedback + return { action: "handled" }; // Skip LLM + } + + return { action: "continue" }; // Default: pass through +}); +``` + +**Results:** `continue` (pass through), `transform` (modify text/images), `handled` (skip LLM). Transforms chain; first "handled" wins. See [input-transform.ts](../examples/extensions/input-transform.ts). + ## ExtensionContext Every handler receives `ctx: ExtensionContext`: diff --git a/packages/coding-agent/examples/extensions/input-transform.ts b/packages/coding-agent/examples/extensions/input-transform.ts new file mode 100644 index 00000000..af785b4d --- /dev/null +++ b/packages/coding-agent/examples/extensions/input-transform.ts @@ -0,0 +1,43 @@ +/** + * Input Transform Example - demonstrates the `input` event for intercepting user input. + * + * Start pi with this extension: + * pi -e ./examples/extensions/input-transform.ts + * + * Then type these inside pi: + * ?quick What is TypeScript? → "Respond briefly: What is TypeScript?" + * ping → "pong" (instant, no LLM) + * time → current time (instant, no LLM) + */ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.on("input", async (event, ctx) => { + // Source-based logic: skip processing for extension-injected messages + if (event.source === "extension") { + return { action: "continue" }; + } + + // Transform: ?quick prefix for brief responses + if (event.text.startsWith("?quick ")) { + const query = event.text.slice(7).trim(); + if (!query) { + ctx.ui.notify("Usage: ?quick ", "warning"); + return { action: "handled" }; + } + return { action: "transform", text: `Respond briefly in 1-2 sentences: ${query}` }; + } + + // Handle: instant responses without LLM (extension shows its own feedback) + if (event.text.toLowerCase() === "ping") { + ctx.ui.notify("pong", "info"); + return { action: "handled" }; + } + if (event.text.toLowerCase() === "time") { + ctx.ui.notify(new Date().toLocaleString(), "info"); + return { action: "handled" }; + } + + return { action: "continue" }; + }); +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index bcd7bd43..08899c39 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -39,6 +39,7 @@ import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index. import { createToolHtmlRenderer } from "./export-html/tool-renderer.js"; import type { ExtensionRunner, + InputSource, SessionBeforeCompactResult, SessionBeforeForkResult, SessionBeforeSwitchResult, @@ -101,6 +102,8 @@ export interface PromptOptions { images?: ImageContent[]; /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */ streamingBehavior?: "steer" | "followUp"; + /** Source of input for extension input event handlers. Defaults to "interactive". */ + source?: InputSource; } /** Result from cycleModel() */ @@ -566,8 +569,28 @@ export class AgentSession { } } + // Emit input event for extension interception (before template expansion) + let currentText = text; + let currentImages = options?.images; + if (this._extensionRunner?.hasHandlers("input")) { + const inputResult = await this._extensionRunner.emitInput( + currentText, + currentImages, + options?.source ?? "interactive", + ); + if (inputResult.action === "handled") { + return; + } + if (inputResult.action === "transform") { + currentText = inputResult.text; + currentImages = inputResult.images ?? currentImages; + } + } + // Expand file-based prompt templates if requested - const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this._promptTemplates]) : text; + const expandedText = expandPromptTemplates + ? expandPromptTemplate(currentText, [...this._promptTemplates]) + : currentText; // If streaming, queue via steer() or followUp() based on option if (this.isStreaming) { @@ -616,8 +639,8 @@ export class AgentSession { // Add user message const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; - if (options?.images) { - userContent.push(...options.images); + if (currentImages) { + userContent.push(...currentImages); } messages.push({ role: "user", @@ -635,7 +658,7 @@ export class AgentSession { if (this._extensionRunner) { const result = await this._extensionRunner.emitBeforeAgentStart( expandedText, - options?.images, + currentImages, this._baseSystemPrompt, ); // Add all custom messages from extensions @@ -853,6 +876,7 @@ export class AgentSession { expandPromptTemplates: false, streamingBehavior: options?.deliverAs, images, + source: "extension", }); } diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 0929c99c..9289a440 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -61,6 +61,10 @@ export type { GetAllToolsHandler, GetThinkingLevelHandler, GrepToolResultEvent, + // Events - Input + InputEvent, + InputEventResult, + InputSource, KeybindingsManager, LoadExtensionsResult, LsToolResultEvent, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 04e62666..117f89c5 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -25,6 +25,9 @@ import type { ExtensionRuntime, ExtensionShortcut, ExtensionUIContext, + InputEvent, + InputEventResult, + InputSource, MessageRenderer, RegisteredCommand, RegisteredTool, @@ -538,4 +541,35 @@ export class ExtensionRunner { return undefined; } + + /** Emit input event. Transforms chain, "handled" short-circuits. */ + async emitInput(text: string, images: ImageContent[] | undefined, source: InputSource): Promise { + const ctx = this.createContext(); + let currentText = text; + let currentImages = images; + + for (const ext of this.extensions) { + for (const handler of ext.handlers.get("input") ?? []) { + try { + const event: InputEvent = { type: "input", text: currentText, images: currentImages, source }; + const result = (await handler(event, ctx)) as InputEventResult | undefined; + if (result?.action === "handled") return result; + if (result?.action === "transform") { + currentText = result.text; + currentImages = result.images ?? currentImages; + } + } catch (err) { + this.emitError({ + extensionPath: ext.path, + event: "input", + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + } + } + } + return currentText !== text || currentImages !== images + ? { action: "transform", text: currentText, images: currentImages } + : { action: "continue" }; + } } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 38debe33..e837cf40 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -446,6 +446,30 @@ export interface UserBashEvent { cwd: string; } +// ============================================================================ +// Input Events +// ============================================================================ + +/** Source of user input */ +export type InputSource = "interactive" | "rpc" | "extension"; + +/** Fired when user input is received, before agent processing */ +export interface InputEvent { + type: "input"; + /** The input text */ + text: string; + /** Attached images, if any */ + images?: ImageContent[]; + /** Where the input came from */ + source: InputSource; +} + +/** Result from input event handler */ +export type InputEventResult = + | { action: "continue" } + | { action: "transform"; text: string; images?: ImageContent[] } + | { action: "handled" }; + // ============================================================================ // Tool Events // ============================================================================ @@ -551,6 +575,7 @@ export type ExtensionEvent = | TurnEndEvent | ModelSelectEvent | UserBashEvent + | InputEvent | ToolCallEvent | ToolResultEvent; @@ -675,6 +700,7 @@ export interface ExtensionAPI { on(event: "tool_call", handler: ExtensionHandler): void; on(event: "tool_result", handler: ExtensionHandler): void; on(event: "user_bash", handler: ExtensionHandler): void; + on(event: "input", handler: ExtensionHandler): void; // ========================================================================= // Tool Registration diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 83e99cc6..e93f0eec 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -64,6 +64,9 @@ export type { ExtensionShortcut, ExtensionUIContext, ExtensionUIDialogOptions, + InputEvent, + InputEventResult, + InputSource, KeybindingsManager, LoadExtensionsResult, MessageRenderer, diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 68f34297..a2b0081e 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -99,7 +99,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti return { cancelled: result.cancelled }; }, }, - // No UI context + // No UI context - hasUI will be false ); extensionRunner.onError((err) => { console.error(`Extension error (${err.extensionPath}): ${err.error}`); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 6741631d..dc8a7a4a 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -348,6 +348,7 @@ export async function runRpcMode(session: AgentSession): Promise { .prompt(command.message, { images: command.images, streamingBehavior: command.streamingBehavior, + source: "rpc", }) .catch((e) => output(error(id, "prompt", e.message))); return success(id, "prompt"); diff --git a/packages/coding-agent/test/extensions-input-event.test.ts b/packages/coding-agent/test/extensions-input-event.test.ts new file mode 100644 index 00000000..c990b7ef --- /dev/null +++ b/packages/coding-agent/test/extensions-input-event.test.ts @@ -0,0 +1,112 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AuthStorage } from "../src/core/auth-storage.js"; +import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js"; +import { ExtensionRunner } from "../src/core/extensions/runner.js"; +import { ModelRegistry } from "../src/core/model-registry.js"; +import { SessionManager } from "../src/core/session-manager.js"; + +describe("Input Event", () => { + let tempDir: string; + let extensionsDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-input-test-")); + extensionsDir = path.join(tempDir, "extensions"); + fs.mkdirSync(extensionsDir); + // Clean globalThis test vars + delete (globalThis as any).testVar; + }); + + afterEach(() => fs.rmSync(tempDir, { recursive: true, force: true })); + + async function createRunner(...extensions: string[]) { + // Clear and recreate extensions dir for clean state + fs.rmSync(extensionsDir, { recursive: true, force: true }); + fs.mkdirSync(extensionsDir); + for (let i = 0; i < extensions.length; i++) fs.writeFileSync(path.join(extensionsDir, `e${i}.ts`), extensions[i]); + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const sm = SessionManager.inMemory(); + const mr = new ModelRegistry(new AuthStorage(path.join(tempDir, "auth.json"))); + return new ExtensionRunner(result.extensions, result.runtime, tempDir, sm, mr); + } + + it("returns continue when no handlers, undefined return, or explicit continue", async () => { + // No handlers + expect((await (await createRunner()).emitInput("x", undefined, "interactive")).action).toBe("continue"); + // Returns undefined + let r = await createRunner(`export default p => p.on("input", async () => {});`); + expect((await r.emitInput("x", undefined, "interactive")).action).toBe("continue"); + // Returns explicit continue + r = await createRunner(`export default p => p.on("input", async () => ({ action: "continue" }));`); + expect((await r.emitInput("x", undefined, "interactive")).action).toBe("continue"); + }); + + it("transforms text and preserves images when omitted", async () => { + const r = await createRunner( + `export default p => p.on("input", async e => ({ action: "transform", text: "T:" + e.text }));`, + ); + const imgs = [{ type: "image" as const, data: "orig", mimeType: "image/png" }]; + const result = await r.emitInput("hi", imgs, "interactive"); + expect(result).toEqual({ action: "transform", text: "T:hi", images: imgs }); + }); + + it("transforms and replaces images when provided", async () => { + const r = await createRunner( + `export default p => p.on("input", async () => ({ action: "transform", text: "X", images: [{ type: "image", data: "new", mimeType: "image/jpeg" }] }));`, + ); + const result = await r.emitInput("hi", [{ type: "image", data: "orig", mimeType: "image/png" }], "interactive"); + expect(result).toEqual({ + action: "transform", + text: "X", + images: [{ type: "image", data: "new", mimeType: "image/jpeg" }], + }); + }); + + it("chains transforms across multiple handlers", async () => { + const r = await createRunner( + `export default p => p.on("input", async e => ({ action: "transform", text: e.text + "[1]" }));`, + `export default p => p.on("input", async e => ({ action: "transform", text: e.text + "[2]" }));`, + ); + const result = await r.emitInput("X", undefined, "interactive"); + expect(result).toEqual({ action: "transform", text: "X[1][2]", images: undefined }); + }); + + it("short-circuits on handled and skips subsequent handlers", async () => { + (globalThis as any).testVar = false; + const r = await createRunner( + `export default p => p.on("input", async () => ({ action: "handled" }));`, + `export default p => p.on("input", async () => { globalThis.testVar = true; });`, + ); + expect(await r.emitInput("X", undefined, "interactive")).toEqual({ action: "handled" }); + expect((globalThis as any).testVar).toBe(false); + }); + + it("passes source correctly for all source types", async () => { + const r = await createRunner( + `export default p => p.on("input", async e => { globalThis.testVar = e.source; return { action: "continue" }; });`, + ); + for (const source of ["interactive", "rpc", "extension"] as const) { + await r.emitInput("x", undefined, source); + expect((globalThis as any).testVar).toBe(source); + } + }); + + it("catches handler errors and continues", async () => { + const r = await createRunner(`export default p => p.on("input", async () => { throw new Error("boom"); });`); + const errs: string[] = []; + r.onError((e) => errs.push(e.error)); + const result = await r.emitInput("x", undefined, "interactive"); + expect(result.action).toBe("continue"); + expect(errs).toContain("boom"); + }); + + it("hasHandlers returns correct value", async () => { + let r = await createRunner(); + expect(r.hasHandlers("input")).toBe(false); + r = await createRunner(`export default p => p.on("input", async () => {});`); + expect(r.hasHandlers("input")).toBe(true); + }); +});