mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
- Input submissions take entry: { kind, data } (InputEntry): placed as that
kind with that data and the content as user message, instead of pi.user.
Kinds must be non-empty strings outside pi.*. Queued inputs carry it in
their pi.inbox item.
- Writes with only kind and data (no model, head, edits) are appended and
settled done at once, busy or not; only a queued reset holds them back.
- Experimental hosts render such inputs as user messages and title by them.
closes #10407
232 lines
9.1 KiB
TypeScript
232 lines
9.1 KiB
TypeScript
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
|
import type { ConversationView, EntryRecord, InboxState, LiveState } from "@earendil-works/pi-durable";
|
|
import { Container, Spacer, Text, TruncatedText, type TUI } from "@earendil-works/pi-tui";
|
|
import { createAllToolRenderers } from "../core/tools/renderers/index.ts";
|
|
import { AssistantMessageComponent } from "../modes/interactive/components/assistant-message.ts";
|
|
import { type StatusIndicator, WorkingStatusIndicator } from "../modes/interactive/components/status-indicator.ts";
|
|
import { ToolExecutionComponent, type ToolRenderers } from "../modes/interactive/components/tool-execution.ts";
|
|
import { UserMessageComponent } from "../modes/interactive/components/user-message.ts";
|
|
import { theme } from "../modes/interactive/theme/theme.ts";
|
|
|
|
/** The `pi.live` document of a view: the active run, the streaming answer, and running tools. */
|
|
export function liveOf(view: ConversationView): LiveState {
|
|
return (view.docs["pi.live"] ?? {}) as LiveState;
|
|
}
|
|
|
|
function userText(content: UserMessage["content"]): string {
|
|
if (typeof content === "string") return content;
|
|
return content
|
|
.filter((block) => block.type === "text")
|
|
.map((block) => block.text)
|
|
.join("");
|
|
}
|
|
|
|
/** Renders the root conversation's durable view for the service-only experimental presentation. */
|
|
export class ExperimentalChatView {
|
|
static readonly #renderers: Record<string, ToolRenderers> = createAllToolRenderers();
|
|
|
|
readonly transcript = new Container();
|
|
readonly pendingMessages = new Container();
|
|
readonly status = new Container();
|
|
readonly #ui: TUI;
|
|
readonly #cwd: string;
|
|
/** The newest card per call ID; provider call IDs may repeat across turns. */
|
|
readonly #tools = new Map<string, ToolExecutionComponent>();
|
|
/** Every card shown, also older ones whose call ID a later turn reused. */
|
|
readonly #cards: ToolExecutionComponent[] = [];
|
|
/** Call IDs whose cards the streaming answer created; its entry takes them over. */
|
|
readonly #streamingCalls = new Set<string>();
|
|
#renderedEntryIds: number[] = [];
|
|
#streaming: AssistantMessageComponent | undefined;
|
|
#indicator: StatusIndicator | undefined;
|
|
#statusText = "";
|
|
|
|
constructor(ui: TUI, cwd: string) {
|
|
this.#ui = ui;
|
|
this.#cwd = cwd;
|
|
}
|
|
|
|
apply(view: ConversationView): void {
|
|
const live = liveOf(view);
|
|
this.#syncTranscript(view.entries);
|
|
const message = live.generation?.message as AssistantMessage | undefined;
|
|
// A partial without its entry was dropped, for example by a retry: render the transcript again.
|
|
if (message === undefined && this.#streaming !== undefined) this.#rebuild(view.entries);
|
|
if (message !== undefined) this.#syncStreaming(message);
|
|
for (const slot of live.tools ?? []) {
|
|
if (slot.status === "pending") continue;
|
|
const component = this.#tool(slot.name, slot.callId);
|
|
component.setArgsComplete();
|
|
if (slot.status !== "running") continue;
|
|
component.markExecutionStarted();
|
|
if (slot.output !== undefined) {
|
|
component.updateResult(
|
|
{ content: [{ type: "text", text: slot.output }], details: slot.details, isError: false },
|
|
true,
|
|
);
|
|
}
|
|
}
|
|
this.#syncQueue((view.docs["pi.inbox"] ?? { items: [] }) as InboxState);
|
|
this.#syncStatus(live);
|
|
this.transcript.invalidate();
|
|
this.pendingMessages.invalidate();
|
|
this.status.invalidate();
|
|
}
|
|
|
|
refreshTheme(view: ConversationView): void {
|
|
this.#indicator?.dispose();
|
|
this.#indicator = undefined;
|
|
this.#statusText = "";
|
|
this.status.clear();
|
|
this.#rebuild(view.entries);
|
|
this.apply(view);
|
|
}
|
|
|
|
dispose(): void {
|
|
this.#discardTools();
|
|
this.#indicator?.dispose();
|
|
}
|
|
|
|
/** Finish every card: a running bash card keeps a timer until it gets a final result. */
|
|
#discardTools(): void {
|
|
for (const component of this.#cards) component.updateResult({ content: [], isError: false }, false);
|
|
this.#cards.length = 0;
|
|
this.#tools.clear();
|
|
this.#streamingCalls.clear();
|
|
}
|
|
|
|
#syncQueue(inbox: InboxState): void {
|
|
this.pendingMessages.clear();
|
|
for (const item of inbox.items) {
|
|
const text =
|
|
item.mode === "write"
|
|
? `<${String(item.entry.kind)}>`
|
|
: userText(item.content as UserMessage["content"]).replace(/\s+/g, " ");
|
|
this.pendingMessages.addChild(new TruncatedText(theme.fg("muted", `[${item.mode}] ${text}`), 1, 0));
|
|
}
|
|
}
|
|
|
|
#syncStatus(live: LiveState): void {
|
|
const generation = live.generation;
|
|
const compaction = live.compactions?.[0];
|
|
const runningTool = live.tools?.find((slot) => slot.status === "running");
|
|
let text = "";
|
|
if (generation?.retry !== undefined) {
|
|
text = `Retrying (attempt ${generation.attempt + 1}): ${generation.retry.error}`;
|
|
} else if (generation?.deferred !== undefined) text = "Waiting for deferred response...";
|
|
else if (compaction !== undefined) {
|
|
text = compaction.retry
|
|
? `Retrying ${compaction.reason} compaction (attempt ${compaction.attempt + 1})...`
|
|
: `Compacting (${compaction.reason})...`;
|
|
} else if (runningTool !== undefined) text = `Running ${runningTool.name}... (esc to abort)`;
|
|
else if (live.run !== undefined) text = "Working... (esc to abort)";
|
|
if (text === this.#statusText) return;
|
|
this.#statusText = text;
|
|
this.#indicator?.dispose();
|
|
this.#indicator = undefined;
|
|
this.status.clear();
|
|
if (text.length > 0) {
|
|
this.#indicator = new WorkingStatusIndicator(this.#ui, text);
|
|
this.status.addChild(this.#indicator);
|
|
}
|
|
}
|
|
|
|
#syncTranscript(entries: readonly EntryRecord[]): void {
|
|
// Compaction and resets replace the head of the active transcript.
|
|
if (this.#renderedEntryIds.some((id, index) => entries[index]?.id !== id)) this.#rebuild(entries);
|
|
for (const entry of entries.slice(this.#renderedEntryIds.length)) {
|
|
this.#addEntry(entry);
|
|
this.#renderedEntryIds.push(entry.id);
|
|
}
|
|
}
|
|
|
|
#rebuild(entries: readonly EntryRecord[]): void {
|
|
this.transcript.clear();
|
|
this.#discardTools();
|
|
this.#renderedEntryIds = [];
|
|
this.#streaming = undefined;
|
|
for (const entry of entries) {
|
|
this.#addEntry(entry);
|
|
this.#renderedEntryIds.push(entry.id);
|
|
}
|
|
}
|
|
|
|
#addEntry(entry: EntryRecord): void {
|
|
const message = entry.model?.[0];
|
|
if (entry.kind === "pi.user" && message?.role === "user") {
|
|
this.transcript.addChild(new Spacer(1));
|
|
this.transcript.addChild(new UserMessageComponent(userText(message.content)));
|
|
} else if (entry.kind === "pi.assistant" && message?.role === "assistant") {
|
|
const component = this.#streaming ?? new AssistantMessageComponent();
|
|
if (this.#streaming === undefined) this.transcript.addChild(component);
|
|
this.#streaming = undefined;
|
|
component.updateContent(message, false);
|
|
// Only a tool-calling answer runs its calls; an aborted, failed, or truncated one never does.
|
|
const ran = message.stopReason === "toolUse";
|
|
for (const content of message.content) {
|
|
if (content.type !== "toolCall") continue;
|
|
const streamed = this.#streamingCalls.has(content.id);
|
|
if (!ran && !streamed) continue;
|
|
const card = this.#tool(content.name, content.id, content.arguments, !streamed);
|
|
card.setArgsComplete();
|
|
if (!ran) {
|
|
const text = "Not run: the answer was interrupted.";
|
|
card.updateResult({ content: [{ type: "text", text }], isError: true }, false);
|
|
}
|
|
}
|
|
this.#streamingCalls.clear();
|
|
} else if (entry.kind === "pi.tool-result" && message?.role === "toolResult") {
|
|
const result = message as ToolResultMessage;
|
|
this.#tool(result.toolName, result.toolCallId).updateResult(result);
|
|
} else if (entry.kind === "pi.compaction") {
|
|
this.#addText(theme.fg("muted", "[compaction]"));
|
|
if (message?.role === "user") this.#addText(userText(message.content));
|
|
} else if (entry.kind === "pi.reset") {
|
|
this.#addText(theme.fg("muted", "[new context]"));
|
|
} else if (!entry.kind.startsWith("pi.") && message?.role === "user" && entry.model?.length === 1) {
|
|
// An input placed as an application entry.
|
|
this.transcript.addChild(new Spacer(1));
|
|
this.transcript.addChild(new UserMessageComponent(userText(message.content)));
|
|
}
|
|
}
|
|
|
|
#syncStreaming(message: AssistantMessage): void {
|
|
if (this.#streaming === undefined) {
|
|
this.#streaming = new AssistantMessageComponent();
|
|
this.transcript.addChild(this.#streaming);
|
|
}
|
|
this.#streaming.updateContent(message, true);
|
|
for (const content of message.content) {
|
|
if (content.type !== "toolCall") continue;
|
|
this.#tool(content.name, content.id, content.arguments, !this.#streamingCalls.has(content.id));
|
|
this.#streamingCalls.add(content.id);
|
|
}
|
|
}
|
|
|
|
/** The card of a call; `fresh` starts a new one for a call ID an earlier turn used. */
|
|
#tool(toolName: string, toolCallId: string, args?: unknown, fresh = false): ToolExecutionComponent {
|
|
const existing = fresh ? undefined : this.#tools.get(toolCallId);
|
|
if (existing !== undefined) {
|
|
if (args !== undefined) existing.updateArgs(args);
|
|
return existing;
|
|
}
|
|
const component = new ToolExecutionComponent(
|
|
toolName,
|
|
toolCallId,
|
|
args ?? {},
|
|
{},
|
|
ExperimentalChatView.#renderers[toolName],
|
|
this.#ui,
|
|
this.#cwd,
|
|
);
|
|
this.transcript.addChild(component);
|
|
this.#cards.push(component);
|
|
this.#tools.set(toolCallId, component);
|
|
return component;
|
|
}
|
|
|
|
#addText(text: string): void {
|
|
this.transcript.addChild(new Spacer(1));
|
|
this.transcript.addChild(new Text(text, 1, 0));
|
|
}
|
|
}
|