mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 07:04:45 +00:00
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:
parent
012319e15a
commit
3e5d91f287
11 changed files with 282 additions and 5 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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`:
|
||||
|
|
|
|||
43
packages/coding-agent/examples/extensions/input-transform.ts
Normal file
43
packages/coding-agent/examples/extensions/input-transform.ts
Normal 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" };
|
||||
});
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ export type {
|
|||
GetAllToolsHandler,
|
||||
GetThinkingLevelHandler,
|
||||
GrepToolResultEvent,
|
||||
// Events - Input
|
||||
InputEvent,
|
||||
InputEventResult,
|
||||
InputSource,
|
||||
KeybindingsManager,
|
||||
LoadExtensionsResult,
|
||||
LsToolResultEvent,
|
||||
|
|
|
|||
|
|
@ -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<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" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ToolCallEvent, ToolCallEventResult>): void;
|
||||
on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
|
||||
on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;
|
||||
on(event: "input", handler: ExtensionHandler<InputEvent, InputEventResult>): void;
|
||||
|
||||
// =========================================================================
|
||||
// Tool Registration
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ export type {
|
|||
ExtensionShortcut,
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
InputEvent,
|
||||
InputEventResult,
|
||||
InputSource,
|
||||
KeybindingsManager,
|
||||
LoadExtensionsResult,
|
||||
MessageRenderer,
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -348,6 +348,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
.prompt(command.message, {
|
||||
images: command.images,
|
||||
streamingBehavior: command.streamingBehavior,
|
||||
source: "rpc",
|
||||
})
|
||||
.catch((e) => output(error(id, "prompt", e.message)));
|
||||
return success(id, "prompt");
|
||||
|
|
|
|||
112
packages/coding-agent/test/extensions-input-event.test.ts
Normal file
112
packages/coding-agent/test/extensions-input-event.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue