Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/coding-agent/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1667,6 +1667,25 @@ The hook is display-only. It does not modify persisted messages or model context

See [built-in-message-renderer.ts](../examples/extensions/built-in-message-renderer.ts) for a complete wrapping example.

### pi.registerTurnBoundaryRenderer(transform)

Wrap the visual boundary Pi inserts before each user turn after the first. Without an override, the boundary is Pi's existing blank one-line spacer. The transform receives the current renderer and returns another synchronous renderer; multiple extensions compose in load order, with later transforms wrapping earlier ones.

The renderer receives `{ message, source, isReplay }` and the current theme. `message` is an isolated snapshot. `source` is `"interactive"`, `"rpc"`, or `"extension"` for live prompts that enter through the corresponding input path. It is undefined for restored messages and direct `steer()` or `followUp()` calls whose origin is not known. `isReplay` is true while Pi reconstructs a saved transcript.

Pi does not invoke the hook for the first rendered user message because there is no preceding turn to separate. Normal and skill-invocation user turns share the same outer boundary. If a transform throws or returns an invalid component, Pi falls back to the previous renderer layer.

```typescript
import { Text } from "@earendil-works/pi-tui";

pi.registerTurnBoundaryRenderer((_current) => (context, theme) => {
const source = context.isReplay ? "replay" : (context.source ?? "unknown");
return new Text(theme.fg("dim", `── turn · ${source} ──`), 0, 0);
});
```

See [turn-boundary-renderer.ts](../examples/extensions/turn-boundary-renderer.ts) for a complete example.

### pi.registerMarkdownTransformer(transformer)

Register a transformer for the Markdown in normal user text, assistant text, and thinking blocks. Transformers run in extension load order, and each transformer receives the Markdown returned by the previous transformer. After the chain finishes, Pi renders the transformed content with its built-in renderer.
Expand Down Expand Up @@ -2937,6 +2956,8 @@ See [tui.md](tui.md) Pattern 7 for a complete example with mode indicator.

Use `pi.registerBuiltInMessageRenderer()` to wrap the native user or assistant transcript component without changing the message stored in the session or sent to the model. The current renderer can be delegated to, padded differently, or placed inside a component-owned card. See [built-in-message-renderer.ts](../examples/extensions/built-in-message-renderer.ts).

Use `pi.registerTurnBoundaryRenderer()` to replace or wrap the blank spacer before each user turn after the first. See [turn-boundary-renderer.ts](../examples/extensions/turn-boundary-renderer.ts).

Register a custom renderer for messages with your `customType`. Use message renderers for content that should participate in LLM context:

```typescript
Expand Down Expand Up @@ -3073,6 +3094,7 @@ All examples in [examples/extensions/](../examples/extensions/).
| `status-line.ts` | Footer status indicator | `setStatus`, session events |
| `working-indicator.ts` | Customize the streaming working indicator | `setWorkingIndicator`, `registerCommand` |
| `built-in-message-renderer.ts` | Wrap native user and assistant transcript cards | `registerBuiltInMessageRenderer` |
| `turn-boundary-renderer.ts` | Replace the boundary before user turns | `registerTurnBoundaryRenderer` |
| `github-issue-autocomplete.ts` | Add `#1234` issue completions on top of built-in autocomplete by preloading recent open issues from `gh issue list` | `addAutocompleteProvider`, `on("session_start")`, `exec` |
| `custom-footer.ts` | Replace footer entirely | `registerCommand`, `setFooter` |
| `custom-header.ts` | Replace startup header | `on("session_start")`, `setHeader` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";

export default function (pi: ExtensionAPI) {
pi.registerTurnBoundaryRenderer((_current) => (context, theme) => {
const source = context.isReplay ? "replay" : (context.source ?? "unknown");
return new Text(theme.fg("dim", `── turn · ${source} ──`), 0, 0);
});
}
94 changes: 71 additions & 23 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,12 @@ export function parseSkillBlock(text: string): ParsedSkillBlock | null {

/** Session-specific events that extend the core AgentEvent */
export type AgentSessionEvent =
| Exclude<AgentEvent, { type: "agent_end" }>
| Exclude<AgentEvent, { type: "agent_end" | "message_start" }>
| {
type: "message_start";
message: AgentMessage;
source?: InputSource;
}
| {
type: "agent_end";
messages: AgentMessage[];
Expand Down Expand Up @@ -324,6 +329,7 @@ export class AgentSession {
// Event subscription state
private _unsubscribeAgent?: () => void;
private _eventListeners: AgentSessionEventListener[] = [];
private _inputSources = new WeakMap<AgentMessage, InputSource>();
private _isAgentRunActive = false;
private _pendingExtensionMessageActions = 0;
private _idleWaitPromise: Promise<void> | undefined;
Expand Down Expand Up @@ -637,8 +643,18 @@ export class AgentSession {
// Emit to extensions first
await this._emitExtensionEvent(event);

let sessionEvent: AgentSessionEvent;
if (event.type === "message_start") {
const source = this._consumeInputSource(event.message);
sessionEvent = { ...event, source };
} else if (event.type === "agent_end") {
sessionEvent = { ...event, willRetry: this._willRetryAfterAgentEnd(event) };
} else {
sessionEvent = event;
}

// Notify all listeners
this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
this._emit(sessionEvent);

// Handle session persistence
if (event.type === "message_end") {
Expand Down Expand Up @@ -1078,7 +1094,12 @@ export class AgentSession {
// Prompting
// =========================================================================

private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {
private async _runAgentPrompt(messages: AgentMessage | AgentMessage[], inputSource?: InputSource): Promise<void> {
const userMessage = (Array.isArray(messages) ? messages : [messages]).find((message) => message.role === "user");
if (userMessage && inputSource) {
this._inputSources.set(userMessage, inputSource);
}

this._isAgentRunActive = true;
try {
await this.agent.prompt(messages);
Expand All @@ -1092,12 +1113,24 @@ export class AgentSession {
await this.agent.continue();
}
} finally {
if (userMessage) {
this._inputSources.delete(userMessage);
}
this._systemPromptOverride = undefined;
this._flushPendingBashMessages();
await this._emitAgentSettled();
}
}

private _consumeInputSource(message: AgentMessage): InputSource | undefined {
if (message.role !== "user") {
return undefined;
}
const source = this._inputSources.get(message);
this._inputSources.delete(message);
return source;
}

private async _handlePostAgentRun(): Promise<boolean> {
const msg = this._lastAssistantMessage;
this._lastAssistantMessage = undefined;
Expand Down Expand Up @@ -1140,6 +1173,7 @@ export class AgentSession {
async prompt(text: string, options?: PromptOptions): Promise<void> {
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
const preflightResult = options?.preflightResult;
const inputSource = options?.source ?? "interactive";
let messages: AgentMessage[] | undefined;

try {
Expand All @@ -1161,7 +1195,7 @@ export class AgentSession {
const inputResult = await this._extensionRunner.emitInput(
currentText,
currentImages,
options?.source ?? "interactive",
inputSource,
this.isStreaming ? options?.streamingBehavior : undefined,
);
if (inputResult.action === "handled") {
Expand Down Expand Up @@ -1189,9 +1223,9 @@ export class AgentSession {
);
}
if (options.streamingBehavior === "followUp") {
await this._queueFollowUp(expandedText, currentImages);
await this._queueFollowUp(expandedText, currentImages, inputSource);
} else {
await this._queueSteer(expandedText, currentImages);
await this._queueSteer(expandedText, currentImages, inputSource);
}
preflightResult?.(true);
return;
Expand Down Expand Up @@ -1287,7 +1321,7 @@ export class AgentSession {
}

preflightResult?.(true);
await this._runAgentPrompt(messages);
await this._runAgentPrompt(messages, inputSource);
}

queueCommand(command: string, args = "", options?: QueueCommandOptions): void {
Expand Down Expand Up @@ -1475,35 +1509,49 @@ export class AgentSession {
/**
* Internal: Queue a steering message (already expanded, no extension command check).
*/
private async _queueSteer(text: string, images?: ImageContent[]): Promise<void> {
this._steeringMessages.push(text);
this._emitQueueUpdate();
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
if (images) {
content.push(...images);
}
this.agent.steer({
role: "user",
content,
timestamp: Date.now(),
});
private async _queueSteer(text: string, images?: ImageContent[], source?: InputSource): Promise<void> {
this._queueUserMessage("steer", text, images, source);
}

/**
* Internal: Queue a follow-up message (already expanded, no extension command check).
*/
private async _queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
this._followUpMessages.push(text);
private async _queueFollowUp(text: string, images?: ImageContent[], source?: InputSource): Promise<void> {
this._queueUserMessage("followUp", text, images, source);
}

private _queueUserMessage(
delivery: "steer" | "followUp",
text: string,
images?: ImageContent[],
source?: InputSource,
): void {
const queue = delivery === "steer" ? this._steeringMessages : this._followUpMessages;
queue.push(text);
this._emitQueueUpdate();

const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
if (images) {
content.push(...images);
}
this.agent.followUp({
const message: AgentMessage = {
role: "user",
content,
timestamp: Date.now(),
});
};
if (source) {
this._inputSources.set(message, source);
}
try {
if (delivery === "steer") {
this.agent.steer(message);
} else {
this.agent.followUp(message);
}
} catch (error) {
this._inputSources.delete(message);
throw error;
}
}

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/coding-agent/src/core/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ export type {
TreeNavigationOptions,
TreeNavigationSummary,
TreePreparation,
TurnBoundaryContext,
TurnBoundaryRenderer,
TurnBoundaryRendererTransform,
TurnEndEvent,
TurnStartEvent,
// Events - User Bash
Expand Down
7 changes: 7 additions & 0 deletions packages/coding-agent/src/core/extensions/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type {
RegisteredCommand,
ToolDefinition,
ToolTransform,
TurnBoundaryRendererTransform,
} from "./types.ts";

/** Modules available to extensions via virtualModules (for compiled Bun binary) */
Expand Down Expand Up @@ -321,6 +322,12 @@ function createExtensionAPI(
});
},

registerTurnBoundaryRenderer(transform: TurnBoundaryRendererTransform): void {
runtime.assertActive();
extension.turnBoundaryRendererTransforms ??= [];
extension.turnBoundaryRendererTransforms.push({ transform });
},

registerMarkdownTransformer(transformer: MarkdownTransformer): void {
runtime.assertActive();
extension.markdownTransformer = transformer;
Expand Down
7 changes: 7 additions & 0 deletions packages/coding-agent/src/core/extensions/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import type {
ToolResultEvent,
ToolResultEventResult,
TreeNavigationOptions,
TurnBoundaryRendererTransform,
UserBashEvent,
UserBashEventResult,
} from "./types.ts";
Expand Down Expand Up @@ -610,6 +611,12 @@ export class ExtensionRunner {
);
}

getTurnBoundaryRendererTransforms(): TurnBoundaryRendererTransform[] {
return this.extensions.flatMap((extension) =>
(extension.turnBoundaryRendererTransforms ?? []).map((registration) => registration.transform),
);
}

getMarkdownTransformers(): MarkdownTransformer[] {
return this.extensions.flatMap((ext) => (ext.markdownTransformer ? [ext.markdownTransformer] : []));
}
Expand Down
18 changes: 18 additions & 0 deletions packages/coding-agent/src/core/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,20 @@ export interface RegisteredBuiltInMessageRendererTransform {
transform: BuiltInMessageRendererTransform<BuiltInMessageRole>;
}

export interface TurnBoundaryContext {
message: BuiltInMessageByRole["user"];
source?: InputSource;
isReplay: boolean;
}

export type TurnBoundaryRenderer = (context: TurnBoundaryContext, theme: Theme) => Component;

export type TurnBoundaryRendererTransform = (renderer: TurnBoundaryRenderer) => TurnBoundaryRenderer;

export interface RegisteredTurnBoundaryRendererTransform {
transform: TurnBoundaryRendererTransform;
}

export type EntryRenderer<T = unknown> = (
entry: CustomEntry<T>,
options: EntryRenderOptions,
Expand Down Expand Up @@ -1396,6 +1410,9 @@ export interface ExtensionAPI {
transform: BuiltInMessageRendererTransform<Role>,
): void;

/** Wrap the visual boundary Pi inserts before each user turn after the first. */
registerTurnBoundaryRenderer(transform: TurnBoundaryRendererTransform): void;

/** Register a transformer for user and assistant Markdown before Pi renders it in the interactive transcript. */
registerMarkdownTransformer(transformer: MarkdownTransformer): void;

Expand Down Expand Up @@ -1816,6 +1833,7 @@ export interface Extension {
toolTransforms?: RegisteredToolTransform[];
messageRenderers: Map<string, MessageRenderer>;
builtInMessageRendererTransforms?: RegisteredBuiltInMessageRendererTransform[];
turnBoundaryRendererTransforms?: RegisteredTurnBoundaryRendererTransform[];
markdownTransformer?: MarkdownTransformer;
entryRenderers?: Map<string, EntryRenderer>;
commands: Map<string, RegisteredCommand>;
Expand Down
3 changes: 3 additions & 0 deletions packages/coding-agent/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export {
type ToolDefinition,
type ToolRenderResultOptions,
type ToolResultEvent,
type TurnBoundaryContext,
type TurnBoundaryRenderer,
type TurnBoundaryRendererTransform,
type TurnEndEvent,
type TurnStartEvent,
type WorkingIndicatorOptions,
Expand Down
3 changes: 3 additions & 0 deletions packages/coding-agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ export type {
ToolRenderResultOptions,
ToolResultEvent,
ToolTransform,
TurnBoundaryContext,
TurnBoundaryRenderer,
TurnBoundaryRendererTransform,
TurnEndEvent,
TurnStartEvent,
UserBashEvent,
Expand Down
Loading
Loading