rowboat/apps/cli/src/application/lib/stream-renderer.ts

250 lines
7.5 KiB
TypeScript
Raw Normal View History

2025-10-28 13:17:06 +05:30
import { z } from "zod";
2025-11-15 01:51:22 +05:30
import { RunEvent } from "../entities/run-events.js";
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
2025-10-28 13:17:06 +05:30
export interface StreamRendererOptions {
showHeaders?: boolean;
dimReasoning?: boolean;
jsonIndent?: number;
truncateJsonAt?: number;
}
export class StreamRenderer {
private options: Required<StreamRendererOptions>;
private reasoningActive = false;
private textActive = false;
constructor(options?: StreamRendererOptions) {
this.options = {
showHeaders: true,
dimReasoning: true,
jsonIndent: 2,
truncateJsonAt: 500,
...options,
};
}
2025-11-11 12:32:46 +05:30
render(event: z.infer<typeof RunEvent>) {
2025-11-07 11:42:10 +05:30
switch (event.type) {
2025-11-11 12:32:46 +05:30
case "start": {
2025-11-16 18:21:41 +05:30
this.onStart(event.agent, event.runId, event.interactive);
2025-11-07 11:42:10 +05:30
break;
}
2025-11-11 12:32:46 +05:30
case "step-start": {
2025-11-15 01:51:22 +05:30
this.onStepStart();
2025-11-07 11:42:10 +05:30
break;
}
2025-11-11 12:32:46 +05:30
case "stream-event": {
2025-11-07 11:42:10 +05:30
this.renderLlmEvent(event.event);
break;
}
2025-11-11 12:32:46 +05:30
case "message": {
2025-11-07 11:42:10 +05:30
// this.onStepMessage(event.stepId, event.message);
break;
}
2025-11-11 12:32:46 +05:30
case "tool-invocation": {
2025-11-15 01:51:22 +05:30
this.onStepToolInvocation(event.toolName, event.input);
2025-11-07 11:42:10 +05:30
break;
}
2025-11-11 12:32:46 +05:30
case "tool-result": {
2025-11-15 01:51:22 +05:30
this.onStepToolResult(event.toolName, event.result);
2025-11-07 11:42:10 +05:30
break;
}
2025-11-11 12:32:46 +05:30
case "step-end": {
2025-11-15 01:51:22 +05:30
this.onStepEnd();
2025-11-07 11:42:10 +05:30
break;
}
2025-11-11 12:32:46 +05:30
case "end": {
2025-11-15 01:51:22 +05:30
this.onEnd();
2025-11-07 11:42:10 +05:30
break;
}
2025-11-11 12:32:46 +05:30
case "error": {
2025-11-15 01:51:22 +05:30
this.onError(event.error);
2025-11-07 11:42:10 +05:30
break;
}
}
}
private renderLlmEvent(event: z.infer<typeof LlmStepStreamEvent>) {
2025-10-28 13:17:06 +05:30
switch (event.type) {
case "reasoning-start":
this.onReasoningStart();
break;
case "reasoning-delta":
this.onReasoningDelta(event.delta);
break;
case "reasoning-end":
this.onReasoningEnd();
break;
case "text-start":
this.onTextStart();
break;
case "text-delta":
this.onTextDelta(event.delta);
break;
case "text-end":
this.onTextEnd();
break;
case "tool-call":
this.onToolCall(event.toolCallId, event.toolName, event.input);
break;
case "usage":
this.onUsage(event.usage);
break;
}
}
2025-11-16 18:21:41 +05:30
private onStart(agent: string, runId: string, interactive: boolean) {
2025-11-07 11:42:10 +05:30
this.write("\n");
2025-11-16 18:21:41 +05:30
this.write(this.bold(`▶ Agent ${agent} (run ${runId})`));
2025-11-11 12:32:46 +05:30
if (!interactive) this.write(this.dim(" (--no-interactive)"));
2025-11-07 11:42:10 +05:30
this.write("\n");
}
2025-11-15 01:51:22 +05:30
private onEnd() {
2025-11-16 18:21:41 +05:30
this.write(this.bold("\n■ complete\n"));
2025-11-07 11:42:10 +05:30
}
2025-11-15 01:51:22 +05:30
private onError(error: string) {
2025-11-16 18:21:41 +05:30
this.write(this.red(`\n✖ error: ${error}\n`));
2025-11-07 11:42:10 +05:30
}
2025-11-15 01:51:22 +05:30
private onStepStart() {
2025-11-07 11:42:10 +05:30
this.write("\n");
2025-11-15 01:51:22 +05:30
this.write(this.cyan(`─ Step started`));
2025-11-07 11:42:10 +05:30
this.write("\n");
}
2025-11-15 01:51:22 +05:30
private onStepEnd() {
this.write(this.dim(`✓ Step finished\n`));
2025-11-07 11:42:10 +05:30
}
2025-11-11 12:32:46 +05:30
private onStepMessage(stepIndex: number, message: any) {
2025-11-07 11:42:10 +05:30
const role = message?.role ?? "message";
const content = message?.content;
this.write(this.bold(`${role}: `));
if (typeof content === "string") {
this.write(content + "\n");
} else {
const pretty = this.truncate(JSON.stringify(message, null, this.options.jsonIndent));
this.write(this.dim("\n" + this.indent(pretty) + "\n"));
}
}
2025-11-15 01:51:22 +05:30
private onStepToolInvocation(toolName: string, input: string) {
2025-11-07 11:42:10 +05:30
this.write(this.cyan(`\n→ Tool invoke ${toolName}`));
if (input && input.length) {
this.write("\n" + this.dim(this.indent(this.truncate(input))) + "\n");
} else {
this.write("\n");
}
}
2025-11-15 01:51:22 +05:30
private onStepToolResult(toolName: string, result: unknown) {
2025-11-07 11:42:10 +05:30
const res = this.truncate(JSON.stringify(result, null, this.options.jsonIndent));
this.write(this.cyan(`\n← Tool result ${toolName}\n`));
this.write(this.dim(this.indent(res)) + "\n");
}
2025-10-28 13:17:06 +05:30
private onReasoningStart() {
if (this.reasoningActive) return;
this.reasoningActive = true;
if (this.options.showHeaders) {
this.write("\n");
this.write(this.dim("Reasoning: "));
}
}
private onReasoningDelta(delta: string) {
if (!this.reasoningActive) this.onReasoningStart();
this.write(this.options.dimReasoning ? this.dim(delta) : delta);
}
private onReasoningEnd() {
if (!this.reasoningActive) return;
this.reasoningActive = false;
this.write(this.dim("\n"));
}
private onTextStart() {
if (this.textActive) return;
this.textActive = true;
if (this.options.showHeaders) {
this.write("\n");
this.write(this.bold("Assistant: "));
}
}
private onTextDelta(delta: string) {
if (!this.textActive) this.onTextStart();
this.write(delta);
}
private onTextEnd() {
if (!this.textActive) return;
this.textActive = false;
this.write("\n");
}
private onToolCall(toolCallId: string, toolName: string, input: unknown) {
const inputStr = this.truncate(JSON.stringify(input, null, this.options.jsonIndent));
this.write("\n");
this.write(this.cyan(`→ Tool call ${toolName} (${toolCallId})`));
this.write("\n");
this.write(this.dim(this.indent(inputStr)));
this.write("\n");
}
private onUsage(usage: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
cachedInputTokens?: number;
}) {
const parts: string[] = [];
if (usage.inputTokens !== undefined) parts.push(`input=${usage.inputTokens}`);
if (usage.outputTokens !== undefined) parts.push(`output=${usage.outputTokens}`);
if (usage.reasoningTokens !== undefined) parts.push(`reasoning=${usage.reasoningTokens}`);
if (usage.cachedInputTokens !== undefined) parts.push(`cached=${usage.cachedInputTokens}`);
if (usage.totalTokens !== undefined) parts.push(`total=${usage.totalTokens}`);
const line = parts.join(", ");
this.write(this.dim(`\nUsage: ${line}\n`));
}
// Formatting helpers
private write(text: string) {
process.stdout.write(text);
}
private indent(text: string): string {
return text
.split("\n")
.map((line) => (line.length ? ` ${line}` : line))
.join("\n");
}
private truncate(text: string): string {
if (text.length <= this.options.truncateJsonAt) return text;
return text.slice(0, this.options.truncateJsonAt) + "…";
}
private bold(text: string): string {
return "\x1b[1m" + text + "\x1b[0m";
}
private dim(text: string): string {
return "\x1b[2m" + text + "\x1b[0m";
}
private cyan(text: string): string {
return "\x1b[36m" + text + "\x1b[0m";
}
2025-11-07 11:42:10 +05:30
private red(text: string): string {
return "\x1b[31m" + text + "\x1b[0m";
}
2025-10-28 13:17:06 +05:30
}