File size: 6,803 Bytes
f500658 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | import type { AssistantMessage } from "@earendil-works/pi-ai";
import { Container, Markdown, type MarkdownTheme, MouseRegion, Spacer, Text } from "@earendil-works/pi-tui";
import type { MarkdownTransformer } from "../../../core/extensions/types.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
import { createMarkdownTransform } from "./markdown-transform.ts";
const OSC133_ZONE_START = "\x1b]133;A\x07";
const OSC133_ZONE_END = "\x1b]133;B\x07";
const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
/**
* Component that renders a complete assistant message
*/
export class AssistantMessageComponent extends Container {
private contentContainer: Container;
private hideThinkingBlock: boolean;
private markdownTheme: MarkdownTheme;
private hiddenThinkingLabel: string;
private outputPad: number;
private markdownTransformers: readonly MarkdownTransformer[];
private lastMessage?: AssistantMessage;
private hasToolCalls = false;
private isStreaming = false;
private thinkingVisibilityOverrides = new Map<number, boolean>();
constructor(
message?: AssistantMessage,
hideThinkingBlock = false,
markdownTheme: MarkdownTheme = getMarkdownTheme(),
hiddenThinkingLabel = "Thinking...",
outputPad = 1,
markdownTransformers: readonly MarkdownTransformer[] = [],
) {
super();
this.hideThinkingBlock = hideThinkingBlock;
this.markdownTheme = markdownTheme;
this.hiddenThinkingLabel = hiddenThinkingLabel;
this.outputPad = outputPad;
this.markdownTransformers = markdownTransformers;
// 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;
this.thinkingVisibilityOverrides.clear();
if (this.lastMessage) {
this.updateContent(this.lastMessage);
}
}
setHiddenThinkingLabel(label: string): void {
this.hiddenThinkingLabel = label;
if (this.lastMessage) {
this.updateContent(this.lastMessage);
}
}
setOutputPad(padding: number): void {
this.outputPad = padding;
if (this.lastMessage) {
this.updateContent(this.lastMessage);
}
}
override render(width: number): string[] {
const lines = super.render(width);
if (this.hasToolCalls || lines.length === 0) {
return lines;
}
lines[0] = OSC133_ZONE_START + lines[0];
lines[lines.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + lines[lines.length - 1];
return lines;
}
updateContent(message: AssistantMessage, isStreaming = this.isStreaming): void {
this.lastMessage = message;
this.isStreaming = isStreaming;
// 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
let thinkingRunIndex = 0;
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(), this.outputPad, 0, this.markdownTheme, undefined, {
transform: createMarkdownTransform("assistant", this.isStreaming, this.markdownTransformers),
}),
);
} else if (content.type === "thinking") {
const thinkingBlocks: string[] = [];
for (; i < message.content.length; i++) {
const thinkingContent = message.content[i];
if (thinkingContent.type !== "thinking") {
break;
}
const thinking = thinkingContent.thinking.trim();
if (thinking) {
thinkingBlocks.push(thinking);
}
}
i--;
if (thinkingBlocks.length === 0) {
continue;
}
// 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()));
const runIndex = thinkingRunIndex++;
const hidden = this.thinkingVisibilityOverrides.get(runIndex) ?? this.hideThinkingBlock;
const thinkingComponent = hidden
? new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), this.outputPad, 0)
: new Markdown(
thinkingBlocks.join("\n\n"),
this.outputPad,
0,
this.markdownTheme,
{
color: (text: string) => theme.fg("thinkingText", text),
italic: true,
},
{
transform: createMarkdownTransform(
"assistant-thinking",
this.isStreaming,
this.markdownTransformers,
),
},
);
this.contentContainer.addChild(
new MouseRegion(thinkingComponent, (event) => {
if (event.type !== "click" || event.button !== "left") return undefined;
this.thinkingVisibilityOverrides.set(runIndex, !hidden);
if (this.lastMessage) this.updateContent(this.lastMessage);
return { handled: true };
}),
);
if (hasVisibleContentAfter) {
this.contentContainer.addChild(new Spacer(1));
}
}
}
// Check if incomplete/failed - show after partial content.
// For aborted/error tool calls, tool execution components show the error.
// Length stops can happen before a tool call is complete, so surface them here too.
const hasToolCalls = message.content.some((c) => c.type === "toolCall");
this.hasToolCalls = hasToolCalls;
if (message.stopReason === "length") {
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(
new Text(theme.fg("error", "Response was truncated before completion."), this.outputPad, 0),
);
} else if (!hasToolCalls) {
if (message.stopReason === "aborted") {
const abortMessage =
message.errorMessage && message.errorMessage !== "Request was aborted"
? message.errorMessage
: "Operation aborted";
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), this.outputPad, 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}`), this.outputPad, 0));
}
}
}
}
|