import { html } from "@mariozechner/mini-lit"; import type { ToolResultMessage, Usage } from "@mariozechner/pi-ai"; import { LitElement } from "lit"; import { customElement, property, query } from "lit/decorators.js"; import { ModelSelector } from "../dialogs/ModelSelector.js"; import type { MessageEditor } from "./MessageEditor.js"; import "./MessageEditor.js"; import "./MessageList.js"; import "./Messages.js"; // Import for side effects to register the custom elements import type { Agent, AgentEvent } from "../agent/agent.js"; import { getAppStorage } from "../storage/app-storage.js"; import "./StreamingMessageContainer.js"; import type { Attachment } from "../utils/attachment-utils.js"; import { formatUsage } from "../utils/format.js"; import { i18n } from "../utils/i18n.js"; import type { StreamingMessageContainer } from "./StreamingMessageContainer.js"; @customElement("agent-interface") export class AgentInterface extends LitElement { // Optional external session: when provided, this component becomes a view over the session @property({ attribute: false }) session?: Agent; @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; // Optional callback called before sending a message @property({ attribute: false }) onBeforeSend?: () => void | Promise; // Optional callback called before executing a tool call - return false to prevent execution @property({ attribute: false }) onBeforeToolCall?: (toolName: string, args: any) => boolean | Promise; // Optional callback called when cost display is clicked @property({ attribute: false }) onCostClick?: () => void; // References @query("message-editor") private _messageEditor!: MessageEditor; @query("streaming-message-container") private _streamingContainer!: StreamingMessageContainer; private _autoScroll = true; private _lastScrollTop = 0; private _lastClientHeight = 0; private _scrollContainer?: HTMLElement; private _resizeObserver?: ResizeObserver; private _unsubscribeSession?: () => void; public setInput(text: string, attachments?: Attachment[]) { const update = () => { if (!this._messageEditor) requestAnimationFrame(update); else { this._messageEditor.value = text; this._messageEditor.attachments = attachments || []; } }; update(); } public setAutoScroll(enabled: boolean) { this._autoScroll = enabled; } protected override createRenderRoot(): HTMLElement | DocumentFragment { return this; } override willUpdate(changedProperties: Map) { super.willUpdate(changedProperties); // Re-subscribe when session property changes if (changedProperties.has("session")) { this.setupSessionSubscription(); } } override async connectedCallback() { super.connectedCallback(); this.style.display = "flex"; this.style.flexDirection = "column"; this.style.height = "100%"; this.style.minHeight = "0"; // Wait for first render to get scroll container await this.updateComplete; this._scrollContainer = this.querySelector(".overflow-y-auto") as HTMLElement; if (this._scrollContainer) { // Set up ResizeObserver to detect content changes this._resizeObserver = new ResizeObserver(() => { if (this._autoScroll && this._scrollContainer) { this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight; } }); // Observe the content container inside the scroll container const contentContainer = this._scrollContainer.querySelector(".max-w-3xl"); if (contentContainer) { this._resizeObserver.observe(contentContainer); } // Set up scroll listener with better detection this._scrollContainer.addEventListener("scroll", this._handleScroll); } // Subscribe to external session if provided this.setupSessionSubscription(); } override disconnectedCallback() { super.disconnectedCallback(); // Clean up observers and listeners if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = undefined; } if (this._scrollContainer) { this._scrollContainer.removeEventListener("scroll", this._handleScroll); } if (this._unsubscribeSession) { this._unsubscribeSession(); this._unsubscribeSession = undefined; } } private setupSessionSubscription() { if (this._unsubscribeSession) { this._unsubscribeSession(); this._unsubscribeSession = undefined; } if (!this.session) return; this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => { if (ev.type === "state-update") { if (this._streamingContainer) { this._streamingContainer.isStreaming = ev.state.isStreaming; this._streamingContainer.setMessage(ev.state.streamMessage, !ev.state.isStreaming); } this.requestUpdate(); } else if (ev.type === "error-no-model") { // TODO show some UI feedback } else if (ev.type === "error-no-api-key") { // Handled by onApiKeyRequired callback } }); } private _handleScroll = (_ev: any) => { if (!this._scrollContainer) return; const currentScrollTop = this._scrollContainer.scrollTop; const scrollHeight = this._scrollContainer.scrollHeight; const clientHeight = this._scrollContainer.clientHeight; const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight; // Ignore relayout due to message editor getting pushed up by stats if (clientHeight < this._lastClientHeight) { this._lastClientHeight = clientHeight; return; } // Only disable auto-scroll if user scrolled UP or is far from bottom if (currentScrollTop !== 0 && currentScrollTop < this._lastScrollTop && distanceFromBottom > 50) { this._autoScroll = false; } else if (distanceFromBottom < 10) { // Re-enable if very close to bottom this._autoScroll = true; } this._lastScrollTop = currentScrollTop; this._lastClientHeight = clientHeight; }; public async sendMessage(input: string, attachments?: Attachment[]) { if ((!input.trim() && attachments?.length === 0) || this.session?.state.isStreaming) return; const session = this.session; if (!session) throw new Error("No session set on AgentInterface"); if (!session.state.model) throw new Error("No model set on AgentInterface"); // Check if API key exists for the provider (only needed in direct mode) const provider = session.state.model.provider; const apiKey = await getAppStorage().providerKeys.get(provider); // If no API key, prompt for it if (!apiKey) { if (!this.onApiKeyRequired) { console.error("No API key configured and no onApiKeyRequired handler set"); return; } const success = await this.onApiKeyRequired(provider); // If still no API key, abort the send if (!success) { return; } } // Call onBeforeSend hook before sending if (this.onBeforeSend) { await this.onBeforeSend(); } // Only clear editor after we know we can send this._messageEditor.value = ""; this._messageEditor.attachments = []; this._autoScroll = true; // Enable auto-scroll when sending a message await this.session?.prompt(input, attachments); } private renderMessages() { if (!this.session) return html`
${i18n("No session available")}
`; const state = this.session.state; // Build a map of tool results to allow inline rendering in assistant messages const toolResultsById = new Map>(); for (const message of state.messages) { if (message.role === "toolResult") { toolResultsById.set(message.toolCallId, message); } } return html`
()} .isStreaming=${state.isStreaming} .onCostClick=${this.onCostClick} >
`; } private renderStats() { if (!this.session) return html`
`; const state = this.session.state; const totals = state.messages .filter((m) => m.role === "assistant") .reduce( (acc, msg: any) => { const usage = msg.usage; if (usage) { acc.input += usage.input; acc.output += usage.output; acc.cacheRead += usage.cacheRead; acc.cacheWrite += usage.cacheWrite; acc.cost.total += usage.cost.total; } return acc; }, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, } satisfies Usage, ); const hasTotals = totals.input || totals.output || totals.cacheRead || totals.cacheWrite; const totalsText = hasTotals ? formatUsage(totals) : ""; return html`
${this.showThemeToggle ? html`` : html``}
${ totalsText ? this.onCostClick ? html`${totalsText}` : html`${totalsText}` : "" }
`; } override render() { if (!this.session) return html`
${i18n("No session set")}
`; const session = this.session; const state = this.session.state; return html`
${this.renderMessages()}
{ this.sendMessage(input, attachments); }} .onAbort=${() => session.abort()} .onModelSelect=${() => { ModelSelector.open(state.model, (model) => session.setModel(model)); }} .onThinkingChange=${ this.enableThinkingSelector ? (level: "off" | "minimal" | "low" | "medium" | "high") => { session.setThinkingLevel(level); } : undefined } > ${this.renderStats()}
`; } } // Register custom element with guard if (!customElements.get("agent-interface")) { customElements.define("agent-interface", AgentInterface); }