summaryrefslogtreecommitdiffhomepage
path: root/packages/cli/src/render.ts
blob: 9f25dd3cd7560fed77a83085f4c0563164cfcd37 (plain)
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
/**
 * Pure event renderer — zero I/O.
 *
 * Maps an AgentEvent to optional stdout/stderr strings.
 * Consumers write these to process.stdout / process.stderr.
 */

import type { AgentEvent, ConversationMeta } from "@dispatch/transport-contract";

interface RenderOpts {
	readonly showReasoning: boolean;
}

interface RenderOutput {
	readonly stdout?: string;
	readonly stderr?: string;
}

export function renderEvent(e: AgentEvent, opts: RenderOpts): RenderOutput | undefined {
	switch (e.type) {
		case "text-delta":
			return { stdout: e.delta };
		case "reasoning-delta":
			return opts.showReasoning ? { stdout: e.delta } : undefined;
		case "tool-call":
			return { stdout: `\n[tool] ${e.toolName} ${JSON.stringify(e.input)}\n` };
		case "tool-output":
			return { stdout: e.data };
		case "tool-result":
			return {
				stdout: `[tool:${e.toolName}]${e.isError ? " ERROR" : ""} ${e.content}\n`,
			};
		case "usage":
			return {
				stdout: `\n[usage] in=${e.usage.inputTokens} out=${e.usage.outputTokens}\n`,
			};
		case "error":
			return { stderr: `[error] ${e.message}\n` };
		case "status":
		case "turn-start":
		case "turn-sealed":
		case "done":
			return undefined;
	}
}

/**
 * Accumulate ALL `text-delta` events' `delta` text into one string — the full
 * assistant reply assembled from a turn's event stream. Pure: input → output,
 * no I/O. Returns the empty string when the stream had no text deltas.
 */
export function extractLastText(events: readonly AgentEvent[]): string {
	let text = "";
	for (const e of events) {
		if (e.type === "text-delta") {
			text += e.delta;
		}
	}
	return text;
}

/**
 * Render an epoch-ms timestamp as a short relative phrase ("just now", "5m ago",
 * "3h ago", "2d ago") or, past a week, the `YYYY-MM-DD` date. `now` is injected
 * (clock is an outermost edge) so the function is pure and testable.
 */
export function formatRelativeTime(epochMs: number, now: number): string {
	const delta = now - epochMs;
	if (delta < 60_000) return "just now";
	const minutes = Math.floor(delta / 60_000);
	if (minutes < 60) return `${minutes}m ago`;
	const hours = Math.floor(minutes / 60);
	if (hours < 24) return `${hours}h ago`;
	const days = Math.floor(hours / 24);
	if (days < 7) return `${days}d ago`;
	return new Date(epochMs).toISOString().slice(0, 10);
}

/**
 * Format the conversation list as a table — one row per conversation:
 * `shortId | title | lastActivity (relative)`. Empty input yields the empty
 * string. `now` is injected for `formatRelativeTime` (pure + testable).
 */
export function formatConversationList(
	conversations: readonly ConversationMeta[],
	now: number,
): string {
	if (conversations.length === 0) return "";
	return conversations
		.map((c) => `${c.id.slice(0, 8)} | ${c.title} | ${formatRelativeTime(c.lastActivityAt, now)}`)
		.join("\n");
}