Extensions: add pi.sendUserMessage() for sending user messages

Adds sendUserMessage() to the extension API, allowing extensions to send
actual user messages (role: user) rather than custom messages. Unlike
sendMessage(), this always triggers a turn and behaves as if the user
typed the message.

- Add SendUserMessageHandler type and sendUserMessage() to ExtensionAPI
- Wire handler through loader, runner, and all modes
- Implement via prompt() with expandPromptTemplates: false
- Add send-user-message.ts example with /ask, /steer, /followup commands
- Document in extensions.md

fixes #483
This commit is contained in:
Mario Zechner 2026-01-06 13:40:24 +01:00
parent f023af0dab
commit 7210086677
13 changed files with 222 additions and 1 deletions

View file

@ -7,6 +7,7 @@
- Extensions can now replace the footer with `ctx.ui.setFooter()`, see `examples/extensions/custom-footer.ts` ([#481](https://github.com/badlogic/pi-mono/issues/481))
- Session ID is now forwarded to LLM providers for session-based caching (used by OpenAI Codex for prompt caching).
- Added `blockImages` setting to prevent images from being sent to LLM providers ([#492](https://github.com/badlogic/pi-mono/pull/492) by [@jsinge97](https://github.com/jsinge97))
- Extensions can now send user messages via `pi.sendUserMessage()` ([#483](https://github.com/badlogic/pi-mono/issues/483))
### Fixed

View file

@ -632,7 +632,7 @@ pi.registerTool({
### pi.sendMessage(message, options?)
Inject a message into the session:
Inject a custom message into the session:
```typescript
pi.sendMessage({
@ -653,6 +653,34 @@ pi.sendMessage({
- `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything.
- `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`).
### pi.sendUserMessage(content, options?)
Send a user message to the agent. Unlike `sendMessage()` which sends custom messages, this sends an actual user message that appears as if typed by the user. Always triggers a turn.
```typescript
// Simple text message
pi.sendUserMessage("What is 2+2?");
// With content array (text + images)
pi.sendUserMessage([
{ type: "text", text: "Describe this image:" },
{ type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } },
]);
// During streaming - must specify delivery mode
pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" });
pi.sendUserMessage("And then summarize", { deliverAs: "followUp" });
```
**Options:**
- `deliverAs` - Required when agent is streaming:
- `"steer"` - Interrupts after current tool, remaining tools skipped
- `"followUp"` - Waits for agent to finish all tools
When not streaming, the message is sent immediately and triggers a new turn. When streaming without `deliverAs`, throws an error.
See [send-user-message.ts](../examples/extensions/send-user-message.ts) for a complete example.
### pi.appendEntry(customType, data?)
Persist extension state (does NOT participate in LLM context):

View file

@ -42,6 +42,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors |
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
| `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions |
### Git Integration

View file

@ -0,0 +1,97 @@
/**
* Send User Message Example
*
* Demonstrates pi.sendUserMessage() for sending user messages from extensions.
* Unlike pi.sendMessage() which sends custom messages, sendUserMessage() sends
* actual user messages that appear in the conversation as if typed by the user.
*
* Usage:
* /ask What is 2+2? - Sends a user message (always triggers a turn)
* /steer Focus on X - Sends while streaming with steer delivery
* /followup And then? - Sends while streaming with followUp delivery
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// Simple command that sends a user message
pi.registerCommand("ask", {
description: "Send a user message to the agent",
handler: async (args, ctx) => {
if (!args.trim()) {
ctx.ui.notify("Usage: /ask <message>", "warning");
return;
}
// sendUserMessage always triggers a turn when not streaming
// If streaming, it will throw (no deliverAs specified)
if (!ctx.isIdle()) {
ctx.ui.notify("Agent is busy. Use /steer or /followup instead.", "warning");
return;
}
pi.sendUserMessage(args);
},
});
// Command that steers the agent mid-conversation
pi.registerCommand("steer", {
description: "Send a steering message (interrupts current processing)",
handler: async (args, ctx) => {
if (!args.trim()) {
ctx.ui.notify("Usage: /steer <message>", "warning");
return;
}
if (ctx.isIdle()) {
// Not streaming, just send normally
pi.sendUserMessage(args);
} else {
// Streaming - use steer to interrupt
pi.sendUserMessage(args, { deliverAs: "steer" });
}
},
});
// Command that queues a follow-up message
pi.registerCommand("followup", {
description: "Queue a follow-up message (waits for current processing)",
handler: async (args, ctx) => {
if (!args.trim()) {
ctx.ui.notify("Usage: /followup <message>", "warning");
return;
}
if (ctx.isIdle()) {
// Not streaming, just send normally
pi.sendUserMessage(args);
} else {
// Streaming - queue as follow-up
pi.sendUserMessage(args, { deliverAs: "followUp" });
ctx.ui.notify("Follow-up queued", "info");
}
},
});
// Example with content array (text + images would go here)
pi.registerCommand("askwith", {
description: "Send a user message with structured content",
handler: async (args, ctx) => {
if (!args.trim()) {
ctx.ui.notify("Usage: /askwith <message>", "warning");
return;
}
if (!ctx.isIdle()) {
ctx.ui.notify("Agent is busy", "warning");
return;
}
// sendUserMessage accepts string or (TextContent | ImageContent)[]
pi.sendUserMessage([
{ type: "text", text: `User request: ${args}` },
{ type: "text", text: "Please respond concisely." },
]);
},
});
}

View file

@ -788,6 +788,45 @@ export class AgentSession {
}
}
/**
* Send a user message to the agent. Always triggers a turn.
* When the agent is streaming, use deliverAs to specify how to queue the message.
*
* @param content User message content (string or content array)
* @param options.deliverAs Delivery mode when streaming: "steer" or "followUp"
*/
async sendUserMessage(
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" },
): Promise<void> {
// Normalize content to text string + optional images
let text: string;
let images: ImageContent[] | undefined;
if (typeof content === "string") {
text = content;
} else {
const textParts: string[] = [];
images = [];
for (const part of content) {
if (part.type === "text") {
textParts.push(part.text);
} else {
images.push(part);
}
}
text = textParts.join("\n");
if (images.length === 0) images = undefined;
}
// Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
await this.prompt(text, {
expandPromptTemplates: false,
streamingBehavior: options?.deliverAs,
images,
});
}
/**
* Clear all queued messages and return them.
* Useful for restoring to editor when user aborts.

View file

@ -52,6 +52,7 @@ export type {
RegisteredCommand,
RegisteredTool,
SendMessageHandler,
SendUserMessageHandler,
SessionBeforeBranchEvent,
SessionBeforeBranchResult,
SessionBeforeCompactEvent,

View file

@ -29,6 +29,7 @@ import type {
RegisteredCommand,
RegisteredTool,
SendMessageHandler,
SendUserMessageHandler,
SetActiveToolsHandler,
ToolDefinition,
} from "./types.js";
@ -117,6 +118,7 @@ function createExtensionAPI(
flagValues: Map<string, boolean | string>;
shortcuts: Map<KeyId, ExtensionShortcut>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setSendUserMessageHandler: (handler: SendUserMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;
@ -124,6 +126,7 @@ function createExtensionAPI(
setFlagValue: (name: string, value: boolean | string) => void;
} {
let sendMessageHandler: SendMessageHandler = () => {};
let sendUserMessageHandler: SendUserMessageHandler = () => {};
let appendEntryHandler: AppendEntryHandler = () => {};
let getActiveToolsHandler: GetActiveToolsHandler = () => [];
let getAllToolsHandler: GetAllToolsHandler = () => [];
@ -185,6 +188,10 @@ function createExtensionAPI(
sendMessageHandler(message, options);
},
sendUserMessage(content, options): void {
sendUserMessageHandler(content, options);
},
appendEntry(customType: string, data?: unknown): void {
appendEntryHandler(customType, data);
},
@ -218,6 +225,9 @@ function createExtensionAPI(
setSendMessageHandler: (handler: SendMessageHandler) => {
sendMessageHandler = handler;
},
setSendUserMessageHandler: (handler: SendUserMessageHandler) => {
sendUserMessageHandler = handler;
},
setAppendEntryHandler: (handler: AppendEntryHandler) => {
appendEntryHandler = handler;
},
@ -261,6 +271,7 @@ async function loadExtensionWithBun(
flagValues,
shortcuts,
setSendMessageHandler,
setSendUserMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
@ -282,6 +293,7 @@ async function loadExtensionWithBun(
flagValues,
shortcuts,
setSendMessageHandler,
setSendUserMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
@ -341,6 +353,7 @@ async function loadExtension(
flagValues,
shortcuts,
setSendMessageHandler,
setSendUserMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
@ -362,6 +375,7 @@ async function loadExtension(
flagValues,
shortcuts,
setSendMessageHandler,
setSendUserMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
@ -396,6 +410,7 @@ export function loadExtensionFromFactory(
flagValues,
shortcuts,
setSendMessageHandler,
setSendUserMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,
@ -416,6 +431,7 @@ export function loadExtensionFromFactory(
flagValues,
shortcuts,
setSendMessageHandler,
setSendUserMessageHandler,
setAppendEntryHandler,
setGetActiveToolsHandler,
setGetAllToolsHandler,

View file

@ -28,6 +28,7 @@ import type {
RegisteredCommand,
RegisteredTool,
SendMessageHandler,
SendUserMessageHandler,
SessionBeforeCompactResult,
SessionBeforeTreeResult,
SetActiveToolsHandler,
@ -108,6 +109,7 @@ export class ExtensionRunner {
initialize(options: {
getModel: () => Model<any> | undefined;
sendMessageHandler: SendMessageHandler;
sendUserMessageHandler: SendUserMessageHandler;
appendEntryHandler: AppendEntryHandler;
getActiveToolsHandler: GetActiveToolsHandler;
getAllToolsHandler: GetAllToolsHandler;
@ -140,6 +142,7 @@ export class ExtensionRunner {
for (const ext of this.extensions) {
ext.setSendMessageHandler(options.sendMessageHandler);
ext.setSendUserMessageHandler(options.sendUserMessageHandler);
ext.setAppendEntryHandler(options.appendEntryHandler);
ext.setGetActiveToolsHandler(options.getActiveToolsHandler);
ext.setGetAllToolsHandler(options.getAllToolsHandler);

View file

@ -592,6 +592,15 @@ export interface ExtensionAPI {
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
): void;
/**
* Send a user message to the agent. Always triggers a turn.
* When the agent is streaming, use deliverAs to specify how to queue the message.
*/
sendUserMessage(
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" },
): void;
/** Append a custom entry to the session for state persistence (not sent to LLM). */
appendEntry<T = unknown>(customType: string, data?: T): void;
@ -645,6 +654,11 @@ export type SendMessageHandler = <T = unknown>(
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
) => void;
export type SendUserMessageHandler = (
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" },
) => void;
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
export type GetActiveToolsHandler = () => string[];
@ -665,6 +679,7 @@ export interface LoadedExtension {
flagValues: Map<string, boolean | string>;
shortcuts: Map<KeyId, ExtensionShortcut>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setSendUserMessageHandler: (handler: SendUserMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
setGetAllToolsHandler: (handler: GetAllToolsHandler) => void;

View file

@ -442,6 +442,11 @@ export class InteractiveMode {
this.showError(`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`);
});
},
sendUserMessageHandler: (content, options) => {
this.session.sendUserMessage(content, options).catch((err) => {
this.showError(`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`);
});
},
appendEntryHandler: (customType, data) => {
this.sessionManager.appendCustomEntry(customType, data);
},

View file

@ -37,6 +37,11 @@ export async function runPrintMode(
console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
},
sendUserMessageHandler: (content, options) => {
session.sendUserMessage(content, options).catch((e) => {
console.error(`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
},
appendEntryHandler: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},

View file

@ -229,6 +229,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
output(error(undefined, "extension_send", e.message));
});
},
sendUserMessageHandler: (content, options) => {
session.sendUserMessage(content, options).catch((e) => {
output(error(undefined, "extension_send_user", e.message));
});
},
appendEntryHandler: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},

View file

@ -83,6 +83,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
flagValues: new Map(),
shortcuts: new Map(),
setSendMessageHandler: () => {},
setSendUserMessageHandler: () => {},
setAppendEntryHandler: () => {},
setGetActiveToolsHandler: () => {},
setGetAllToolsHandler: () => {},
@ -111,6 +112,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
extensionRunner.initialize({
getModel: () => session.model,
sendMessageHandler: async () => {},
sendUserMessageHandler: async () => {},
appendEntryHandler: async () => {},
getActiveToolsHandler: () => [],
getAllToolsHandler: () => [],
@ -284,6 +286,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
flagValues: new Map(),
shortcuts: new Map(),
setSendMessageHandler: () => {},
setSendUserMessageHandler: () => {},
setAppendEntryHandler: () => {},
setGetActiveToolsHandler: () => {},
setGetAllToolsHandler: () => {},
@ -339,6 +342,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
flagValues: new Map(),
shortcuts: new Map(),
setSendMessageHandler: () => {},
setSendUserMessageHandler: () => {},
setAppendEntryHandler: () => {},
setGetActiveToolsHandler: () => {},
setGetAllToolsHandler: () => {},
@ -376,6 +380,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
flagValues: new Map(),
shortcuts: new Map(),
setSendMessageHandler: () => {},
setSendUserMessageHandler: () => {},
setAppendEntryHandler: () => {},
setGetActiveToolsHandler: () => {},
setGetAllToolsHandler: () => {},