fix(coding-agent): add entry renderers for session entries
This commit is contained in:
@@ -5,9 +5,11 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added public SDK exports for CLI-equivalent model and scoped-model resolution ([#6201](https://github.com/earendil-works/pi/issues/6201)).
|
- 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
|
||||||
|
|
||||||
|
- 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)).
|
- 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
|
## [0.80.3] - 2026-06-30
|
||||||
|
|||||||
@@ -946,6 +946,7 @@ For `tool_call`, this state is synchronized through the current assistant messag
|
|||||||
```typescript
|
```typescript
|
||||||
ctx.sessionManager.getEntries() // All entries
|
ctx.sessionManager.getEntries() // All entries
|
||||||
ctx.sessionManager.getBranch() // Current branch
|
ctx.sessionManager.getBranch() // Current branch
|
||||||
|
ctx.sessionManager.buildContextEntries() // Active branch entries with compaction applied
|
||||||
ctx.sessionManager.getLeafId() // Current leaf entry ID
|
ctx.sessionManager.getLeafId() // Current leaf entry ID
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1352,7 +1353,7 @@ pi.registerTool({
|
|||||||
|
|
||||||
### pi.sendMessage(message, options?)
|
### 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
|
```typescript
|
||||||
pi.sendMessage({
|
pi.sendMessage({
|
||||||
@@ -1403,10 +1404,11 @@ See [send-user-message.ts](../examples/extensions/send-user-message.ts) for a co
|
|||||||
|
|
||||||
### pi.appendEntry(customType, data?)
|
### 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
|
```typescript
|
||||||
pi.appendEntry("my-state", { count: 42 });
|
pi.appendEntry("my-state", { count: 42 });
|
||||||
|
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });
|
||||||
|
|
||||||
// Restore on reload
|
// Restore on reload
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
@@ -1524,7 +1526,27 @@ mode and would not execute if sent via `prompt`.
|
|||||||
|
|
||||||
### pi.registerMessageRenderer(customType, renderer)
|
### 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)
|
### 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.
|
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
|
```typescript
|
||||||
import { Text } from "@earendil-works/pi-tui";
|
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
|
### Theme Colors
|
||||||
|
|
||||||
All render functions receive a `theme` object. See [themes.md](themes.md) for creating custom themes and the full color palette.
|
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 |
|
| `custom-provider-gitlab-duo/` | GitLab Duo integration | `registerProvider` with OAuth |
|
||||||
| **Messages & Communication** |||
|
| **Messages & Communication** |||
|
||||||
| `message-renderer.ts` | Custom message rendering | `registerMessageRenderer`, `sendMessage` |
|
| `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` |
|
| `event-bus.ts` | Inter-extension events | `pi.events` |
|
||||||
| **Session Metadata** |||
|
| **Session Metadata** |||
|
||||||
| `session-name.ts` | Name sessions for selector | `setSessionName`, `getSessionName` |
|
| `session-name.ts` | Name sessions for selector | `setSessionName`, `getSessionName` |
|
||||||
|
|||||||
@@ -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}}
|
{"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
|
### CustomMessageEntry
|
||||||
|
|
||||||
@@ -306,15 +306,24 @@ Entries form a tree:
|
|||||||
|
|
||||||
## Context Building
|
## 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
|
1. Collects all entries on the path
|
||||||
2. Extracts current model and thinking level settings
|
2. If a `CompactionEntry` is on the path:
|
||||||
3. If a `CompactionEntry` is on the path:
|
- Includes the compaction entry first
|
||||||
- Emits the summary first
|
- Then entries from `firstKeptEntryId` to compaction
|
||||||
- Then messages from `firstKeptEntryId` to compaction
|
- Then entries after compaction
|
||||||
- Then messages after compaction
|
3. Preserves non-message entries in the selected range so interactive mode can render them
|
||||||
4. Converts `BranchSummaryEntry` and `CustomMessageEntry` to appropriate message formats
|
|
||||||
|
`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
|
## Parsing Example
|
||||||
|
|
||||||
@@ -401,6 +410,7 @@ Key methods for working with sessions programmatically.
|
|||||||
- `branchWithSummary(entryId, summary, details?, fromHook?)` - Branch with context summary
|
- `branchWithSummary(entryId, summary, details?, fromHook?)` - Branch with context summary
|
||||||
|
|
||||||
### Instance Methods - Context & Info
|
### Instance Methods - Context & Info
|
||||||
|
- `buildContextEntries()` - Get active branch entries with compaction applied
|
||||||
- `buildSessionContext()` - Get messages, thinkingLevel, and model for LLM
|
- `buildSessionContext()` - Get messages, thinkingLevel, and model for LLM
|
||||||
- `getEntries()` - All entries (excluding header)
|
- `getEntries()` - All entries (excluding header)
|
||||||
- `getHeader()` - Session header metadata
|
- `getHeader()` - Session header metadata
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
|||||||
| Extension | Description |
|
| Extension | Description |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `message-renderer.ts` | Custom message rendering with colors and expandable details via `registerMessageRenderer` |
|
| `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` |
|
| `event-bus.ts` | Inter-extension communication via `pi.events` |
|
||||||
|
|
||||||
### Session Metadata
|
### Session Metadata
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -86,7 +86,7 @@ import type { BashExecutionMessage, CustomMessage } from "./messages.ts";
|
|||||||
import type { ModelRegistry } from "./model-registry.ts";
|
import type { ModelRegistry } from "./model-registry.ts";
|
||||||
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";
|
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";
|
||||||
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.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 { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts";
|
||||||
import type { SettingsManager } from "./settings-manager.ts";
|
import type { SettingsManager } from "./settings-manager.ts";
|
||||||
import type { SlashCommandInfo } from "./slash-commands.ts";
|
import type { SlashCommandInfo } from "./slash-commands.ts";
|
||||||
@@ -137,6 +137,7 @@ export type AgentSessionEvent =
|
|||||||
followUp: readonly string[];
|
followUp: readonly string[];
|
||||||
}
|
}
|
||||||
| { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }
|
| { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }
|
||||||
|
| { type: "entry_appended"; entry: SessionEntry }
|
||||||
| { type: "session_info_changed"; name: string | undefined }
|
| { type: "session_info_changed"; name: string | undefined }
|
||||||
| { type: "thinking_level_changed"; level: ThinkingLevel }
|
| { type: "thinking_level_changed"; level: ThinkingLevel }
|
||||||
| {
|
| {
|
||||||
@@ -2262,7 +2263,11 @@ export class AgentSession {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
appendEntry: (customType, data) => {
|
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) => {
|
setSessionName: (name) => {
|
||||||
this.setSessionName(name);
|
this.setSessionName(name);
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export type {
|
|||||||
EditorFactory,
|
EditorFactory,
|
||||||
EditToolCallEvent,
|
EditToolCallEvent,
|
||||||
EditToolResultEvent,
|
EditToolResultEvent,
|
||||||
|
// Message and Entry Rendering
|
||||||
|
EntryRenderer,
|
||||||
|
EntryRenderOptions,
|
||||||
ExecOptions,
|
ExecOptions,
|
||||||
ExecResult,
|
ExecResult,
|
||||||
Extension,
|
Extension,
|
||||||
@@ -91,7 +94,6 @@ export type {
|
|||||||
LsToolResultEvent,
|
LsToolResultEvent,
|
||||||
// Events - Message
|
// Events - Message
|
||||||
MessageEndEvent,
|
MessageEndEvent,
|
||||||
// Message Rendering
|
|
||||||
MessageRenderer,
|
MessageRenderer,
|
||||||
MessageRenderOptions,
|
MessageRenderOptions,
|
||||||
MessageStartEvent,
|
MessageStartEvent,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { execCommand } from "../exec.ts";
|
|||||||
import { createSyntheticSourceInfo } from "../source-info.ts";
|
import { createSyntheticSourceInfo } from "../source-info.ts";
|
||||||
import { time } from "../timings.ts";
|
import { time } from "../timings.ts";
|
||||||
import type {
|
import type {
|
||||||
|
EntryRenderer,
|
||||||
Extension,
|
Extension,
|
||||||
ExtensionAPI,
|
ExtensionAPI,
|
||||||
ExtensionFactory,
|
ExtensionFactory,
|
||||||
@@ -269,6 +270,12 @@ function createExtensionAPI(
|
|||||||
extension.messageRenderers.set(customType, renderer as MessageRenderer);
|
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
|
// Flag access - checks extension registered it, reads from runtime
|
||||||
getFlag(name: string): boolean | string | undefined {
|
getFlag(name: string): boolean | string | undefined {
|
||||||
runtime.assertActive();
|
runtime.assertActive();
|
||||||
@@ -415,6 +422,7 @@ function createExtension(extensionPath: string, resolvedPath: string): Extension
|
|||||||
handlers: new Map(),
|
handlers: new Map(),
|
||||||
tools: new Map(),
|
tools: new Map(),
|
||||||
messageRenderers: new Map(),
|
messageRenderers: new Map(),
|
||||||
|
entryRenderers: new Map(),
|
||||||
commands: new Map(),
|
commands: new Map(),
|
||||||
flags: new Map(),
|
flags: new Map(),
|
||||||
shortcuts: new Map(),
|
shortcuts: new Map(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
ContextEvent,
|
ContextEvent,
|
||||||
ContextEventResult,
|
ContextEventResult,
|
||||||
ContextUsage,
|
ContextUsage,
|
||||||
|
EntryRenderer,
|
||||||
Extension,
|
Extension,
|
||||||
ExtensionActions,
|
ExtensionActions,
|
||||||
ExtensionCommandContext,
|
ExtensionCommandContext,
|
||||||
@@ -553,6 +554,16 @@ export class ExtensionRunner {
|
|||||||
return undefined;
|
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[] {
|
private resolveRegisteredCommands(): ResolvedCommand[] {
|
||||||
const commands: RegisteredCommand[] = [];
|
const commands: RegisteredCommand[] = [];
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import type { ModelRegistry } from "../model-registry.ts";
|
|||||||
import type {
|
import type {
|
||||||
BranchSummaryEntry,
|
BranchSummaryEntry,
|
||||||
CompactionEntry,
|
CompactionEntry,
|
||||||
|
CustomEntry,
|
||||||
ReadonlySessionManager,
|
ReadonlySessionManager,
|
||||||
SessionEntry,
|
SessionEntry,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
@@ -1093,19 +1094,29 @@ export interface SessionBeforeTreeResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Message Rendering
|
// Message and Entry Rendering
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface MessageRenderOptions {
|
export interface MessageRenderOptions {
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EntryRenderOptions {
|
||||||
|
expanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type MessageRenderer<T = unknown> = (
|
export type MessageRenderer<T = unknown> = (
|
||||||
message: CustomMessage<T>,
|
message: CustomMessage<T>,
|
||||||
options: MessageRenderOptions,
|
options: MessageRenderOptions,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
) => Component | undefined;
|
) => Component | undefined;
|
||||||
|
|
||||||
|
export type EntryRenderer<T = unknown> = (
|
||||||
|
entry: CustomEntry<T>,
|
||||||
|
options: EntryRenderOptions,
|
||||||
|
theme: Theme,
|
||||||
|
) => Component | undefined;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Command Registration
|
// Command Registration
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1224,6 +1235,9 @@ export interface ExtensionAPI {
|
|||||||
/** Register a custom renderer for CustomMessageEntry. */
|
/** Register a custom renderer for CustomMessageEntry. */
|
||||||
registerMessageRenderer<T = unknown>(customType: string, renderer: MessageRenderer<T>): void;
|
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
|
// Actions
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -1598,6 +1612,7 @@ export interface Extension {
|
|||||||
handlers: Map<string, HandlerFn[]>;
|
handlers: Map<string, HandlerFn[]>;
|
||||||
tools: Map<string, RegisteredTool>;
|
tools: Map<string, RegisteredTool>;
|
||||||
messageRenderers: Map<string, MessageRenderer>;
|
messageRenderers: Map<string, MessageRenderer>;
|
||||||
|
entryRenderers?: Map<string, EntryRenderer>;
|
||||||
commands: Map<string, RegisteredCommand>;
|
commands: Map<string, RegisteredCommand>;
|
||||||
flags: Map<string, ExtensionFlag>;
|
flags: Map<string, ExtensionFlag>;
|
||||||
shortcuts: Map<KeyId, ExtensionShortcut>;
|
shortcuts: Map<KeyId, ExtensionShortcut>;
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export type ReadonlySessionManager = Pick<
|
|||||||
| "getEntry"
|
| "getEntry"
|
||||||
| "getLabel"
|
| "getLabel"
|
||||||
| "getBranch"
|
| "getBranch"
|
||||||
|
| "buildContextEntries"
|
||||||
| "getHeader"
|
| "getHeader"
|
||||||
| "getEntries"
|
| "getEntries"
|
||||||
| "getTree"
|
| "getTree"
|
||||||
@@ -317,6 +318,126 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
|
|||||||
return null;
|
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.
|
* Build the session context from entries using tree traversal.
|
||||||
* If leafId is provided, walks from that entry to root.
|
* If leafId is provided, walks from that entry to root.
|
||||||
@@ -327,108 +448,9 @@ export function buildSessionContext(
|
|||||||
leafId?: string | null,
|
leafId?: string | null,
|
||||||
byId?: Map<string, SessionEntry>,
|
byId?: Map<string, SessionEntry>,
|
||||||
): SessionContext {
|
): SessionContext {
|
||||||
// Build uuid index if not available
|
const path = buildSessionPath(entries, leafId, byId);
|
||||||
if (!byId) {
|
const { thinkingLevel, model } = getSessionContextSettings(path);
|
||||||
byId = new Map<string, SessionEntry>();
|
const messages = buildContextEntries(entries, leafId, byId).flatMap(sessionEntryToContextMessages);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { messages, thinkingLevel, model };
|
return { messages, thinkingLevel, model };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1164,6 +1186,14 @@ export class SessionManager {
|
|||||||
return path;
|
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).
|
* Build the session context (what gets sent to the LLM).
|
||||||
* Uses tree traversal from current leaf.
|
* Uses tree traversal from current leaf.
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export type {
|
|||||||
ContextUsage,
|
ContextUsage,
|
||||||
CustomToolCallEvent,
|
CustomToolCallEvent,
|
||||||
EditToolCallEvent,
|
EditToolCallEvent,
|
||||||
|
EntryRenderer,
|
||||||
|
EntryRenderOptions,
|
||||||
ExecOptions,
|
ExecOptions,
|
||||||
ExecResult,
|
ExecResult,
|
||||||
Extension,
|
Extension,
|
||||||
@@ -214,6 +216,7 @@ export {
|
|||||||
} from "./core/sdk.ts";
|
} from "./core/sdk.ts";
|
||||||
export {
|
export {
|
||||||
type BranchSummaryEntry,
|
type BranchSummaryEntry,
|
||||||
|
buildContextEntries,
|
||||||
buildSessionContext,
|
buildSessionContext,
|
||||||
type CompactionEntry,
|
type CompactionEntry,
|
||||||
CURRENT_SESSION_VERSION,
|
CURRENT_SESSION_VERSION,
|
||||||
@@ -234,6 +237,7 @@ export {
|
|||||||
SessionManager,
|
SessionManager,
|
||||||
type SessionMessageEntry,
|
type SessionMessageEntry,
|
||||||
type SessionTreeNode,
|
type SessionTreeNode,
|
||||||
|
sessionEntryToContextMessages,
|
||||||
type ThinkingLevelChangeEntry,
|
type ThinkingLevelChangeEntry,
|
||||||
} from "./core/session-manager.ts";
|
} from "./core/session-manager.ts";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ import { DefaultPackageManager } from "../../core/package-manager.ts";
|
|||||||
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts";
|
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts";
|
||||||
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
|
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
|
||||||
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.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 { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
|
||||||
import type { SourceInfo } from "../../core/source-info.ts";
|
import type { SourceInfo } from "../../core/source-info.ts";
|
||||||
import { isInstallTelemetryEnabled } from "../../core/telemetry.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 { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts";
|
||||||
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts";
|
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts";
|
||||||
import { CustomEditor } from "./components/custom-editor.ts";
|
import { CustomEditor } from "./components/custom-editor.ts";
|
||||||
|
import { CustomEntryComponent } from "./components/custom-entry.ts";
|
||||||
import { CustomMessageComponent } from "./components/custom-message.ts";
|
import { CustomMessageComponent } from "./components/custom-message.ts";
|
||||||
import { DaxnutsComponent } from "./components/daxnuts.ts";
|
import { DaxnutsComponent } from "./components/daxnuts.ts";
|
||||||
import { DynamicBorder } from "./components/dynamic-border.ts";
|
import { DynamicBorder } from "./components/dynamic-border.ts";
|
||||||
@@ -183,6 +184,12 @@ type CompactionQueuedMessage = {
|
|||||||
mode: "steer" | "followUp";
|
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"]);
|
const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]);
|
||||||
|
|
||||||
function isDeadTerminalError(error: unknown): boolean {
|
function isDeadTerminalError(error: unknown): boolean {
|
||||||
@@ -2781,6 +2788,13 @@ export class InteractiveMode {
|
|||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "entry_appended":
|
||||||
|
if (event.entry.type === "custom") {
|
||||||
|
this.addCustomEntryToChat(event.entry);
|
||||||
|
this.ui.requestRender();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case "session_info_changed":
|
case "session_info_changed":
|
||||||
this.updateTerminalTitle();
|
this.updateTerminalTitle();
|
||||||
this.footer.invalidate();
|
this.footer.invalidate();
|
||||||
@@ -3068,6 +3082,28 @@ export class InteractiveMode {
|
|||||||
this.ui.requestRender();
|
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 {
|
private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
|
||||||
switch (message.role) {
|
switch (message.role) {
|
||||||
case "bashExecution": {
|
case "bashExecution": {
|
||||||
@@ -3167,14 +3203,8 @@ export class InteractiveMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private renderSessionItems(
|
||||||
* Render session context to chat. Used for initial load and rebuild after compaction.
|
items: readonly RenderSessionItem[],
|
||||||
* @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,
|
|
||||||
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
|
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
|
||||||
): void {
|
): void {
|
||||||
this.pendingTools.clear();
|
this.pendingTools.clear();
|
||||||
@@ -3185,7 +3215,13 @@ export class InteractiveMode {
|
|||||||
this.updateEditorBorderColor();
|
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
|
// Assistant messages need special handling for tool calls
|
||||||
if (message.role === "assistant") {
|
if (message.role === "assistant") {
|
||||||
this.addMessageToChat(message);
|
this.addMessageToChat(message);
|
||||||
@@ -3243,10 +3279,28 @@ export class InteractiveMode {
|
|||||||
this.ui.requestRender();
|
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 {
|
renderInitialMessages(): void {
|
||||||
// Get aligned messages and entries from session context
|
const entries = this.sessionManager.buildContextEntries();
|
||||||
const context = this.sessionManager.buildSessionContext();
|
this.renderSessionEntries(entries, {
|
||||||
this.renderSessionContext(context, {
|
|
||||||
updateFooter: true,
|
updateFooter: true,
|
||||||
populateHistory: true,
|
populateHistory: true,
|
||||||
});
|
});
|
||||||
@@ -3297,8 +3351,7 @@ export class InteractiveMode {
|
|||||||
|
|
||||||
private rebuildChatFromMessages(): void {
|
private rebuildChatFromMessages(): void {
|
||||||
this.chatContainer.clear();
|
this.chatContainer.clear();
|
||||||
const context = this.sessionManager.buildSessionContext();
|
this.renderSessionEntries(this.sessionManager.buildContextEntries());
|
||||||
this.renderSessionContext(context);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -335,12 +335,15 @@ describe("extensions discovery", () => {
|
|||||||
expect(result.extensions[0].tools.has("parse_duration")).toBe(true);
|
expect(result.extensions[0].tools.has("parse_duration")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers message renderers", async () => {
|
it("registers message and entry renderers", async () => {
|
||||||
const extCode = `
|
const extCode = `
|
||||||
export default function(pi) {
|
export default function(pi) {
|
||||||
pi.registerMessageRenderer("my-custom-type", (message, options, theme) => {
|
pi.registerMessageRenderer("my-custom-type", (message, options, theme) => {
|
||||||
return null; // Use default rendering
|
return null; // Use default rendering
|
||||||
});
|
});
|
||||||
|
pi.registerEntryRenderer("my-entry-type", (entry, options, theme) => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
fs.writeFileSync(path.join(extensionsDir, "with-renderer.ts"), extCode);
|
fs.writeFileSync(path.join(extensionsDir, "with-renderer.ts"), extCode);
|
||||||
@@ -350,6 +353,7 @@ describe("extensions discovery", () => {
|
|||||||
expect(result.errors).toHaveLength(0);
|
expect(result.errors).toHaveLength(0);
|
||||||
expect(result.extensions).toHaveLength(1);
|
expect(result.extensions).toHaveLength(1);
|
||||||
expect(result.extensions[0].messageRenderers.has("my-custom-type")).toBe(true);
|
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 () => {
|
it("reports error when extension throws during initialization", async () => {
|
||||||
|
|||||||
@@ -560,7 +560,7 @@ describe("ExtensionRunner", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("message renderers", () => {
|
describe("message and entry renderers", () => {
|
||||||
it("gets message renderer by type", async () => {
|
it("gets message renderer by type", async () => {
|
||||||
const extCode = `
|
const extCode = `
|
||||||
export default function(pi) {
|
export default function(pi) {
|
||||||
@@ -578,6 +578,21 @@ describe("ExtensionRunner", () => {
|
|||||||
const missing = runner.getMessageRenderer("not-exists");
|
const missing = runner.getMessageRenderer("not-exists");
|
||||||
expect(missing).toBeUndefined();
|
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", () => {
|
describe("flags", () => {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
type BranchSummaryEntry,
|
type BranchSummaryEntry,
|
||||||
|
buildContextEntries,
|
||||||
buildSessionContext,
|
buildSessionContext,
|
||||||
type CompactionEntry,
|
type CompactionEntry,
|
||||||
|
type CustomEntry,
|
||||||
type ModelChangeEntry,
|
type ModelChangeEntry,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
type SessionMessageEntry,
|
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 };
|
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 {
|
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 };
|
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).toHaveLength(4);
|
||||||
expect((ctx.messages[0] as any).summary).toContain("Second summary");
|
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", () => {
|
describe("with branches", () => {
|
||||||
|
|||||||
+34
-18
@@ -3,7 +3,7 @@ import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works
|
|||||||
import { Container, Text, type TUI } from "@earendil-works/pi-tui";
|
import { Container, Text, type TUI } from "@earendil-works/pi-tui";
|
||||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||||
import type { AgentSessionEvent } from "../../../src/core/agent-session.ts";
|
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 type { ToolExecutionComponent } from "../../../src/modes/interactive/components/tool-execution.ts";
|
||||||
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
|
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
|
||||||
import { initTheme } from "../../../src/modes/interactive/theme/theme.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 = {
|
type RenderSessionContextThis = {
|
||||||
pendingTools: Map<string, ToolExecutionComponent>;
|
pendingTools: Map<string, ToolExecutionComponent>;
|
||||||
chatContainer: Container;
|
chatContainer: Container;
|
||||||
@@ -43,11 +49,12 @@ type RenderSessionContextThis = {
|
|||||||
updateEditorBorderColor(): void;
|
updateEditorBorderColor(): void;
|
||||||
getRegisteredToolDefinition(toolName: string): undefined;
|
getRegisteredToolDefinition(toolName: string): undefined;
|
||||||
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void;
|
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void;
|
||||||
|
renderSessionItems: RenderSessionItems;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RenderSessionContext = (
|
type RenderSessionEntries = (
|
||||||
this: RenderSessionContextThis,
|
this: RenderSessionContextThis,
|
||||||
sessionContext: SessionContext,
|
entries: SessionEntry[],
|
||||||
options?: { updateFooter?: boolean; populateHistory?: boolean },
|
options?: { updateFooter?: boolean; populateHistory?: boolean },
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
@@ -70,6 +77,8 @@ function createFakeInteractiveModeThis(): RenderSessionContextThis {
|
|||||||
isInitialized: true,
|
isInitialized: true,
|
||||||
updateEditorBorderColor: vi.fn(),
|
updateEditorBorderColor: vi.fn(),
|
||||||
getRegisteredToolDefinition: (_toolName: string) => undefined,
|
getRegisteredToolDefinition: (_toolName: string) => undefined,
|
||||||
|
renderSessionItems: (InteractiveMode.prototype as unknown as { renderSessionItems: RenderSessionItems })
|
||||||
|
.renderSessionItems,
|
||||||
addMessageToChat(message: AgentMessage) {
|
addMessageToChat(message: AgentMessage) {
|
||||||
chatContainer.addChild(new Text(message.role, 0, 0));
|
chatContainer.addChild(new Text(message.role, 0, 0));
|
||||||
},
|
},
|
||||||
@@ -107,31 +116,38 @@ function createToolResultMessage(text: string): ToolResultMessage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSessionContext(messages: AgentMessage[]): SessionContext {
|
function createSessionEntries(messages: AgentMessage[]): SessionEntry[] {
|
||||||
return {
|
let parentId: string | null = null;
|
||||||
messages,
|
return messages.map((message, index) => {
|
||||||
thinkingLevel: "off",
|
const entry: SessionEntry = {
|
||||||
model: null,
|
type: "message",
|
||||||
|
id: `entry-${index}`,
|
||||||
|
parentId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message,
|
||||||
};
|
};
|
||||||
|
parentId = entry.id;
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChat(container: Container): string {
|
function renderChat(container: Container): string {
|
||||||
return stripAnsi(container.render(120).join("\n"));
|
return stripAnsi(container.render(120).join("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("InteractiveMode.renderSessionContext", () => {
|
describe("InteractiveMode.renderSessionEntries", () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
initTheme("dark");
|
initTheme("dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps unresolved rendered tool calls registered for live completion events", async () => {
|
test("keeps unresolved rendered tool calls registered for live completion events", async () => {
|
||||||
const fakeThis = createFakeInteractiveModeThis();
|
const fakeThis = createFakeInteractiveModeThis();
|
||||||
const renderSessionContext = (
|
const renderSessionEntries = (
|
||||||
InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext }
|
InteractiveMode.prototype as unknown as { renderSessionEntries: RenderSessionEntries }
|
||||||
).renderSessionContext;
|
).renderSessionEntries;
|
||||||
const handleEvent = (InteractiveMode.prototype as unknown as { handleEvent: HandleEvent }).handleEvent;
|
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);
|
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", () => {
|
test("does not keep completed historical tool calls registered as pending", () => {
|
||||||
const fakeThis = createFakeInteractiveModeThis();
|
const fakeThis = createFakeInteractiveModeThis();
|
||||||
const renderSessionContext = (
|
const renderSessionEntries = (
|
||||||
InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext }
|
InteractiveMode.prototype as unknown as { renderSessionEntries: RenderSessionEntries }
|
||||||
).renderSessionContext;
|
).renderSessionEntries;
|
||||||
|
|
||||||
renderSessionContext.call(
|
renderSessionEntries.call(
|
||||||
fakeThis,
|
fakeThis,
|
||||||
createSessionContext([createAssistantToolCallMessage(), createToolResultMessage("HISTORICAL_RESULT")]),
|
createSessionEntries([createAssistantToolCallMessage(), createToolResultMessage("HISTORICAL_RESULT")]),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fakeThis.pendingTools.size).toBe(0);
|
expect(fakeThis.pendingTools.size).toBe(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user