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
This commit is contained in:
Nico Bailon 2026-01-15 17:41:56 -08:00 committed by GitHub
parent 012319e15a
commit 3e5d91f287
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 282 additions and 5 deletions

View file

@ -2,6 +2,11 @@
## [Unreleased] ## [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 ### 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)) - 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))

View file

@ -256,6 +256,7 @@ pi starts
user sends prompt ─────────────────────────────────────────┐ user sends prompt ─────────────────────────────────────────┐
│ │ │ │
├─► input (can transform or handle completely) │
├─► before_agent_start (can inject message, modify system prompt) ├─► before_agent_start (can inject message, modify system prompt)
├─► agent_start │ ├─► 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) **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 ## ExtensionContext
Every handler receives `ctx: ExtensionContext`: Every handler receives `ctx: ExtensionContext`:

View file

@ -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 <question>", "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" };
});
}

View file

@ -39,6 +39,7 @@ import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
import type { import type {
ExtensionRunner, ExtensionRunner,
InputSource,
SessionBeforeCompactResult, SessionBeforeCompactResult,
SessionBeforeForkResult, SessionBeforeForkResult,
SessionBeforeSwitchResult, SessionBeforeSwitchResult,
@ -101,6 +102,8 @@ export interface PromptOptions {
images?: ImageContent[]; images?: ImageContent[];
/** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */ /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */
streamingBehavior?: "steer" | "followUp"; streamingBehavior?: "steer" | "followUp";
/** Source of input for extension input event handlers. Defaults to "interactive". */
source?: InputSource;
} }
/** Result from cycleModel() */ /** 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 // 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 streaming, queue via steer() or followUp() based on option
if (this.isStreaming) { if (this.isStreaming) {
@ -616,8 +639,8 @@ export class AgentSession {
// Add user message // Add user message
const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
if (options?.images) { if (currentImages) {
userContent.push(...options.images); userContent.push(...currentImages);
} }
messages.push({ messages.push({
role: "user", role: "user",
@ -635,7 +658,7 @@ export class AgentSession {
if (this._extensionRunner) { if (this._extensionRunner) {
const result = await this._extensionRunner.emitBeforeAgentStart( const result = await this._extensionRunner.emitBeforeAgentStart(
expandedText, expandedText,
options?.images, currentImages,
this._baseSystemPrompt, this._baseSystemPrompt,
); );
// Add all custom messages from extensions // Add all custom messages from extensions
@ -853,6 +876,7 @@ export class AgentSession {
expandPromptTemplates: false, expandPromptTemplates: false,
streamingBehavior: options?.deliverAs, streamingBehavior: options?.deliverAs,
images, images,
source: "extension",
}); });
} }

View file

@ -61,6 +61,10 @@ export type {
GetAllToolsHandler, GetAllToolsHandler,
GetThinkingLevelHandler, GetThinkingLevelHandler,
GrepToolResultEvent, GrepToolResultEvent,
// Events - Input
InputEvent,
InputEventResult,
InputSource,
KeybindingsManager, KeybindingsManager,
LoadExtensionsResult, LoadExtensionsResult,
LsToolResultEvent, LsToolResultEvent,

View file

@ -25,6 +25,9 @@ import type {
ExtensionRuntime, ExtensionRuntime,
ExtensionShortcut, ExtensionShortcut,
ExtensionUIContext, ExtensionUIContext,
InputEvent,
InputEventResult,
InputSource,
MessageRenderer, MessageRenderer,
RegisteredCommand, RegisteredCommand,
RegisteredTool, RegisteredTool,
@ -538,4 +541,35 @@ export class ExtensionRunner {
return undefined; return undefined;
} }
/** Emit input event. Transforms chain, "handled" short-circuits. */
async emitInput(text: string, images: ImageContent[] | undefined, source: InputSource): Promise<InputEventResult> {
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" };
}
} }

View file

@ -446,6 +446,30 @@ export interface UserBashEvent {
cwd: string; 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 // Tool Events
// ============================================================================ // ============================================================================
@ -551,6 +575,7 @@ export type ExtensionEvent =
| TurnEndEvent | TurnEndEvent
| ModelSelectEvent | ModelSelectEvent
| UserBashEvent | UserBashEvent
| InputEvent
| ToolCallEvent | ToolCallEvent
| ToolResultEvent; | ToolResultEvent;
@ -675,6 +700,7 @@ export interface ExtensionAPI {
on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void; on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void; on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void; on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;
on(event: "input", handler: ExtensionHandler<InputEvent, InputEventResult>): void;
// ========================================================================= // =========================================================================
// Tool Registration // Tool Registration

View file

@ -64,6 +64,9 @@ export type {
ExtensionShortcut, ExtensionShortcut,
ExtensionUIContext, ExtensionUIContext,
ExtensionUIDialogOptions, ExtensionUIDialogOptions,
InputEvent,
InputEventResult,
InputSource,
KeybindingsManager, KeybindingsManager,
LoadExtensionsResult, LoadExtensionsResult,
MessageRenderer, MessageRenderer,

View file

@ -99,7 +99,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
return { cancelled: result.cancelled }; return { cancelled: result.cancelled };
}, },
}, },
// No UI context // No UI context - hasUI will be false
); );
extensionRunner.onError((err) => { extensionRunner.onError((err) => {
console.error(`Extension error (${err.extensionPath}): ${err.error}`); console.error(`Extension error (${err.extensionPath}): ${err.error}`);

View file

@ -348,6 +348,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
.prompt(command.message, { .prompt(command.message, {
images: command.images, images: command.images,
streamingBehavior: command.streamingBehavior, streamingBehavior: command.streamingBehavior,
source: "rpc",
}) })
.catch((e) => output(error(id, "prompt", e.message))); .catch((e) => output(error(id, "prompt", e.message)));
return success(id, "prompt"); return success(id, "prompt");

View file

@ -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);
});
});