Add ToolRenderResult interface for custom tool rendering

- Changed ToolRenderer return type from TemplateResult to ToolRenderResult
- ToolRenderResult = { content: TemplateResult, isCustom: boolean }
- isCustom: true = no card wrapper, false = wrap in card
- Updated all existing tool renderers to return new format
- Updated Messages.ts to handle custom rendering

This enables tools to render without default card chrome when needed.
This commit is contained in:
Mario Zechner 2025-10-11 04:40:42 +02:00
parent 3db2a6fe2c
commit b129154cc8
23 changed files with 423 additions and 180 deletions

View file

@ -2,13 +2,13 @@ import { streamSimple } from "../stream.js";
import type { AssistantMessage, Context, Message, ToolResultMessage, UserMessage } from "../types.js";
import { EventStream } from "../utils/event-stream.js";
import { validateToolArguments } from "../utils/validation.js";
import type { AgentContext, AgentEvent, AgentTool, AgentToolResult, PromptConfig } from "./types.js";
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentTool, AgentToolResult, QueuedMessage } from "./types.js";
// Main prompt function - returns a stream of events
export function agentLoop(
prompt: UserMessage,
context: AgentContext,
config: PromptConfig,
config: AgentLoopConfig,
signal?: AbortSignal,
streamFn?: typeof streamSimple,
): EventStream<AgentEvent, AgentContext["messages"]> {
@ -36,15 +36,33 @@ export function agentLoop(
messages,
};
// Keep looping while we have tool calls
// Keep looping while we have tool calls or queued messages
let hasMoreToolCalls = true;
let firstTurn = true;
while (hasMoreToolCalls) {
let queuedMessages: QueuedMessage<any>[] = (await config.getQueuedMessages?.()) || [];
while (hasMoreToolCalls || queuedMessages.length > 0) {
if (!firstTurn) {
stream.push({ type: "turn_start" });
} else {
firstTurn = false;
}
// Process queued messages first (inject before next assistant response)
if (queuedMessages.length > 0) {
for (const { original, llm } of queuedMessages) {
stream.push({ type: "message_start", message: original });
stream.push({ type: "message_end", message: original });
if (llm) {
currentContext.messages.push(llm);
newMessages.push(llm);
}
}
queuedMessages = [];
}
console.log("agent-loop: ", [...currentContext.messages]);
// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
newMessages.push(message);
@ -69,6 +87,9 @@ export function agentLoop(
newMessages.push(...toolResults);
}
stream.push({ type: "turn_end", message, toolResults: toolResults });
// Get queued messages after turn completes
queuedMessages = (await config.getQueuedMessages?.()) || [];
}
stream.push({ type: "agent_end", messages: newMessages });
stream.end(newMessages);
@ -80,7 +101,7 @@ export function agentLoop(
// Helper functions
async function streamAssistantResponse(
context: AgentContext,
config: PromptConfig,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
stream: EventStream<AgentEvent, AgentContext["messages"]>,
streamFn?: typeof streamSimple,

View file

@ -1,3 +1,3 @@
export { agentLoop } from "./agent-loop.js";
export * from "./tools/index.js";
export type { AgentContext, AgentEvent, AgentTool, PromptConfig } from "./types.js";
export type { AgentContext, AgentEvent, AgentLoopConfig, AgentTool, QueuedMessage } from "./types.js";

View file

@ -62,8 +62,15 @@ export type AgentEvent =
// contained in messages, which can be appended to the context
| { type: "agent_end"; messages: AgentContext["messages"] };
// Configuration for prompt execution
export interface PromptConfig extends SimpleStreamOptions {
// Queued message with optional LLM representation
export interface QueuedMessage<TApp = Message> {
original: TApp; // Original message for UI events
llm?: Message; // Optional transformed message for loop context (undefined if filtered)
}
// Configuration for agent loop execution
export interface AgentLoopConfig extends SimpleStreamOptions {
model: Model<any>;
preprocessor?: (messages: AgentContext["messages"], abortSignal?: AbortSignal) => Promise<AgentContext["messages"]>;
getQueuedMessages?: <T>() => Promise<QueuedMessage<T>[]>;
}

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { agentLoop } from "../src/agent/agent-loop.js";
import { calculateTool } from "../src/agent/tools/calculate.js";
import type { AgentContext, AgentEvent, PromptConfig } from "../src/agent/types.js";
import type { AgentContext, AgentEvent, AgentLoopConfig } from "../src/agent/types.js";
import { getModel } from "../src/models.js";
import type { Api, Message, Model, OptionsForApi, UserMessage } from "../src/types.js";
@ -15,7 +15,7 @@ async function calculateTest<TApi extends Api>(model: Model<TApi>, options: Opti
};
// Create the prompt config
const config: PromptConfig = {
const config: AgentLoopConfig = {
model,
...options,
};
@ -167,7 +167,7 @@ async function abortTest<TApi extends Api>(model: Model<TApi>, options: OptionsF
};
// Create the prompt config
const config: PromptConfig = {
const config: AgentLoopConfig = {
model,
...options,
};

View file

@ -1,4 +1,4 @@
import type { Context } from "@mariozechner/pi-ai";
import type { Context, QueuedMessage } from "@mariozechner/pi-ai";
import {
type AgentTool,
type AssistantMessage as AssistantMessageType,
@ -47,7 +47,9 @@ export interface AgentState {
export type AgentEvent =
| { type: "state-update"; state: AgentState }
| { type: "error-no-model" }
| { type: "error-no-api-key"; provider: string };
| { type: "error-no-api-key"; provider: string }
| { type: "started" }
| { type: "completed" };
export interface AgentOptions {
initialState?: Partial<AgentState>;
@ -74,6 +76,7 @@ export class Agent {
private transport: AgentTransport;
private debugListener?: (entry: DebugLogEntry) => void;
private messageTransformer: (messages: AppMessage[]) => Message[] | Promise<Message[]>;
private messageQueue: Array<QueuedMessage<AppMessage>> = [];
constructor(opts: AgentOptions) {
this._state = { ...this._state, ...opts.initialState };
@ -111,6 +114,14 @@ export class Agent {
appendMessage(m: AppMessage) {
this.patch({ messages: [...this._state.messages, m] });
}
async queueMessage(m: AppMessage) {
// Transform message and queue it for injection at next turn
const transformed = await this.messageTransformer([m]);
this.messageQueue.push({
original: m,
llm: transformed[0], // undefined if filtered out
});
}
clearMessages() {
this.patch({ messages: [] });
}
@ -119,6 +130,11 @@ export class Agent {
this.abortController?.abort();
}
private logState(message: string) {
const { systemPrompt, model, messages } = this._state;
console.log(message, { systemPrompt, model, messages });
}
async prompt(input: string, attachments?: Attachment[]) {
const model = this._state.model;
if (!model) {
@ -150,6 +166,7 @@ export class Agent {
this.abortController = new AbortController();
this.patch({ isStreaming: true, streamMessage: null, error: undefined });
this.emit({ type: "started" });
const reasoning =
this._state.thinkingLevel === "off"
@ -162,6 +179,12 @@ export class Agent {
tools: this._state.tools,
model,
reasoning,
getQueuedMessages: async <T>() => {
// Return queued messages (they'll be added to state via message_end event)
const queued = this.messageQueue.slice();
this.messageQueue = [];
return queued as QueuedMessage<T>[];
},
};
try {
@ -169,9 +192,12 @@ export class Agent {
let turnDebug: DebugLogEntry | null = null;
let turnStart = 0;
// Transform app messages to LLM-compatible messages
this.logState("prompt started, current state:");
// Transform app messages to LLM-compatible messages (initial set)
const llmMessages = await this.messageTransformer(this._state.messages);
console.log("transformed messages:", llmMessages);
for await (const ev of this.transport.run(
llmMessages,
userMessage as Message,
@ -292,11 +318,9 @@ export class Agent {
} finally {
this.patch({ isStreaming: false, streamMessage: null, pendingToolCalls: new Set<string>() });
this.abortController = undefined;
this.emit({ type: "completed" });
}
{
const { systemPrompt, model, messages } = this._state;
console.log("final state:", { systemPrompt, model, messages });
}
this.logState("final state:");
}
private patch(p: Partial<AgentState>): void {

View file

@ -1,12 +1,12 @@
import type {
AgentContext,
AgentLoopConfig,
Api,
AssistantMessage,
AssistantMessageEvent,
Context,
Message,
Model,
PromptConfig,
SimpleStreamOptions,
ToolCall,
UserMessage,
@ -348,9 +348,10 @@ export class AppTransport implements AgentTransport {
tools: cfg.tools,
};
const pc: PromptConfig = {
const pc: AgentLoopConfig = {
model: cfg.model,
reasoning: cfg.reasoning,
getQueuedMessages: cfg.getQueuedMessages,
};
// Yield events from the upstream agentLoop iterator

View file

@ -1,4 +1,10 @@
import { type AgentContext, agentLoop, type Message, type PromptConfig, type UserMessage } from "@mariozechner/pi-ai";
import {
type AgentContext,
type AgentLoopConfig,
agentLoop,
type Message,
type UserMessage,
} from "@mariozechner/pi-ai";
import { getAppStorage } from "../../storage/app-storage.js";
import type { AgentRunConfig, AgentTransport } from "./types.js";
@ -34,10 +40,11 @@ export class ProviderTransport implements AgentTransport {
tools: cfg.tools,
};
const pc: PromptConfig = {
const pc: AgentLoopConfig = {
model,
reasoning: cfg.reasoning,
apiKey,
getQueuedMessages: cfg.getQueuedMessages,
};
// Yield events from agentLoop

View file

@ -1,4 +1,4 @@
import type { AgentEvent, AgentTool, Message, Model } from "@mariozechner/pi-ai";
import type { AgentEvent, AgentTool, Message, Model, QueuedMessage } from "@mariozechner/pi-ai";
// The minimal configuration needed to run a turn.
export interface AgentRunConfig {
@ -6,6 +6,7 @@ export interface AgentRunConfig {
tools: AgentTool<any>[];
model: Model<any>;
reasoning?: "low" | "medium" | "high";
getQueuedMessages?: <T>() => Promise<QueuedMessage<T>[]>;
}
// Events yielded by transports must match the @mariozechner/pi-ai prompt() events.

View file

@ -19,10 +19,10 @@ import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
export class AgentInterface extends LitElement {
// Optional external session: when provided, this component becomes a view over the session
@property({ attribute: false }) session?: Agent;
@property() enableAttachments = true;
@property() enableModelSelector = true;
@property() enableThinkingSelector = true;
@property() showThemeToggle = false;
@property({ type: Boolean }) enableAttachments = true;
@property({ type: Boolean }) enableModelSelector = true;
@property({ type: Boolean }) enableThinkingSelector = true;
@property({ type: Boolean }) showThemeToggle = false;
// Optional custom API key prompt handler - if not provided, uses default dialog
@property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise<boolean>;
// Optional callback called before sending a message
@ -52,6 +52,10 @@ export class AgentInterface extends LitElement {
update();
}
public setAutoScroll(enabled: boolean) {
this._autoScroll = enabled;
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}

View file

@ -231,16 +231,22 @@ export class ToolMessage extends LitElement {
const result: ToolResultMessageType<any> | undefined = this.aborted
? { role: "toolResult", isError: true, output: "", toolCallId: this.toolCall.id, toolName: this.toolCall.name }
: this.result;
const toolContent = renderTool(
const renderResult = renderTool(
toolName,
this.toolCall.arguments,
result,
!this.aborted && (this.isStreaming || this.pending),
);
// Handle custom rendering (no card wrapper)
if (renderResult.isCustom) {
return renderResult.content;
}
// Default: wrap in card
return html`
<div class="p-2.5 border border-border rounded-md bg-card text-card-foreground shadow-xs">
${toolContent}
${renderResult.content}
</div>
`;
}

View file

@ -25,6 +25,15 @@ export interface SandboxResult {
*/
export type SandboxUrlProvider = () => string;
/**
* Escape HTML special sequences in code to prevent premature tag closure
* @param code Code that will be injected into <script> tags
* @returns Escaped code safe for injection
*/
function escapeScriptContent(code: string): string {
return code.replace(/<\/script/gi, "<\\/script");
}
@customElement("sandbox-iframe")
export class SandboxIframe extends LitElement {
private iframe?: HTMLIFrameElement;
@ -79,6 +88,26 @@ export class SandboxIframe extends LitElement {
// loadContent is always used for HTML artifacts
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, providers, true);
// Validate HTML before loading
const validationError = this.validateHtml(completeHtml);
if (validationError) {
console.error("HTML validation failed:", validationError);
// Show error in iframe instead of crashing
this.iframe?.remove();
this.iframe = document.createElement("iframe");
this.iframe.style.cssText = "width: 100%; height: 100%; border: none;";
this.iframe.srcdoc = `
<html>
<body style="font-family: monospace; padding: 20px; background: #fff; color: #000;">
<h3 style="color: #c00;">HTML Validation Error</h3>
<pre style="background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap;">${validationError}</pre>
</body>
</html>
`;
this.appendChild(this.iframe);
return;
}
// Remove previous iframe if exists
this.iframe?.remove();
@ -104,10 +133,11 @@ export class SandboxIframe extends LitElement {
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for sandbox-ready message directly
// Listen for sandbox-ready and sandbox-error messages directly
const readyHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Send content to sandbox
this.iframe?.contentWindow?.postMessage(
@ -121,7 +151,27 @@ export class SandboxIframe extends LitElement {
}
};
const errorHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-error" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// The sandbox.js already sent us the error via postMessage.
// We need to convert it to an execution-error message that the execute() consumer will handle.
// Simulate receiving an execution-error from the sandbox
window.postMessage(
{
sandboxId: sandboxId,
type: "execution-error",
error: { message: e.data.error, stack: e.data.stack },
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
window.addEventListener("message", errorHandler);
this.appendChild(this.iframe);
}
@ -228,15 +278,22 @@ export class SandboxIframe extends LitElement {
resolve({
success: false,
console: consoleProvider.getLogs(),
error: { message: "Execution timeout (30s)", stack: "" },
error: { message: "Execution timeout (120s)", stack: "" },
files,
});
}
}, 30000);
}, 120000);
// 4. Prepare HTML and create iframe
const completeHtml = this.prepareHtmlDocument(sandboxId, code, providers, isHtmlArtifact);
// 5. Validate HTML before sending to sandbox
const validationError = this.validateHtml(completeHtml);
if (validationError) {
reject(new Error(`HTML validation failed: ${validationError}`));
return;
}
if (this.sandboxUrlProvider) {
// Browser extension mode: wait for sandbox-ready
this.iframe = document.createElement("iframe");
@ -247,10 +304,11 @@ export class SandboxIframe extends LitElement {
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for sandbox-ready message directly
// Listen for sandbox-ready and sandbox-error messages
const readyHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Send content to sandbox
this.iframe?.contentWindow?.postMessage(
@ -264,7 +322,25 @@ export class SandboxIframe extends LitElement {
}
};
const errorHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-error" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Convert sandbox-error to execution-error for the execution consumer
window.postMessage(
{
sandboxId: sandboxId,
type: "execution-error",
error: { message: e.data.error, stack: e.data.stack },
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
window.addEventListener("message", errorHandler);
this.appendChild(this.iframe);
} else {
@ -282,6 +358,27 @@ export class SandboxIframe extends LitElement {
});
}
/**
* Validate HTML using DOMParser - returns error message if invalid, null if valid
* Note: JavaScript syntax validation is done in sandbox.js to avoid CSP restrictions
*/
private validateHtml(html: string): string | null {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Check for parser errors
const parserError = doc.querySelector("parsererror");
if (parserError) {
return parserError.textContent || "Unknown parse error";
}
return null;
} catch (error: any) {
return error.message || "Unknown validation error";
}
}
/**
* Prepare complete HTML document with runtime + user code
* PUBLIC so HtmlArtifact can use it for download button
@ -315,6 +412,9 @@ export class SandboxIframe extends LitElement {
return runtime + userCode;
} else {
// REPL - wrap code in HTML with runtime and call complete() when done
// Escape </script> in user code to prevent premature tag closure
const escapedUserCode = escapeScriptContent(userCode);
return `<!DOCTYPE html>
<html>
<head>
@ -326,7 +426,7 @@ export class SandboxIframe extends LitElement {
try {
// Wrap user code in async function to capture return value
const userCodeFunc = async () => {
${userCode}
${escapedUserCode}
};
const returnValue = await userCodeFunc();

View file

@ -59,7 +59,6 @@ export class RuntimeMessageRouter {
// Setup global listener if not already done
this.setupListener();
console.log(`Registered sandbox: ${sandboxId}, providers: ${providers.length}, consumers: ${consumers.length}`);
}
/**
@ -72,7 +71,6 @@ export class RuntimeMessageRouter {
if (context) {
context.iframe = iframe;
}
console.log("Set iframe for sandbox:", sandboxId);
}
/**
@ -96,7 +94,6 @@ export class RuntimeMessageRouter {
this.userScriptMessageListener = null;
}
}
console.log("Unregistered sandbox:", sandboxId);
}
/**
@ -108,7 +105,6 @@ export class RuntimeMessageRouter {
if (context) {
context.consumers.add(consumer);
}
console.log("Added consumer for sandbox:", sandboxId);
}
/**
@ -119,7 +115,6 @@ export class RuntimeMessageRouter {
if (context) {
context.consumers.delete(consumer);
}
console.log("Removed consumer for sandbox:", sandboxId);
}
/**
@ -132,18 +127,8 @@ export class RuntimeMessageRouter {
const { sandboxId, messageId } = e.data;
if (!sandboxId) return;
console.log(
"[ROUTER] Received message for sandbox:",
sandboxId,
"type:",
e.data.type,
"full message:",
e.data,
);
const context = this.sandboxes.get(sandboxId);
if (!context) {
console.log("[ROUTER] No context found for sandbox:", sandboxId);
return;
}
@ -161,19 +146,15 @@ export class RuntimeMessageRouter {
};
// 1. Try provider handlers first (for bidirectional comm)
console.log("[ROUTER] Broadcasting to", context.providers.length, "providers");
for (const provider of context.providers) {
if (provider.handleMessage) {
console.log("[ROUTER] Calling provider.handleMessage for", provider.constructor.name);
await provider.handleMessage(e.data, respond);
// Don't stop - let consumers also handle the message
}
}
// 2. Broadcast to consumers (one-way messages or lifecycle events)
console.log("[ROUTER] Broadcasting to", context.consumers.size, "consumers");
for (const consumer of context.consumers) {
console.log("[ROUTER] Calling consumer.handleMessage");
await consumer.handleMessage(e.data);
// Don't stop - let all consumers see the message
}
@ -186,7 +167,6 @@ export class RuntimeMessageRouter {
if (!this.userScriptMessageListener) {
// Guard: check if we're in extension context
if (typeof chrome === "undefined" || !chrome.runtime?.onUserScriptMessage) {
console.log("[RuntimeMessageRouter] User script API not available (not in extension context)");
return;
}
@ -197,8 +177,6 @@ export class RuntimeMessageRouter {
const context = this.sandboxes.get(sandboxId);
if (!context) return false;
console.log("Router received user script message for sandbox:", sandboxId, message);
const respond = (response: any) => {
sendResponse({
...response,
@ -227,7 +205,6 @@ export class RuntimeMessageRouter {
};
chrome.runtime.onUserScriptMessage.addListener(this.userScriptMessageListener);
console.log("[RuntimeMessageRouter] Registered chrome.runtime.onUserScriptMessage listener");
}
}
}

View file

@ -93,7 +93,7 @@ export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js";
// Tool renderers
export { DefaultRenderer } from "./tools/renderers/DefaultRenderer.js";
export { GetCurrentTimeRenderer } from "./tools/renderers/GetCurrentTimeRenderer.js";
export type { ToolRenderer } from "./tools/types.js";
export type { ToolRenderer, ToolRenderResult } from "./tools/types.js";
export type { Attachment } from "./utils/attachment-utils.js";
// Utils
export { loadAttachment } from "./utils/attachment-utils.js";

View file

@ -14,7 +14,7 @@ Execute JavaScript code in a sandboxed browser environment with full Web APIs.
## When to Use
- Quick calculations or data transformations
- Testing JavaScript code snippets
- Testing JavaScript code snippets in isolation
- Processing data with libraries (XLSX, CSV, etc.)
- Creating visualizations (charts, graphs)
@ -29,17 +29,32 @@ Execute JavaScript code in a sandboxed browser environment with full Web APIs.
- Chart.js: const Chart = (await import('https://esm.run/chart.js/auto')).default;
- Three.js: const THREE = await import('https://esm.run/three');
## Important Notes
- Graphics: Use fixed dimensions (800x600), NOT window.innerWidth/Height
- Chart.js: Set options: { responsive: false, animation: false }
- Three.js: renderer.setSize(800, 600) with matching aspect ratio
- Output: All console.log() calls are captured and displayed
## Persistence between tool calls
- Objects stored on global scope do not persist between calls.
- Use artifacts as a key-value JSON object store:
- Use createOrUpdateArtifact(filename, content) to persist data between calls. JSON objects are auto-stringified.
- Use listArtifacts() and getArtifact(filename) to read persisted data. JSON files are auto-parsed to objects.
- Prefer to use a single artifact throughout the session to store intermediate data (e.g. 'data.json').
## Input
- You have access to the user's attachments via listAttachments(), readTextAttachment(id), and readBinaryAttachment(id)
- You have access to previously created artifacts via listArtifacts() and getArtifact(filename)
## Output
- All console.log() calls are captured for you to inspect. The user does not see these logs.
- Create artifacts for file results (images, JSON, CSV, etc.) which persiste throughout the
session and are accessible to you and the user.
## Example
const data = [10, 20, 15, 25];
const sum = data.reduce((a, b) => a + b, 0);
const avg = sum / data.length;
console.log('Sum:', sum, 'Average:', avg);`;
console.log('Sum:', sum, 'Average:', avg);
## Important Notes
- Graphics: Use fixed dimensions (800x600), NOT window.innerWidth/Height
- Chart.js: Set options: { responsive: false, animation: false }
- Three.js: renderer.setSize(800, 600) with matching aspect ratio`;
// ============================================================================
// Artifacts Tool

View file

@ -6,7 +6,7 @@ import { FileCode2 } from "lucide";
import "../../components/ConsoleBlock.js";
import { i18n } from "../../utils/i18n.js";
import { renderCollapsibleHeader, renderHeader } from "../renderer-registry.js";
import type { ToolRenderer } from "../types.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
import { ArtifactPill } from "./ArtifactPill.js";
import type { ArtifactsPanel, ArtifactsParams } from "./artifacts.js";
@ -54,7 +54,7 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
params: ArtifactsParams | undefined,
result: ToolResultMessage<undefined> | undefined,
isStreaming?: boolean,
): TemplateResult {
): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : isStreaming ? "inprogress" : "complete";
// Create refs for collapsible sections
@ -101,7 +101,8 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
const isHtml = filename?.endsWith(".html");
return html`
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300 space-y-3">
@ -113,16 +114,21 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
}
</div>
</div>
`;
`,
isCustom: false,
};
}
// For other errors, just show error message
return html`
return {
content: html`
<div class="space-y-3">
${renderHeader(state, FileCode2, headerText)}
<div class="text-sm text-destructive">${result.output || i18n("An error occurred")}</div>
</div>
`;
`,
isCustom: false,
};
}
// Full params + result
@ -136,27 +142,33 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
// GET command: show code block with file content
if (command === "get") {
const fileContent = result.output || i18n("(no output)");
return html`
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300">
<code-block .code=${fileContent} language=${getLanguageFromFilename(filename)}></code-block>
</div>
</div>
`;
`,
isCustom: false,
};
}
// LOGS command: show console block
if (command === "logs") {
const logs = result.output || i18n("(no output)");
return html`
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300">
<console-block .content=${logs}></console-block>
</div>
</div>
`;
`,
isCustom: false,
};
}
// CREATE/UPDATE/REWRITE: always show code block, + console block for .html files
@ -165,7 +177,8 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
const isHtml = filename?.endsWith(".html");
const logs = result.output || "";
return html`
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300 space-y-3">
@ -173,13 +186,16 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
${isHtml && logs ? html`<console-block .content=${logs}></console-block>` : ""}
</div>
</div>
`;
`,
isCustom: false,
};
}
if (command === "update") {
const isHtml = filename?.endsWith(".html");
const logs = result.output || "";
return html`
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300 space-y-3">
@ -187,15 +203,20 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
${isHtml && logs ? html`<console-block .content=${logs}></console-block>` : ""}
</div>
</div>
`;
`,
isCustom: false,
};
}
// For DELETE, just show header
return html`
return {
content: html`
<div class="space-y-3">
${renderHeader(state, FileCode2, renderHeaderWithPill(headerText, filename))}
</div>
`;
`,
isCustom: false,
};
}
// Params only (streaming or waiting for result)
@ -204,7 +225,7 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
// If no command yet
if (!command) {
return renderHeader(state, FileCode2, i18n("Preparing artifact..."));
return { content: renderHeader(state, FileCode2, i18n("Preparing artifact...")), isCustom: false };
}
const labels = getCommandLabels(command);
@ -214,7 +235,8 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
switch (command) {
case "create":
case "rewrite":
return html`
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300">
@ -225,10 +247,13 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
}
</div>
</div>
`;
`,
isCustom: false,
};
case "update":
return html`
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300">
@ -239,27 +264,35 @@ export class ArtifactsToolRenderer implements ToolRenderer<ArtifactsParams, unde
}
</div>
</div>
`;
`,
isCustom: false,
};
case "get":
case "logs":
return html`
return {
content: html`
<div>
${renderCollapsibleHeader(state, FileCode2, renderHeaderWithPill(headerText, filename), contentRef, chevronRef, false)}
<div ${ref(contentRef)} class="max-h-0 overflow-hidden transition-all duration-300"></div>
</div>
`;
`,
isCustom: false,
};
default:
return html`
return {
content: html`
<div>
${renderHeader(state, FileCode2, renderHeaderWithPill(headerText, filename))}
</div>
`;
`,
isCustom: false,
};
}
}
// No params or result yet
return renderHeader(state, FileCode2, i18n("Preparing artifact..."));
return { content: renderHeader(state, FileCode2, i18n("Preparing artifact...")), isCustom: false };
}
}

View file

@ -408,7 +408,7 @@ export class ArtifactsPanel extends LitElement {
let result = `Created file ${params.filename}`;
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
const logs = await this.waitForHtmlExecution(params.filename);
result += logs;
result += `\n${logs}`;
}
return result;
@ -486,7 +486,7 @@ export class ArtifactsPanel extends LitElement {
let result = "";
if (this.getFileType(params.filename) === "html" && !options.skipWait) {
const logs = await this.waitForHtmlExecution(params.filename);
result += logs;
result += `\n${logs}`;
}
return result;

View file

@ -1,9 +1,9 @@
import type { TemplateResult } from "@mariozechner/mini-lit";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import "./javascript-repl.js"; // Auto-registers the renderer
import { getToolRenderer, registerToolRenderer } from "./renderer-registry.js";
import { BashRenderer } from "./renderers/BashRenderer.js";
import { DefaultRenderer } from "./renderers/DefaultRenderer.js";
import "./javascript-repl.js"; // Auto-registers the renderer
import type { ToolRenderResult } from "./types.js";
// Register all built-in tool renderers
registerToolRenderer("bash", new BashRenderer());
@ -18,7 +18,7 @@ export function renderTool(
params: any | undefined,
result: ToolResultMessage | undefined,
isStreaming?: boolean,
): TemplateResult {
): ToolRenderResult {
const renderer = getToolRenderer(toolName);
if (renderer) {
return renderer.render(params, result, isStreaming);
@ -26,4 +26,4 @@ export function renderTool(
return defaultRenderer.render(params, result, isStreaming);
}
export { registerToolRenderer, getToolRenderer };
export { getToolRenderer, registerToolRenderer };

View file

@ -1,4 +1,4 @@
import { html, i18n, type TemplateResult } from "@mariozechner/mini-lit";
import { html, i18n } from "@mariozechner/mini-lit";
import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
import { createRef, ref } from "lit/directives/ref.js";
@ -8,7 +8,7 @@ import type { SandboxRuntimeProvider } from "../components/sandbox/SandboxRuntim
import { JAVASCRIPT_REPL_DESCRIPTION } from "../prompts/tool-prompts.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { registerToolRenderer, renderCollapsibleHeader, renderHeader } from "./renderer-registry.js";
import type { ToolRenderer } from "./types.js";
import type { ToolRenderer, ToolRenderResult } from "./types.js";
// Execute JavaScript code with attachments using SandboxedIframe
export async function executeJavaScript(
@ -194,7 +194,7 @@ export const javascriptReplRenderer: ToolRenderer<JavaScriptReplParams, JavaScri
params: JavaScriptReplParams | undefined,
result: ToolResultMessage<JavaScriptReplResult> | undefined,
isStreaming?: boolean,
): TemplateResult {
): ToolRenderResult {
// Determine status
const state = result ? (result.isError ? "error" : "complete") : isStreaming ? "inprogress" : "complete";
@ -236,38 +236,44 @@ export const javascriptReplRenderer: ToolRenderer<JavaScriptReplParams, JavaScri
};
});
return html`
<div>
${renderCollapsibleHeader(state, Code, params.title ? params.title : i18n("Executing JavaScript"), codeContentRef, codeChevronRef, false)}
<div ${ref(codeContentRef)} class="max-h-0 overflow-hidden transition-all duration-300 space-y-3">
<code-block .code=${params.code || ""} language="javascript"></code-block>
${output ? html`<console-block .content=${output} .variant=${result.isError ? "error" : "default"}></console-block>` : ""}
return {
content: html`
<div>
${renderCollapsibleHeader(state, Code, params.title ? params.title : i18n("Executing JavaScript"), codeContentRef, codeChevronRef, false)}
<div ${ref(codeContentRef)} class="max-h-0 overflow-hidden transition-all duration-300 space-y-3">
<code-block .code=${params.code || ""} language="javascript"></code-block>
${output ? html`<console-block .content=${output} .variant=${result.isError ? "error" : "default"}></console-block>` : ""}
</div>
${
attachments.length
? html`<div class="flex flex-wrap gap-2 mt-3">
${attachments.map((att) => html`<attachment-tile .attachment=${att}></attachment-tile>`)}
</div>`
: ""
}
</div>
${
attachments.length
? html`<div class="flex flex-wrap gap-2 mt-3">
${attachments.map((att) => html`<attachment-tile .attachment=${att}></attachment-tile>`)}
</div>`
: ""
}
</div>
`;
`,
isCustom: false,
};
}
// Just params (streaming or waiting for result)
if (params) {
return html`
<div>
${renderCollapsibleHeader(state, Code, params.title ? params.title : i18n("Executing JavaScript"), codeContentRef, codeChevronRef, false)}
<div ${ref(codeContentRef)} class="max-h-0 overflow-hidden transition-all duration-300">
${params.code ? html`<code-block .code=${params.code} language="javascript"></code-block>` : ""}
return {
content: html`
<div>
${renderCollapsibleHeader(state, Code, params.title ? params.title : i18n("Executing JavaScript"), codeContentRef, codeChevronRef, false)}
<div ${ref(codeContentRef)} class="max-h-0 overflow-hidden transition-all duration-300">
${params.code ? html`<code-block .code=${params.code} language="javascript"></code-block>` : ""}
</div>
</div>
</div>
`;
`,
isCustom: false,
};
}
// No params or result yet
return renderHeader(state, Code, i18n("Preparing JavaScript..."));
return { content: renderHeader(state, Code, i18n("Preparing JavaScript...")), isCustom: false };
},
};

View file

@ -1,9 +1,9 @@
import { html, type TemplateResult } from "@mariozechner/mini-lit";
import { html } from "@mariozechner/mini-lit";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { SquareTerminal } from "lucide";
import { i18n } from "../../utils/i18n.js";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer } from "../types.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
interface BashParams {
command: string;
@ -11,32 +11,38 @@ interface BashParams {
// Bash tool has undefined details (only uses output)
export class BashRenderer implements ToolRenderer<BashParams, undefined> {
render(params: BashParams | undefined, result: ToolResultMessage<undefined> | undefined): TemplateResult {
render(params: BashParams | undefined, result: ToolResultMessage<undefined> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
// With result: show command + output
if (result && params?.command) {
const output = result.output || "";
const combined = output ? `> ${params.command}\n\n${output}` : `> ${params.command}`;
return html`
<div class="space-y-3">
${renderHeader(state, SquareTerminal, i18n("Running command..."))}
<console-block .content=${combined} .variant=${result.isError ? "error" : "default"}></console-block>
</div>
`;
return {
content: html`
<div class="space-y-3">
${renderHeader(state, SquareTerminal, i18n("Running command..."))}
<console-block .content=${combined} .variant=${result.isError ? "error" : "default"}></console-block>
</div>
`,
isCustom: false,
};
}
// Just params (streaming or waiting)
if (params?.command) {
return html`
<div class="space-y-3">
${renderHeader(state, SquareTerminal, i18n("Running command..."))}
<console-block .content=${`> ${params.command}`}></console-block>
</div>
`;
return {
content: html`
<div class="space-y-3">
${renderHeader(state, SquareTerminal, i18n("Running command..."))}
<console-block .content=${`> ${params.command}`}></console-block>
</div>
`,
isCustom: false,
};
}
// No params yet
return renderHeader(state, SquareTerminal, i18n("Waiting for command..."));
return { content: renderHeader(state, SquareTerminal, i18n("Waiting for command...")), isCustom: false };
}
}

View file

@ -1,9 +1,9 @@
import { html, type TemplateResult } from "@mariozechner/mini-lit";
import { html } from "@mariozechner/mini-lit";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { Calculator } from "lucide";
import { i18n } from "../../utils/i18n.js";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer } from "../types.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
interface CalculateParams {
expression: string;
@ -11,7 +11,7 @@ interface CalculateParams {
// Calculate tool has undefined details (only uses output)
export class CalculateRenderer implements ToolRenderer<CalculateParams, undefined> {
render(params: CalculateParams | undefined, result: ToolResultMessage<undefined> | undefined): TemplateResult {
render(params: CalculateParams | undefined, result: ToolResultMessage<undefined> | undefined): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
// Full params + full result
@ -20,29 +20,35 @@ export class CalculateRenderer implements ToolRenderer<CalculateParams, undefine
// Error: show expression in header, error below
if (result.isError) {
return html`
<div class="space-y-3">
${renderHeader(state, Calculator, params.expression)}
<div class="text-sm text-destructive">${output}</div>
</div>
`;
return {
content: html`
<div class="space-y-3">
${renderHeader(state, Calculator, params.expression)}
<div class="text-sm text-destructive">${output}</div>
</div>
`,
isCustom: false,
};
}
// Success: show expression = result in header
return renderHeader(state, Calculator, `${params.expression} = ${output}`);
return { content: renderHeader(state, Calculator, `${params.expression} = ${output}`), isCustom: false };
}
// Full params, no result: just show header with expression in it
if (params?.expression) {
return renderHeader(state, Calculator, `${i18n("Calculating")} ${params.expression}`);
return {
content: renderHeader(state, Calculator, `${i18n("Calculating")} ${params.expression}`),
isCustom: false,
};
}
// Partial params (empty expression), no result
if (params && !params.expression) {
return renderHeader(state, Calculator, i18n("Writing expression..."));
return { content: renderHeader(state, Calculator, i18n("Writing expression...")), isCustom: false };
}
// No params, no result
return renderHeader(state, Calculator, i18n("Waiting for expression..."));
return { content: renderHeader(state, Calculator, i18n("Waiting for expression...")), isCustom: false };
}
}

View file

@ -1,14 +1,17 @@
import { html, type TemplateResult } from "@mariozechner/mini-lit";
import { html } from "@mariozechner/mini-lit";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { i18n } from "../../utils/i18n.js";
import type { ToolRenderer } from "../types.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
export class DefaultRenderer implements ToolRenderer {
render(params: any | undefined, result: ToolResultMessage | undefined, isStreaming?: boolean): TemplateResult {
render(params: any | undefined, result: ToolResultMessage | undefined, isStreaming?: boolean): ToolRenderResult {
// Show result if available
if (result) {
const text = result.output || i18n("(no output)");
return html`<div class="text-sm text-muted-foreground whitespace-pre-wrap font-mono">${text}</div>`;
return {
content: html`<div class="text-sm text-muted-foreground whitespace-pre-wrap font-mono">${text}</div>`,
isCustom: false,
};
}
// Show params
@ -25,13 +28,19 @@ export class DefaultRenderer implements ToolRenderer {
}
if (isStreaming && (!text || text === "{}" || text === "null")) {
return html`<div class="text-sm text-muted-foreground">${i18n("Preparing tool parameters...")}</div>`;
return {
content: html`<div class="text-sm text-muted-foreground">${i18n("Preparing tool parameters...")}</div>`,
isCustom: false,
};
}
return html`<console-block .content=${text}></console-block>`;
return { content: html`<console-block .content=${text}></console-block>`, isCustom: false };
}
// No params or result yet
return html`<div class="text-sm text-muted-foreground">${i18n("Preparing tool...")}</div>`;
return {
content: html`<div class="text-sm text-muted-foreground">${i18n("Preparing tool...")}</div>`,
isCustom: false,
};
}
}

View file

@ -1,9 +1,9 @@
import { html, type TemplateResult } from "@mariozechner/mini-lit";
import { html } from "@mariozechner/mini-lit";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { Clock } from "lucide";
import { i18n } from "../../utils/i18n.js";
import { renderHeader } from "../renderer-registry.js";
import type { ToolRenderer } from "../types.js";
import type { ToolRenderer, ToolRenderResult } from "../types.js";
interface GetCurrentTimeParams {
timezone?: string;
@ -11,7 +11,10 @@ interface GetCurrentTimeParams {
// GetCurrentTime tool has undefined details (only uses output)
export class GetCurrentTimeRenderer implements ToolRenderer<GetCurrentTimeParams, undefined> {
render(params: GetCurrentTimeParams | undefined, result: ToolResultMessage<undefined> | undefined): TemplateResult {
render(
params: GetCurrentTimeParams | undefined,
result: ToolResultMessage<undefined> | undefined,
): ToolRenderResult {
const state = result ? (result.isError ? "error" : "complete") : "inprogress";
// Full params + full result
@ -23,16 +26,19 @@ export class GetCurrentTimeRenderer implements ToolRenderer<GetCurrentTimeParams
// Error: show header, error below
if (result.isError) {
return html`
<div class="space-y-3">
${renderHeader(state, Clock, headerText)}
<div class="text-sm text-destructive">${output}</div>
</div>
`;
return {
content: html`
<div class="space-y-3">
${renderHeader(state, Clock, headerText)}
<div class="text-sm text-destructive">${output}</div>
</div>
`,
isCustom: false,
};
}
// Success: show time in header
return renderHeader(state, Clock, `${headerText}: ${output}`);
return { content: renderHeader(state, Clock, `${headerText}: ${output}`), isCustom: false };
}
// Full result, no params
@ -41,29 +47,38 @@ export class GetCurrentTimeRenderer implements ToolRenderer<GetCurrentTimeParams
// Error: show header, error below
if (result.isError) {
return html`
<div class="space-y-3">
${renderHeader(state, Clock, i18n("Getting current date and time"))}
<div class="text-sm text-destructive">${output}</div>
</div>
`;
return {
content: html`
<div class="space-y-3">
${renderHeader(state, Clock, i18n("Getting current date and time"))}
<div class="text-sm text-destructive">${output}</div>
</div>
`,
isCustom: false,
};
}
// Success: show time in header
return renderHeader(state, Clock, `${i18n("Getting current date and time")}: ${output}`);
return {
content: renderHeader(state, Clock, `${i18n("Getting current date and time")}: ${output}`),
isCustom: false,
};
}
// Full params, no result: show timezone info in header
if (params?.timezone) {
return renderHeader(state, Clock, `${i18n("Getting current time in")} ${params.timezone}`);
return {
content: renderHeader(state, Clock, `${i18n("Getting current time in")} ${params.timezone}`),
isCustom: false,
};
}
// Partial params (no timezone) or empty params, no result
if (params) {
return renderHeader(state, Clock, i18n("Getting current date and time"));
return { content: renderHeader(state, Clock, i18n("Getting current date and time")), isCustom: false };
}
// No params, no result
return renderHeader(state, Clock, i18n("Getting time..."));
return { content: renderHeader(state, Clock, i18n("Getting time...")), isCustom: false };
}
}

View file

@ -1,10 +1,15 @@
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import type { TemplateResult } from "lit";
export interface ToolRenderResult {
content: TemplateResult;
isCustom: boolean; // true = no card wrapper, false = wrap in card
}
export interface ToolRenderer<TParams = any, TDetails = any> {
render(
params: TParams | undefined,
result: ToolResultMessage<TDetails> | undefined,
isStreaming?: boolean,
): TemplateResult;
): ToolRenderResult;
}