fix(coding-agent): add entry renderers for session entries

This commit is contained in:
Mario Zechner 2026-07-01 11:00:14 +02:00
parent 8c9436407c
commit ba10b60b51
18 changed files with 507 additions and 158 deletions

View file

@ -5,9 +5,11 @@
### Added
- Added public SDK exports for CLI-equivalent model and scoped-model resolution ([#6201](https://github.com/earendil-works/pi/issues/6201)).
- Added extension entry renderers for persisted display-only session entries that are rendered in interactive mode without being sent to the model context.
### Fixed
- Fixed custom session entries appended during assistant streaming to render before the live assistant message, matching persisted session order.
- Fixed oversized bash tool timeouts to fail with a clear validation error instead of being clamped to an immediate timeout ([#6181](https://github.com/earendil-works/pi/issues/6181)).
## [0.80.3] - 2026-06-30

View file

@ -944,9 +944,10 @@ Read-only access to session state. See [Session Format](session-format.md) for t
For `tool_call`, this state is synchronized through the current assistant message before handlers run. In parallel tool execution mode it is still not guaranteed to include sibling tool results from the same assistant message.
```typescript
ctx.sessionManager.getEntries() // All entries
ctx.sessionManager.getBranch() // Current branch
ctx.sessionManager.getLeafId() // Current leaf entry ID
ctx.sessionManager.getEntries() // All entries
ctx.sessionManager.getBranch() // Current branch
ctx.sessionManager.buildContextEntries() // Active branch entries with compaction applied
ctx.sessionManager.getLeafId() // Current leaf entry ID
```
### ctx.modelRegistry / ctx.model
@ -1352,7 +1353,7 @@ pi.registerTool({
### pi.sendMessage(message, options?)
Inject a custom message into the session.
Inject a custom message into the session. Custom messages participate in LLM context. For durable TUI-only content that should not be sent to the LLM, use [`pi.appendEntry()`](#piappendentrycustomtype-data) with [`pi.registerEntryRenderer()`](#piregisterentryrenderercustomtype-renderer).
```typescript
pi.sendMessage({
@ -1403,10 +1404,11 @@ See [send-user-message.ts](../examples/extensions/send-user-message.ts) for a co
### pi.appendEntry(customType, data?)
Persist extension state (does NOT participate in LLM context).
Persist extension data. Custom entries do NOT participate in LLM context. In interactive mode, they can also render inside the chat transcript when paired with `pi.registerEntryRenderer()`.
```typescript
pi.appendEntry("my-state", { count: 42 });
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });
// Restore on reload
pi.on("session_start", async (_event, ctx) => {
@ -1524,7 +1526,27 @@ mode and would not execute if sent via `prompt`.
### pi.registerMessageRenderer(customType, renderer)
Register a custom TUI renderer for messages with your `customType`. See [Custom UI](#custom-ui).
Register a custom TUI renderer for custom messages with your `customType`. Custom messages are created with `pi.sendMessage()` and participate in LLM context. See [Custom UI](#custom-ui).
### pi.registerEntryRenderer(customType, renderer)
Register a custom TUI renderer for custom entries with your `customType`. Custom entries are created with `pi.appendEntry()` and do not participate in LLM context.
```typescript
import { Box, Text } from "@earendil-works/pi-tui";
pi.registerEntryRenderer("status-card", (entry, { expanded }, theme) => {
const data = entry.data as { title: string; count: number };
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
box.addChild(new Text(`${theme.bold(data.title)}: ${data.count}`));
if (expanded) {
box.addChild(new Text(theme.fg("dim", JSON.stringify(data, null, 2))));
}
return box;
});
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });
```
### pi.registerShortcut(shortcut, options)
@ -2528,9 +2550,9 @@ ctx.ui.setEditorComponent((tui, theme, keybindings) =>
See [tui.md](tui.md) Pattern 7 for a complete example with mode indicator.
### Message Rendering
### Message and Entry Rendering
Register a custom renderer for messages with your `customType`:
Register a custom renderer for messages with your `customType`. Use message renderers for content that should participate in LLM context:
```typescript
import { Text } from "@earendil-works/pi-tui";
@ -2559,6 +2581,16 @@ pi.sendMessage({
});
```
For TUI-only content that should not be sent to the LLM, render custom entries instead:
```typescript
pi.registerEntryRenderer("my-card", (entry, options, theme) => {
return new Text(theme.fg("accent", JSON.stringify(entry.data)));
});
pi.appendEntry("my-card", { status: "done" });
```
### Theme Colors
All render functions receive a `theme` object. See [themes.md](themes.md) for creating custom themes and the full color palette.
@ -2685,6 +2717,7 @@ All examples in [examples/extensions/](../examples/extensions/).
| `custom-provider-gitlab-duo/` | GitLab Duo integration | `registerProvider` with OAuth |
| **Messages & Communication** |||
| `message-renderer.ts` | Custom message rendering | `registerMessageRenderer`, `sendMessage` |
| `entry-renderer.ts` | TUI-only custom entry rendering | `registerEntryRenderer`, `appendEntry` |
| `event-bus.ts` | Inter-extension events | `pi.events` |
| **Session Metadata** |||
| `session-name.ts` | Name sessions for selector | `setSessionName`, `getSessionName` |

View file

@ -255,7 +255,7 @@ Extension state persistence. Does NOT participate in LLM context.
{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-extension","data":{"count":42}}
```
Use `customType` to identify your extension's entries on reload.
Use `customType` to identify your extension's entries on reload. Interactive mode can render custom entries via `pi.registerEntryRenderer(customType, renderer)`, but they still do not participate in LLM context.
### CustomMessageEntry
@ -306,15 +306,24 @@ Entries form a tree:
## Context Building
`buildSessionContext()` walks from the current leaf to the root, producing the message list for the LLM:
`buildContextEntries()` walks from the current leaf to the root, producing the active entry list while honoring compaction:
1. Collects all entries on the path
2. Extracts current model and thinking level settings
3. If a `CompactionEntry` is on the path:
- Emits the summary first
- Then messages from `firstKeptEntryId` to compaction
- Then messages after compaction
4. Converts `BranchSummaryEntry` and `CustomMessageEntry` to appropriate message formats
2. If a `CompactionEntry` is on the path:
- Includes the compaction entry first
- Then entries from `firstKeptEntryId` to compaction
- Then entries after compaction
3. Preserves non-message entries in the selected range so interactive mode can render them
`buildSessionContext()` builds on that entry list to produce the message list for the LLM:
1. Extracts current model and thinking level settings from the full path
2. Converts selected entries to messages:
- `message` -> stored `AgentMessage`
- `compaction` -> `compactionSummary`
- `branch_summary` -> `branchSummary`
- `custom_message` -> `CustomMessage`
- `custom` -> no context message
## Parsing Example
@ -401,6 +410,7 @@ Key methods for working with sessions programmatically.
- `branchWithSummary(entryId, summary, details?, fromHook?)` - Branch with context summary
### Instance Methods - Context & Info
- `buildContextEntries()` - Get active branch entries with compaction applied
- `buildSessionContext()` - Get messages, thinkingLevel, and model for LLM
- `getEntries()` - All entries (excluding header)
- `getHeader()` - Session header metadata

View file

@ -112,6 +112,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
| Extension | Description |
|-----------|-------------|
| `message-renderer.ts` | Custom message rendering with colors and expandable details via `registerMessageRenderer` |
| `entry-renderer.ts` | TUI-only session entry rendering via `appendEntry` and `registerEntryRenderer` |
| `event-bus.ts` | Inter-extension communication via `pi.events` |
### Session Metadata

View file

@ -0,0 +1,41 @@
/**
* Custom entry rendering example.
*
* Shows how to render durable extension data inside the chat without sending it
* to the LLM. Custom entries are stored in the session via pi.appendEntry() and
* rendered in interactive mode via pi.registerEntryRenderer().
*
* Usage: /status-card [message]
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Box, Text } from "@earendil-works/pi-tui";
interface StatusCardData {
message: string;
timestamp: number;
}
export default function (pi: ExtensionAPI) {
pi.registerEntryRenderer<StatusCardData>("status-card", (entry, { expanded }, theme) => {
const data = entry.data ?? { message: "No data", timestamp: Date.now() };
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
box.addChild(new Text(`${theme.fg("accent", "[status]")} ${data.message}`, 0, 0));
if (expanded) {
box.addChild(new Text(theme.fg("dim", new Date(data.timestamp).toLocaleString()), 0, 0));
}
return box;
});
pi.registerCommand("status-card", {
description: "Render a durable status card that is not sent to the LLM",
handler: async (args) => {
pi.appendEntry<StatusCardData>("status-card", {
message: args.trim() || "Status card",
timestamp: Date.now(),
});
},
});
}

View file

@ -86,7 +86,7 @@ import type { BashExecutionMessage, CustomMessage } from "./messages.ts";
import type { ModelRegistry } from "./model-registry.ts";
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";
import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.ts";
import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts";
import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts";
import type { SettingsManager } from "./settings-manager.ts";
import type { SlashCommandInfo } from "./slash-commands.ts";
@ -137,6 +137,7 @@ export type AgentSessionEvent =
followUp: readonly string[];
}
| { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }
| { type: "entry_appended"; entry: SessionEntry }
| { type: "session_info_changed"; name: string | undefined }
| { type: "thinking_level_changed"; level: ThinkingLevel }
| {
@ -2262,7 +2263,11 @@ export class AgentSession {
});
},
appendEntry: (customType, data) => {
this.sessionManager.appendCustomEntry(customType, data);
const entryId = this.sessionManager.appendCustomEntry(customType, data);
const entry = this.sessionManager.getEntry(entryId);
if (entry) {
this._emit({ type: "entry_appended", entry });
}
},
setSessionName: (name) => {
this.setSessionName(name);

View file

@ -50,6 +50,9 @@ export type {
EditorFactory,
EditToolCallEvent,
EditToolResultEvent,
// Message and Entry Rendering
EntryRenderer,
EntryRenderOptions,
ExecOptions,
ExecResult,
Extension,
@ -91,7 +94,6 @@ export type {
LsToolResultEvent,
// Events - Message
MessageEndEvent,
// Message Rendering
MessageRenderer,
MessageRenderOptions,
MessageStartEvent,

View file

@ -30,6 +30,7 @@ import { execCommand } from "../exec.ts";
import { createSyntheticSourceInfo } from "../source-info.ts";
import { time } from "../timings.ts";
import type {
EntryRenderer,
Extension,
ExtensionAPI,
ExtensionFactory,
@ -269,6 +270,12 @@ function createExtensionAPI(
extension.messageRenderers.set(customType, renderer as MessageRenderer);
},
registerEntryRenderer<T>(customType: string, renderer: EntryRenderer<T>): void {
runtime.assertActive();
extension.entryRenderers ??= new Map();
extension.entryRenderers.set(customType, renderer as EntryRenderer);
},
// Flag access - checks extension registered it, reads from runtime
getFlag(name: string): boolean | string | undefined {
runtime.assertActive();
@ -415,6 +422,7 @@ function createExtension(extensionPath: string, resolvedPath: string): Extension
handlers: new Map(),
tools: new Map(),
messageRenderers: new Map(),
entryRenderers: new Map(),
commands: new Map(),
flags: new Map(),
shortcuts: new Map(),

View file

@ -19,6 +19,7 @@ import type {
ContextEvent,
ContextEventResult,
ContextUsage,
EntryRenderer,
Extension,
ExtensionActions,
ExtensionCommandContext,
@ -553,6 +554,16 @@ export class ExtensionRunner {
return undefined;
}
getEntryRenderer(customType: string): EntryRenderer | undefined {
for (const ext of this.extensions) {
const renderer = ext.entryRenderers?.get(customType);
if (renderer) {
return renderer;
}
}
return undefined;
}
private resolveRegisteredCommands(): ResolvedCommand[] {
const commands: RegisteredCommand[] = [];
const counts = new Map<string, number>();

View file

@ -52,6 +52,7 @@ import type { ModelRegistry } from "../model-registry.ts";
import type {
BranchSummaryEntry,
CompactionEntry,
CustomEntry,
ReadonlySessionManager,
SessionEntry,
SessionManager,
@ -1093,19 +1094,29 @@ export interface SessionBeforeTreeResult {
}
// ============================================================================
// Message Rendering
// Message and Entry Rendering
// ============================================================================
export interface MessageRenderOptions {
expanded: boolean;
}
export interface EntryRenderOptions {
expanded: boolean;
}
export type MessageRenderer<T = unknown> = (
message: CustomMessage<T>,
options: MessageRenderOptions,
theme: Theme,
) => Component | undefined;
export type EntryRenderer<T = unknown> = (
entry: CustomEntry<T>,
options: EntryRenderOptions,
theme: Theme,
) => Component | undefined;
// ============================================================================
// Command Registration
// ============================================================================
@ -1224,6 +1235,9 @@ export interface ExtensionAPI {
/** Register a custom renderer for CustomMessageEntry. */
registerMessageRenderer<T = unknown>(customType: string, renderer: MessageRenderer<T>): void;
/** Register a custom renderer for CustomEntry. Custom entries do not participate in LLM context. */
registerEntryRenderer<T = unknown>(customType: string, renderer: EntryRenderer<T>): void;
// =========================================================================
// Actions
// =========================================================================
@ -1598,6 +1612,7 @@ export interface Extension {
handlers: Map<string, HandlerFn[]>;
tools: Map<string, RegisteredTool>;
messageRenderers: Map<string, MessageRenderer>;
entryRenderers?: Map<string, EntryRenderer>;
commands: Map<string, RegisteredCommand>;
flags: Map<string, ExtensionFlag>;
shortcuts: Map<KeyId, ExtensionShortcut>;

View file

@ -194,6 +194,7 @@ export type ReadonlySessionManager = Pick<
| "getEntry"
| "getLabel"
| "getBranch"
| "buildContextEntries"
| "getHeader"
| "getEntries"
| "getTree"
@ -317,6 +318,126 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
return null;
}
function buildEntryIndex(entries: SessionEntry[], byId?: Map<string, SessionEntry>): Map<string, SessionEntry> {
if (byId) return byId;
const index = new Map<string, SessionEntry>();
for (const entry of entries) {
index.set(entry.id, entry);
}
return index;
}
function buildSessionPath(
entries: SessionEntry[],
leafId?: string | null,
byId?: Map<string, SessionEntry>,
): SessionEntry[] {
const index = buildEntryIndex(entries, byId);
let leaf: SessionEntry | undefined;
if (leafId === null) {
return [];
}
if (leafId) {
leaf = index.get(leafId);
}
leaf ??= entries[entries.length - 1];
if (!leaf) {
return [];
}
const path: SessionEntry[] = [];
let current: SessionEntry | undefined = leaf;
while (current) {
path.push(current);
current = current.parentId ? index.get(current.parentId) : undefined;
}
path.reverse();
return path;
}
function getSessionContextSettings(path: SessionEntry[]): Pick<SessionContext, "thinkingLevel" | "model"> {
let thinkingLevel = "off";
let model: { provider: string; modelId: string } | null = null;
for (const entry of path) {
if (entry.type === "thinking_level_change") {
thinkingLevel = entry.thinkingLevel;
} else if (entry.type === "model_change") {
model = { provider: entry.provider, modelId: entry.modelId };
} else if (entry.type === "message" && entry.message.role === "assistant") {
model = { provider: entry.message.provider, modelId: entry.message.model };
}
}
return { thinkingLevel, model };
}
/**
* Project one selected session entry into LLM/runtime messages.
* Plain custom entries are display/state entries and do not participate in context.
*/
export function sessionEntryToContextMessages(entry: SessionEntry): AgentMessage[] {
if (entry.type === "message") {
return [entry.message];
}
if (entry.type === "custom_message") {
return [createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp)];
}
if (entry.type === "branch_summary" && entry.summary) {
return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
}
if (entry.type === "compaction") {
return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp)];
}
return [];
}
/**
* Build the active, compaction-aware session entry list.
*
* This follows the current leaf path. If the path contains compaction entries,
* the latest compaction is represented by the compaction entry itself, followed
* by the kept entries starting at firstKeptEntryId and all entries after the
* compaction entry. Older summarized entries are omitted.
*/
export function buildContextEntries(
entries: SessionEntry[],
leafId?: string | null,
byId?: Map<string, SessionEntry>,
): SessionEntry[] {
const path = buildSessionPath(entries, leafId, byId);
let compaction: CompactionEntry | null = null;
for (const entry of path) {
if (entry.type === "compaction") {
compaction = entry;
}
}
if (!compaction) {
return path;
}
const compactionIdx = path.findIndex((entry) => entry.id === compaction.id);
if (compactionIdx < 0) {
return path;
}
const contextEntries: SessionEntry[] = [compaction];
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = path[i];
if (entry.id === compaction.firstKeptEntryId) {
foundFirstKept = true;
}
if (foundFirstKept) {
contextEntries.push(entry);
}
}
contextEntries.push(...path.slice(compactionIdx + 1));
return contextEntries;
}
/**
* Build the session context from entries using tree traversal.
* If leafId is provided, walks from that entry to root.
@ -327,108 +448,9 @@ export function buildSessionContext(
leafId?: string | null,
byId?: Map<string, SessionEntry>,
): SessionContext {
// Build uuid index if not available
if (!byId) {
byId = new Map<string, SessionEntry>();
for (const entry of entries) {
byId.set(entry.id, entry);
}
}
// Find leaf
let leaf: SessionEntry | undefined;
if (leafId === null) {
// Explicitly null - return no messages (navigated to before first entry)
return { messages: [], thinkingLevel: "off", model: null };
}
if (leafId) {
leaf = byId.get(leafId);
}
if (!leaf) {
// Fallback to last entry (when leafId is undefined)
leaf = entries[entries.length - 1];
}
if (!leaf) {
return { messages: [], thinkingLevel: "off", model: null };
}
// Walk from leaf to root, collecting path
const path: SessionEntry[] = [];
let current: SessionEntry | undefined = leaf;
while (current) {
path.push(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
path.reverse();
// Extract settings and find compaction
let thinkingLevel = "off";
let model: { provider: string; modelId: string } | null = null;
let compaction: CompactionEntry | null = null;
for (const entry of path) {
if (entry.type === "thinking_level_change") {
thinkingLevel = entry.thinkingLevel;
} else if (entry.type === "model_change") {
model = { provider: entry.provider, modelId: entry.modelId };
} else if (entry.type === "message" && entry.message.role === "assistant") {
model = { provider: entry.message.provider, modelId: entry.message.model };
} else if (entry.type === "compaction") {
compaction = entry;
}
}
// Build messages and collect corresponding entries
// When there's a compaction, we need to:
// 1. Emit summary first (entry = compaction)
// 2. Emit kept messages (from firstKeptEntryId up to compaction)
// 3. Emit messages after compaction
const messages: AgentMessage[] = [];
const appendMessage = (entry: SessionEntry) => {
if (entry.type === "message") {
messages.push(entry.message);
} else if (entry.type === "custom_message") {
messages.push(
createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
);
} else if (entry.type === "branch_summary" && entry.summary) {
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
}
};
if (compaction) {
// Emit summary first
messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));
// Find compaction index in path
const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id);
// Emit kept messages (before compaction, starting from firstKeptEntryId)
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = path[i];
if (entry.id === compaction.firstKeptEntryId) {
foundFirstKept = true;
}
if (foundFirstKept) {
appendMessage(entry);
}
}
// Emit messages after compaction
for (let i = compactionIdx + 1; i < path.length; i++) {
const entry = path[i];
appendMessage(entry);
}
} else {
// No compaction - emit all messages, handle branch summaries and custom messages
for (const entry of path) {
appendMessage(entry);
}
}
const path = buildSessionPath(entries, leafId, byId);
const { thinkingLevel, model } = getSessionContextSettings(path);
const messages = buildContextEntries(entries, leafId, byId).flatMap(sessionEntryToContextMessages);
return { messages, thinkingLevel, model };
}
@ -1164,6 +1186,14 @@ export class SessionManager {
return path;
}
/**
* Build the active, compaction-aware entry list for context/rendering.
* Uses tree traversal from current leaf.
*/
buildContextEntries(): SessionEntry[] {
return buildContextEntries(this.getEntries(), this.leafId, this.byId);
}
/**
* Build the session context (what gets sent to the LLM).
* Uses tree traversal from current leaf.

View file

@ -77,6 +77,8 @@ export type {
ContextUsage,
CustomToolCallEvent,
EditToolCallEvent,
EntryRenderer,
EntryRenderOptions,
ExecOptions,
ExecResult,
Extension,
@ -214,6 +216,7 @@ export {
} from "./core/sdk.ts";
export {
type BranchSummaryEntry,
buildContextEntries,
buildSessionContext,
type CompactionEntry,
CURRENT_SESSION_VERSION,
@ -234,6 +237,7 @@ export {
SessionManager,
type SessionMessageEntry,
type SessionTreeNode,
sessionEntryToContextMessages,
type ThinkingLevelChangeEntry,
} from "./core/session-manager.ts";
export {

View file

@ -0,0 +1,62 @@
import type { Component } from "@earendil-works/pi-tui";
import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
import type { EntryRenderer } from "../../../core/extensions/types.ts";
import type { CustomEntry } from "../../../core/session-manager.ts";
import { theme } from "../theme/theme.ts";
/**
* Component that renders a custom session entry from extensions.
* The host owns transcript spacing; renderer output should provide only its content.
*/
export class CustomEntryComponent extends Container {
private entry: CustomEntry<unknown>;
private renderer: EntryRenderer;
private customComponent?: Component;
private _expanded = false;
constructor(entry: CustomEntry<unknown>, renderer: EntryRenderer) {
super();
this.entry = entry;
this.renderer = renderer;
this.rebuild();
}
hasContent(): boolean {
return this.customComponent !== undefined;
}
setExpanded(expanded: boolean): void {
if (this._expanded !== expanded) {
this._expanded = expanded;
this.rebuild();
}
}
override invalidate(): void {
super.invalidate();
this.rebuild();
}
private rebuild(): void {
this.clear();
this.customComponent = undefined;
let component: Component | undefined;
try {
component = this.renderer(this.entry, { expanded: this._expanded }, theme);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
box.addChild(new Text(theme.fg("error", `[${this.entry.customType}] renderer failed: ${message}`), 0, 0));
component = box;
}
if (!component) {
return;
}
this.customComponent = component;
this.addChild(new Spacer(1));
this.addChild(component);
}
}

View file

@ -81,7 +81,7 @@ import { DefaultPackageManager } from "../../core/package-manager.ts";
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts";
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
import { type SessionContext, SessionManager } from "../../core/session-manager.ts";
import { type SessionEntry, SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.ts";
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
import type { SourceInfo } from "../../core/source-info.ts";
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
@ -103,6 +103,7 @@ import { BorderedLoader } from "./components/bordered-loader.ts";
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts";
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts";
import { CustomEditor } from "./components/custom-editor.ts";
import { CustomEntryComponent } from "./components/custom-entry.ts";
import { CustomMessageComponent } from "./components/custom-message.ts";
import { DaxnutsComponent } from "./components/daxnuts.ts";
import { DynamicBorder } from "./components/dynamic-border.ts";
@ -183,6 +184,12 @@ type CompactionQueuedMessage = {
mode: "steer" | "followUp";
};
type RenderSessionItem = AgentMessage | Extract<SessionEntry, { type: "custom" }>;
function isCustomSessionEntry(item: RenderSessionItem): item is Extract<SessionEntry, { type: "custom" }> {
return "type" in item && item.type === "custom";
}
const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]);
function isDeadTerminalError(error: unknown): boolean {
@ -2781,6 +2788,13 @@ export class InteractiveMode {
this.ui.requestRender();
break;
case "entry_appended":
if (event.entry.type === "custom") {
this.addCustomEntryToChat(event.entry);
this.ui.requestRender();
}
break;
case "session_info_changed":
this.updateTerminalTitle();
this.footer.invalidate();
@ -3068,6 +3082,28 @@ export class InteractiveMode {
this.ui.requestRender();
}
private addCustomEntryToChat(entry: Extract<SessionEntry, { type: "custom" }>): void {
const renderer = this.session.extensionRunner.getEntryRenderer(entry.customType);
if (!renderer) {
return;
}
const component = new CustomEntryComponent(entry, renderer);
component.setExpanded(this.toolOutputExpanded);
if (!component.hasContent()) {
return;
}
if (this.streamingComponent) {
const streamingIndex = this.chatContainer.children.indexOf(this.streamingComponent);
if (streamingIndex >= 0) {
this.chatContainer.children.splice(streamingIndex, 0, component);
return;
}
}
this.chatContainer.addChild(component);
}
private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
switch (message.role) {
case "bashExecution": {
@ -3167,14 +3203,8 @@ export class InteractiveMode {
}
}
/**
* Render session context to chat. Used for initial load and rebuild after compaction.
* @param sessionContext Session context to render
* @param options.updateFooter Update footer state
* @param options.populateHistory Add user messages to editor history
*/
private renderSessionContext(
sessionContext: SessionContext,
private renderSessionItems(
items: readonly RenderSessionItem[],
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
): void {
this.pendingTools.clear();
@ -3185,7 +3215,13 @@ export class InteractiveMode {
this.updateEditorBorderColor();
}
for (const message of sessionContext.messages) {
for (const item of items) {
if (isCustomSessionEntry(item)) {
this.addCustomEntryToChat(item);
continue;
}
const message = item;
// Assistant messages need special handling for tool calls
if (message.role === "assistant") {
this.addMessageToChat(message);
@ -3243,10 +3279,28 @@ export class InteractiveMode {
this.ui.requestRender();
}
/**
* Render session entries to chat. Used for initial load and rebuild after compaction.
* @param entries Compaction-aware session entries to render
* @param options.updateFooter Update footer state
* @param options.populateHistory Add user messages to editor history
*/
private renderSessionEntries(
entries: SessionEntry[],
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
): void {
const items = entries.flatMap((entry): RenderSessionItem[] => {
if (entry.type === "custom") {
return [entry];
}
return sessionEntryToContextMessages(entry);
});
this.renderSessionItems(items, options);
}
renderInitialMessages(): void {
// Get aligned messages and entries from session context
const context = this.sessionManager.buildSessionContext();
this.renderSessionContext(context, {
const entries = this.sessionManager.buildContextEntries();
this.renderSessionEntries(entries, {
updateFooter: true,
populateHistory: true,
});
@ -3297,8 +3351,7 @@ export class InteractiveMode {
private rebuildChatFromMessages(): void {
this.chatContainer.clear();
const context = this.sessionManager.buildSessionContext();
this.renderSessionContext(context);
this.renderSessionEntries(this.sessionManager.buildContextEntries());
}
// =========================================================================

View file

@ -335,12 +335,15 @@ describe("extensions discovery", () => {
expect(result.extensions[0].tools.has("parse_duration")).toBe(true);
});
it("registers message renderers", async () => {
it("registers message and entry renderers", async () => {
const extCode = `
export default function(pi) {
pi.registerMessageRenderer("my-custom-type", (message, options, theme) => {
return null; // Use default rendering
});
pi.registerEntryRenderer("my-entry-type", (entry, options, theme) => {
return null;
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "with-renderer.ts"), extCode);
@ -350,6 +353,7 @@ describe("extensions discovery", () => {
expect(result.errors).toHaveLength(0);
expect(result.extensions).toHaveLength(1);
expect(result.extensions[0].messageRenderers.has("my-custom-type")).toBe(true);
expect(result.extensions[0].entryRenderers?.has("my-entry-type")).toBe(true);
});
it("reports error when extension throws during initialization", async () => {

View file

@ -560,7 +560,7 @@ describe("ExtensionRunner", () => {
});
});
describe("message renderers", () => {
describe("message and entry renderers", () => {
it("gets message renderer by type", async () => {
const extCode = `
export default function(pi) {
@ -578,6 +578,21 @@ describe("ExtensionRunner", () => {
const missing = runner.getMessageRenderer("not-exists");
expect(missing).toBeUndefined();
});
it("gets entry renderer by type", async () => {
const extCode = `
export default function(pi) {
pi.registerEntryRenderer("my-entry", (entry, options, theme) => null);
}
`;
fs.writeFileSync(path.join(extensionsDir, "entry-renderer.ts"), extCode);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
expect(runner.getEntryRenderer("my-entry")).toBeDefined();
expect(runner.getEntryRenderer("not-exists")).toBeUndefined();
});
});
describe("flags", () => {

View file

@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import {
type BranchSummaryEntry,
buildContextEntries,
buildSessionContext,
type CompactionEntry,
type CustomEntry,
type ModelChangeEntry,
type SessionEntry,
type SessionMessageEntry,
@ -52,6 +54,10 @@ function branchSummary(id: string, parentId: string | null, summary: string, fro
return { type: "branch_summary", id, parentId, timestamp: "2025-01-01T00:00:00Z", summary, fromId };
}
function custom(id: string, parentId: string | null, customType: string, data?: unknown): CustomEntry {
return { type: "custom", id, parentId, timestamp: "2025-01-01T00:00:00Z", customType, data };
}
function thinkingLevel(id: string, parentId: string | null, level: string): ThinkingLevelChangeEntry {
return { type: "thinking_level_change", id, parentId, timestamp: "2025-01-01T00:00:00Z", thinkingLevel: level };
}
@ -169,6 +175,37 @@ describe("buildSessionContext", () => {
expect(ctx.messages).toHaveLength(4);
expect((ctx.messages[0] as any).summary).toContain("Second summary");
});
it("buildContextEntries returns compaction-aware entries including custom entries", () => {
const entries: SessionEntry[] = [
msg("1", null, "user", "first"),
custom("2", "1", "old-state", { hidden: true }),
msg("3", "2", "assistant", "response1"),
custom("4", "3", "kept-card", { title: "Kept" }),
msg("5", "4", "user", "second"),
compaction("6", "5", "Summary", "4"),
custom("7", "6", "after-card", { title: "After" }),
msg("8", "7", "assistant", "response2"),
];
expect(buildContextEntries(entries).map((entry) => entry.id)).toEqual(["6", "4", "5", "7", "8"]);
const ctx = buildSessionContext(entries);
expect(ctx.messages.map((message) => message.role)).toEqual(["compactionSummary", "user", "assistant"]);
});
it("keeps settings from the full path after compaction", () => {
const entries: SessionEntry[] = [
msg("1", null, "user", "first"),
thinkingLevel("2", "1", "high"),
msg("3", "2", "assistant", "response1"),
msg("4", "3", "user", "second"),
compaction("5", "4", "Summary", "4"),
];
const ctx = buildSessionContext(entries);
expect(ctx.thinkingLevel).toBe("high");
expect(ctx.messages.map((message) => message.role)).toEqual(["compactionSummary", "user"]);
});
});
describe("with branches", () => {

View file

@ -3,7 +3,7 @@ import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works
import { Container, Text, type TUI } from "@earendil-works/pi-tui";
import { beforeAll, describe, expect, test, vi } from "vitest";
import type { AgentSessionEvent } from "../../../src/core/agent-session.ts";
import type { SessionContext } from "../../../src/core/session-manager.ts";
import type { SessionEntry } from "../../../src/core/session-manager.ts";
import type { ToolExecutionComponent } from "../../../src/modes/interactive/components/tool-execution.ts";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
@ -27,6 +27,12 @@ const EMPTY_USAGE: Usage = {
},
};
type RenderSessionItems = (
this: RenderSessionContextThis,
items: AgentMessage[],
options?: { updateFooter?: boolean; populateHistory?: boolean },
) => void;
type RenderSessionContextThis = {
pendingTools: Map<string, ToolExecutionComponent>;
chatContainer: Container;
@ -43,11 +49,12 @@ type RenderSessionContextThis = {
updateEditorBorderColor(): void;
getRegisteredToolDefinition(toolName: string): undefined;
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void;
renderSessionItems: RenderSessionItems;
};
type RenderSessionContext = (
type RenderSessionEntries = (
this: RenderSessionContextThis,
sessionContext: SessionContext,
entries: SessionEntry[],
options?: { updateFooter?: boolean; populateHistory?: boolean },
) => void;
@ -70,6 +77,8 @@ function createFakeInteractiveModeThis(): RenderSessionContextThis {
isInitialized: true,
updateEditorBorderColor: vi.fn(),
getRegisteredToolDefinition: (_toolName: string) => undefined,
renderSessionItems: (InteractiveMode.prototype as unknown as { renderSessionItems: RenderSessionItems })
.renderSessionItems,
addMessageToChat(message: AgentMessage) {
chatContainer.addChild(new Text(message.role, 0, 0));
},
@ -107,31 +116,38 @@ function createToolResultMessage(text: string): ToolResultMessage {
};
}
function createSessionContext(messages: AgentMessage[]): SessionContext {
return {
messages,
thinkingLevel: "off",
model: null,
};
function createSessionEntries(messages: AgentMessage[]): SessionEntry[] {
let parentId: string | null = null;
return messages.map((message, index) => {
const entry: SessionEntry = {
type: "message",
id: `entry-${index}`,
parentId,
timestamp: new Date().toISOString(),
message,
};
parentId = entry.id;
return entry;
});
}
function renderChat(container: Container): string {
return stripAnsi(container.render(120).join("\n"));
}
describe("InteractiveMode.renderSessionContext", () => {
describe("InteractiveMode.renderSessionEntries", () => {
beforeAll(() => {
initTheme("dark");
});
test("keeps unresolved rendered tool calls registered for live completion events", async () => {
const fakeThis = createFakeInteractiveModeThis();
const renderSessionContext = (
InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext }
).renderSessionContext;
const renderSessionEntries = (
InteractiveMode.prototype as unknown as { renderSessionEntries: RenderSessionEntries }
).renderSessionEntries;
const handleEvent = (InteractiveMode.prototype as unknown as { handleEvent: HandleEvent }).handleEvent;
renderSessionContext.call(fakeThis, createSessionContext([createAssistantToolCallMessage()]));
renderSessionEntries.call(fakeThis, createSessionEntries([createAssistantToolCallMessage()]));
expect(fakeThis.pendingTools.has(TOOL_CALL_ID)).toBe(true);
@ -149,13 +165,13 @@ describe("InteractiveMode.renderSessionContext", () => {
test("does not keep completed historical tool calls registered as pending", () => {
const fakeThis = createFakeInteractiveModeThis();
const renderSessionContext = (
InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext }
).renderSessionContext;
const renderSessionEntries = (
InteractiveMode.prototype as unknown as { renderSessionEntries: RenderSessionEntries }
).renderSessionEntries;
renderSessionContext.call(
renderSessionEntries.call(
fakeThis,
createSessionContext([createAssistantToolCallMessage(), createToolResultMessage("HISTORICAL_RESULT")]),
createSessionEntries([createAssistantToolCallMessage(), createToolResultMessage("HISTORICAL_RESULT")]),
);
expect(fakeThis.pendingTools.size).toBe(0);