summaryrefslogtreecommitdiffhomepage
path: root/packages/cli/src/render.ts
blob: f512b7acec008cfbc783d4921b2509ff00df986a (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");
}