From ba10b60b512fed0c511cc639733611f9ad8d12cf Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 1 Jul 2026 11:00:14 +0200 Subject: [PATCH] fix(coding-agent): add entry renderers for session entries --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/docs/extensions.md | 49 +++- packages/coding-agent/docs/session-format.md | 26 +- .../examples/extensions/README.md | 1 + .../examples/extensions/entry-renderer.ts | 41 +++ .../coding-agent/src/core/agent-session.ts | 9 +- .../coding-agent/src/core/extensions/index.ts | 4 +- .../src/core/extensions/loader.ts | 8 + .../src/core/extensions/runner.ts | 11 + .../coding-agent/src/core/extensions/types.ts | 17 +- .../coding-agent/src/core/session-manager.ts | 234 ++++++++++-------- packages/coding-agent/src/index.ts | 4 + .../interactive/components/custom-entry.ts | 62 +++++ .../src/modes/interactive/interactive-mode.ts | 83 +++++-- .../test/extensions-discovery.test.ts | 6 +- .../test/extensions-runner.test.ts | 17 +- .../session-manager/build-context.test.ts | 37 +++ ...hinking-toggle-pending-tool-render.test.ts | 54 ++-- 18 files changed, 507 insertions(+), 158 deletions(-) create mode 100644 packages/coding-agent/examples/extensions/entry-renderer.ts create mode 100644 packages/coding-agent/src/modes/interactive/components/custom-entry.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f64876213..810834a71 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 83c367a88..df6c55e5e 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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` | diff --git a/packages/coding-agent/docs/session-format.md b/packages/coding-agent/docs/session-format.md index c32ce3906..a4afb3180 100644 --- a/packages/coding-agent/docs/session-format.md +++ b/packages/coding-agent/docs/session-format.md @@ -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 diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index d4f4f2ccf..3dbc14d0f 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -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 diff --git a/packages/coding-agent/examples/extensions/entry-renderer.ts b/packages/coding-agent/examples/extensions/entry-renderer.ts new file mode 100644 index 000000000..27c2ced81 --- /dev/null +++ b/packages/coding-agent/examples/extensions/entry-renderer.ts @@ -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("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("status-card", { + message: args.trim() || "Status card", + timestamp: Date.now(), + }); + }, + }); +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ff99d6949..42865aefd 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -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); diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 32fdd048a..94b648ebd 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -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, diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 5de785279..0daeb262a 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -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(customType: string, renderer: EntryRenderer): 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(), diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 9cb0e6570..a40625e6d 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -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(); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 737800278..61a783b81 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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 = ( message: CustomMessage, options: MessageRenderOptions, theme: Theme, ) => Component | undefined; +export type EntryRenderer = ( + entry: CustomEntry, + options: EntryRenderOptions, + theme: Theme, +) => Component | undefined; + // ============================================================================ // Command Registration // ============================================================================ @@ -1224,6 +1235,9 @@ export interface ExtensionAPI { /** Register a custom renderer for CustomMessageEntry. */ registerMessageRenderer(customType: string, renderer: MessageRenderer): void; + /** Register a custom renderer for CustomEntry. Custom entries do not participate in LLM context. */ + registerEntryRenderer(customType: string, renderer: EntryRenderer): void; + // ========================================================================= // Actions // ========================================================================= @@ -1598,6 +1612,7 @@ export interface Extension { handlers: Map; tools: Map; messageRenderers: Map; + entryRenderers?: Map; commands: Map; flags: Map; shortcuts: Map; diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index b6edd96c1..e6db87ad1 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -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): Map { + if (byId) return byId; + const index = new Map(); + for (const entry of entries) { + index.set(entry.id, entry); + } + return index; +} + +function buildSessionPath( + entries: SessionEntry[], + leafId?: string | null, + byId?: Map, +): 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 { + 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, +): 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, ): SessionContext { - // Build uuid index if not available - if (!byId) { - byId = new Map(); - 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. diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 6d7fd14ec..034db0064 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -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 { diff --git a/packages/coding-agent/src/modes/interactive/components/custom-entry.ts b/packages/coding-agent/src/modes/interactive/components/custom-entry.ts new file mode 100644 index 000000000..26444edbb --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/custom-entry.ts @@ -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; + private renderer: EntryRenderer; + private customComponent?: Component; + private _expanded = false; + + constructor(entry: CustomEntry, 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); + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8fa18546f..c3c8892cf 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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; + +function isCustomSessionEntry(item: RenderSessionItem): item is Extract { + 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): 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()); } // ========================================================================= diff --git a/packages/coding-agent/test/extensions-discovery.test.ts b/packages/coding-agent/test/extensions-discovery.test.ts index b1b1232f9..af1aba52c 100644 --- a/packages/coding-agent/test/extensions-discovery.test.ts +++ b/packages/coding-agent/test/extensions-discovery.test.ts @@ -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 () => { diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index cd6119624..61be09686 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -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", () => { diff --git a/packages/coding-agent/test/session-manager/build-context.test.ts b/packages/coding-agent/test/session-manager/build-context.test.ts index a0d8c8e89..e81eb5dc1 100644 --- a/packages/coding-agent/test/session-manager/build-context.test.ts +++ b/packages/coding-agent/test/session-manager/build-context.test.ts @@ -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", () => { diff --git a/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts b/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts index 307c71de2..2c8039b5a 100644 --- a/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts +++ b/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts @@ -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; 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);