Files
oh-my-pi/packages/coding-agent/src/modes/components/assistant-message.ts
T

230 lines
7.8 KiB
TypeScript

import type { AssistantMessage, ImageContent, Usage } from "@oh-my-pi/pi-ai";
import { Container, Image, ImageProtocol, Markdown, Spacer, TERMINAL, Text } from "@oh-my-pi/pi-tui";
import { formatNumber } from "@oh-my-pi/pi-utils";
import { settings } from "../../config/settings";
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
import { isSilentAbort } from "../../session/messages";
import { resolveImageOptions } from "../../tools/render-utils";
/**
* Component that renders a complete assistant message
*/
export class AssistantMessageComponent extends Container {
#contentContainer: Container;
#lastMessage?: AssistantMessage;
#toolImagesByCallId = new Map<string, ImageContent[]>();
#usageInfo?: Usage;
#convertedKittyImages = new Map<string, ImageContent>();
#kittyConversionsInFlight = new Set<string>();
constructor(
message?: AssistantMessage,
private hideThinkingBlock = false,
private readonly onImageUpdate?: () => void,
) {
super();
// Container for text/thinking content
this.#contentContainer = new Container();
this.addChild(this.#contentContainer);
if (message) {
this.updateContent(message);
}
}
override invalidate(): void {
super.invalidate();
if (this.#lastMessage) {
this.updateContent(this.#lastMessage);
}
}
setHideThinkingBlock(hide: boolean): void {
this.hideThinkingBlock = hide;
}
setToolResultImages(toolCallId: string, images: ImageContent[]): void {
if (!toolCallId) return;
const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType);
for (const key of Array.from(this.#convertedKittyImages.keys())) {
if (key.startsWith(`${toolCallId}:`)) {
this.#convertedKittyImages.delete(key);
}
}
for (const key of Array.from(this.#kittyConversionsInFlight)) {
if (key.startsWith(`${toolCallId}:`)) {
this.#kittyConversionsInFlight.delete(key);
}
}
if (validImages.length === 0) {
this.#toolImagesByCallId.delete(toolCallId);
} else {
this.#toolImagesByCallId.set(toolCallId, validImages);
this.#convertToolImagesForKitty(toolCallId, validImages);
}
if (this.#lastMessage) {
this.updateContent(this.#lastMessage);
}
}
#convertToolImagesForKitty(toolCallId: string, images: ImageContent[]): void {
if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
for (let index = 0; index < images.length; index++) {
const image = images[index];
if (!image || image.mimeType === "image/png") continue;
const key = `${toolCallId}:${index}`;
if (this.#convertedKittyImages.has(key) || this.#kittyConversionsInFlight.has(key)) continue;
this.#kittyConversionsInFlight.add(key);
new Bun.Image(Buffer.from(image.data, "base64"))
.png()
.toBase64()
.then(data => {
this.#kittyConversionsInFlight.delete(key);
this.#convertedKittyImages.set(key, {
type: "image",
data,
mimeType: "image/png",
});
if (this.#lastMessage) {
this.updateContent(this.#lastMessage);
}
this.onImageUpdate?.();
})
.catch(() => {
this.#kittyConversionsInFlight.delete(key);
});
}
}
setUsageInfo(usage: Usage): void {
this.#usageInfo = usage;
if (this.#lastMessage) {
this.updateContent(this.#lastMessage);
}
}
#renderToolImages(): void {
const imageEntries = Array.from(this.#toolImagesByCallId.entries()).flatMap(([toolCallId, images]) =>
images.map((image, index) => ({ image, key: `${toolCallId}:${index}` })),
);
if (imageEntries.length === 0) return;
this.#contentContainer.addChild(new Spacer(1));
for (const { image, key } of imageEntries) {
const displayImage =
TERMINAL.imageProtocol === ImageProtocol.Kitty && image.mimeType !== "image/png"
? this.#convertedKittyImages.get(key)
: image;
if (TERMINAL.imageProtocol && displayImage) {
this.#contentContainer.addChild(
new Image(
displayImage.data,
displayImage.mimeType,
{ fallbackColor: (text: string) => theme.fg("toolOutput", text) },
resolveImageOptions(),
),
);
continue;
}
this.#contentContainer.addChild(new Text(theme.fg("toolOutput", `[Image: ${image.mimeType}]`), 1, 0));
}
}
updateContent(message: AssistantMessage): void {
this.#lastMessage = message;
// Clear content container
this.#contentContainer.clear();
const hasVisibleContent = message.content.some(
c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
);
if (hasVisibleContent) {
this.#contentContainer.addChild(new Spacer(1));
}
// Render content in order
for (let i = 0; i < message.content.length; i++) {
const content = message.content[i];
if (content.type === "text" && content.text.trim()) {
// Assistant text messages with no background - trim the text
// Set paddingY=0 to avoid extra spacing before tool executions
this.#contentContainer.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme()));
} else if (content.type === "thinking" && content.thinking.trim()) {
// Add spacing only when another visible assistant content block follows.
// This avoids a superfluous blank line before separately-rendered tool execution blocks.
const hasVisibleContentAfter = message.content
.slice(i + 1)
.some(c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()));
if (this.hideThinkingBlock) {
// Show static "Thinking..." label when hidden
this.#contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0));
if (hasVisibleContentAfter) {
this.#contentContainer.addChild(new Spacer(1));
}
} else {
// Thinking traces in thinkingText color, italic
this.#contentContainer.addChild(
new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("thinkingText", text),
italic: true,
}),
);
if (hasVisibleContentAfter) {
this.#contentContainer.addChild(new Spacer(1));
}
}
}
}
this.#renderToolImages();
// Check if aborted - show after partial content
// But only if there are no tool calls (tool execution components will show the error)
const hasToolCalls = message.content.some(c => c.type === "toolCall");
if (!hasToolCalls) {
if (message.stopReason === "aborted" && !isSilentAbort(message.errorMessage)) {
const abortMessage =
message.errorMessage && message.errorMessage !== "Request was aborted"
? message.errorMessage
: "Operation aborted";
if (hasVisibleContent) {
this.#contentContainer.addChild(new Spacer(1));
} else {
this.#contentContainer.addChild(new Spacer(1));
}
this.#contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
} else if (message.stopReason === "error") {
const errorMsg = message.errorMessage || "Unknown error";
this.#contentContainer.addChild(new Spacer(1));
this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
}
}
if (
message.errorMessage &&
!isSilentAbort(message.errorMessage) &&
message.stopReason !== "aborted" &&
message.stopReason !== "error"
) {
this.#contentContainer.addChild(new Spacer(1));
this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${message.errorMessage}`), 1, 0));
}
// Token usage metadata
if (settings.get("display.showTokenUsage") && this.#usageInfo) {
const usage = this.#usageInfo;
const totalInput = usage.input + usage.cacheWrite;
const parts: string[] = [];
parts.push(`${theme.icon.input} ${formatNumber(totalInput)}`);
parts.push(`${theme.icon.output} ${formatNumber(usage.output)}`);
if (usage.cacheRead > 0) {
parts.push(`cache: ${formatNumber(usage.cacheRead)}`);
}
this.#contentContainer.addChild(new Spacer(1));
this.#contentContainer.addChild(new Text(theme.fg("dim", parts.join(" ")), 1, 0));
}
}
}