@a-t-h-i/bot-lobby 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +238 -10
  2. package/package.json +5 -2
  3. package/prompts/master.md +12 -0
  4. package/prompts/panel.md +44 -0
  5. package/prompts/planner.md +69 -0
  6. package/prompts/quickfix.md +41 -0
  7. package/src/execution/agent-runner.ts +7 -2
  8. package/src/execution/pi-runner.ts +25 -0
  9. package/src/index.ts +3 -0
  10. package/src/lobby/ask.ts +226 -0
  11. package/src/lobby/feed.ts +253 -0
  12. package/src/lobby/issues.ts +227 -0
  13. package/src/lobby/keys.ts +51 -0
  14. package/src/lobby/layout.ts +363 -0
  15. package/src/lobby/markdown.ts +48 -0
  16. package/src/lobby/planner.ts +581 -0
  17. package/src/lobby/quickfix.ts +227 -0
  18. package/src/lobby/runtime.ts +591 -0
  19. package/src/lobby/tabs/home.ts +242 -0
  20. package/src/lobby/tabs/issues.ts +72 -0
  21. package/src/lobby/tabs/metrics.ts +275 -0
  22. package/src/lobby/tabs/plan.ts +242 -0
  23. package/src/lobby/tabs/quickfix.ts +129 -0
  24. package/src/lobby/tabs/tasks.ts +243 -0
  25. package/src/lobby/view.ts +1610 -0
  26. package/src/pi/activity.ts +120 -0
  27. package/src/pi/commands.ts +19 -57
  28. package/src/pi/events.ts +5 -2
  29. package/src/pi/model-support.ts +34 -0
  30. package/src/pi/run-summary.ts +2 -0
  31. package/src/pi/settings-ui.ts +69 -1
  32. package/src/pi/start-task.ts +63 -0
  33. package/src/pi/tools.ts +2 -2
  34. package/src/pi/ui.ts +116 -55
  35. package/src/schemas/configuration.ts +102 -3
  36. package/src/schemas/findings.ts +6 -0
  37. package/src/schemas/task.ts +1 -0
  38. package/src/state/backlog.ts +106 -0
  39. package/src/state/comments.ts +136 -0
  40. package/src/state/metrics.ts +305 -0
  41. package/src/workflow/workflow.ts +39 -7
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The Lobby tab: the zen scene of this session's task on top, then the
3
+ * conversation with the oracle (text only, no tool rows, no thinking), the
4
+ * activity log of plain-words steps from every agent, and the one place where
5
+ * thoughts show up. Pure: the scene arrives as a callback, the clock as `now`.
6
+ */
7
+ import type { Task } from "../../schemas/task.ts";
8
+ import type { ActivityEntry, ChatEntry, ThoughtEntry } from "../feed.ts";
9
+ import type { LobbyPanel } from "../../schemas/configuration.ts";
10
+ import { beside, bold, box, clock, fill, italic, markdownHanging, notePane, paint, since, spinner, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
11
+
12
+ /** The Lobby tab's scrollable panes. */
13
+ export const HOME_PANES = ["conversation", "activity", "thinking"] as const;
14
+ export type HomePane = (typeof HOME_PANES)[number];
15
+
16
+ export interface HomeInput {
17
+ task?: Task;
18
+ /** Draws the zen scene into at most `height` lines; absent when no task is active. */
19
+ scene?: (width: number, height: number) => string[];
20
+ chat: readonly ChatEntry[];
21
+ /** The oracle's reply while it streams. */
22
+ liveReply?: string;
23
+ activity: readonly ActivityEntry[];
24
+ thoughts: readonly ThoughtEntry[];
25
+ /** The Master's turn is running. */
26
+ busy: boolean;
27
+ /** Active tasks owned by other sessions, and saved plans waiting to start. */
28
+ others: number;
29
+ pending: number;
30
+ /** How far each pane is scrolled back, in lines from its newest; 0 follows the newest. */
31
+ offsets?: Partial<Record<HomePane, number>>;
32
+ /** The pane the arrow keys scroll; its border lights up. */
33
+ focus?: HomePane;
34
+ /** Filled with where each pane landed and how much it holds. */
35
+ panes?: PaneLayout;
36
+ tick: number;
37
+ now: number;
38
+ /** Which panes show. */
39
+ panels: Record<LobbyPanel, boolean>;
40
+ /** The search in force: panes show only matching entries. */
41
+ query?: string;
42
+ /** Key labels for the pane toggles, shown on the panes. */
43
+ keys?: Record<LobbyPanel, string>;
44
+ }
45
+
46
+ /** Wide terminals put the conversation and the activity log side by side. */
47
+ export const HOME_COLUMNS_MIN = 100;
48
+ /** Most lines the scene may take, and its share of the body. */
49
+ const SCENE_SHARE = 0.45;
50
+ const MAX_SCENE = 36;
51
+
52
+ const SOURCE_COLORS: Record<string, LobbyColor> = {
53
+ MASTER: "accent",
54
+ DEV: "success",
55
+ DESIGN: "mdHeading",
56
+ QA: "warning",
57
+ RESEARCH: "toolTitle",
58
+ "QUICK FIX": "mdCode",
59
+ ORACLE: "accent",
60
+ LOBBY: "muted",
61
+ };
62
+
63
+ export function sourceColor(source: string): LobbyColor {
64
+ return SOURCE_COLORS[source] ?? "muted";
65
+ }
66
+
67
+ const SOURCE_WIDTH = 9;
68
+
69
+ /** One activity line: time, who, what; a running step spins, a finished one is marked. */
70
+ export function activityLine(entry: ActivityEntry, width: number, tick: number, theme?: LobbyTheme): string {
71
+ const who = paint(theme, sourceColor(entry.source), entry.source.padEnd(SOURCE_WIDTH).slice(0, SOURCE_WIDTH));
72
+ const mark = entry.pending
73
+ ? paint(theme, "accent", spinner(tick))
74
+ : entry.kind === "error"
75
+ ? paint(theme, "error", "✗")
76
+ : entry.kind === "warning"
77
+ ? paint(theme, "warning", "!")
78
+ : entry.kind === "success"
79
+ ? paint(theme, "success", "✓")
80
+ : paint(theme, "dim", "·");
81
+ const text = entry.pending ? `${entry.text}…` : entry.text;
82
+ const body = entry.kind === "error" ? paint(theme, "error", text) : entry.pending ? text : paint(theme, "muted", text);
83
+ return `${paint(theme, "dim", clock(entry.at))} ${who} ${mark} ${body}`;
84
+ }
85
+
86
+ function chatLead(role: ChatEntry["role"], theme?: LobbyTheme): string {
87
+ if (role === "you") return `${bold(theme, paint(theme, "accent", "you"))} ${paint(theme, "dim", "▸")} `;
88
+ if (role === "oracle") return `${bold(theme, paint(theme, "toolTitle", "oracle"))} ${paint(theme, "dim", "▸")} `;
89
+ return `${paint(theme, "dim", "·")} `;
90
+ }
91
+
92
+ /** The conversation as wrapped lines, oldest first, a blank line between turns. */
93
+ export function chatLines(chat: readonly ChatEntry[], width: number, theme?: LobbyTheme, live?: string, busy = false, tick = 0): string[] {
94
+ const lines: string[] = [];
95
+ const push = (role: ChatEntry["role"], text: string) => {
96
+ if (lines.length > 0) lines.push("");
97
+ // The oracle writes Markdown; what you typed and notes are shown as written.
98
+ if (role === "oracle") lines.push(...markdownHanging(chatLead(role, theme), text, width, theme));
99
+ else lines.push(...wrapHanging(chatLead(role, theme), role === "note" ? paint(theme, "dim", text) : text, width));
100
+ };
101
+ for (const entry of chat) push(entry.role, entry.text);
102
+ if (live?.trim()) {
103
+ push("oracle", live.trim());
104
+ lines[lines.length - 1] = `${lines.at(-1)!.trimEnd()} ${paint(theme, "accent", spinner(tick))}`;
105
+ } else if (busy) {
106
+ if (lines.length > 0) lines.push("");
107
+ lines.push(...wrapHanging(chatLead("oracle", theme), paint(theme, "dim", `${spinner(tick)} working…`), width));
108
+ }
109
+ return lines;
110
+ }
111
+
112
+ function emptyChat(input: HomeInput, width: number, theme?: LobbyTheme): string[] {
113
+ if (input.query) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${input.query}".`), width);
114
+ const lines = input.task
115
+ ? [paint(theme, "dim", "Nothing said yet. Type below to talk to the oracle about this task.")]
116
+ : [
117
+ bold(theme, "No task is running in this session."),
118
+ "",
119
+ "Type a request below and press enter to start one: the oracle scouts, proposes, plans and delegates.",
120
+ `Or plan it first with the whole planning panel in ${bold(theme, "3 Plan")}, or make a direct change in ${bold(theme, "4 Quick fix")}.`,
121
+ ];
122
+ const extra: string[] = [];
123
+ if (input.others > 0) extra.push(`${input.others} task${input.others === 1 ? " is" : "s are"} running in other sessions — see ${bold(theme, "2 Tasks")}.`);
124
+ if (input.pending > 0) extra.push(`${input.pending} planned task${input.pending === 1 ? "" : "s"} waiting to start — see ${bold(theme, "2 Tasks")}.`);
125
+ return [...lines.flatMap((line) => wrap(line, width)), ...(extra.length > 0 ? ["", ...extra.flatMap((line) => wrap(paint(theme, "muted", line), width))] : [])];
126
+ }
127
+
128
+ function matches(text: string, query: string | undefined): boolean {
129
+ return !query || text.toLowerCase().includes(query.toLowerCase());
130
+ }
131
+
132
+ /** The feed as the search narrows it: only entries whose text (or source) contains the query. */
133
+ export function filterFeed(input: Pick<HomeInput, "chat" | "activity" | "thoughts" | "query">): { chat: ChatEntry[]; activity: ActivityEntry[]; thoughts: ThoughtEntry[] } {
134
+ const query = input.query?.trim();
135
+ return {
136
+ chat: input.chat.filter((entry) => matches(entry.text, query)),
137
+ activity: input.activity.filter((entry) => matches(`${entry.source} ${entry.text}`, query)),
138
+ thoughts: input.thoughts.filter((entry) => matches(`${entry.source} ${entry.text}`, query)),
139
+ };
140
+ }
141
+
142
+ function conversation(input: HomeInput, chat: readonly ChatEntry[], width: number, theme?: LobbyTheme): string[] {
143
+ const searching = Boolean(input.query);
144
+ const live = searching ? undefined : input.liveReply;
145
+ const busy = !searching && input.busy;
146
+ return chat.length > 0 || live || busy ? chatLines(chat, width, theme, live, busy, input.tick) : emptyChat(input, width, theme);
147
+ }
148
+
149
+ function activity(input: HomeInput, entries: readonly ActivityEntry[], width: number, theme?: LobbyTheme): string[] {
150
+ if (entries.length === 0) return [paint(theme, "dim", input.query ? `No activity matches "${input.query}".` : "No activity yet.")];
151
+ return entries.map((entry) => activityLine(entry, width, input.tick, theme));
152
+ }
153
+
154
+ /** The last `rows` lines, `offset` lines back from the newest (clamped), and where they start. */
155
+ export function tailWindow(lines: readonly string[], rows: number, offset = 0): { shown: string[]; start: number; offset: number } {
156
+ const max = Math.max(0, lines.length - rows);
157
+ const back = Math.min(Math.max(0, offset), max);
158
+ const start = max - back;
159
+ return { shown: lines.slice(start, start + Math.max(0, rows)), start, offset: back };
160
+ }
161
+
162
+ /** The thought to show: a live one first, otherwise the newest. */
163
+ export function currentThought(thoughts: readonly ThoughtEntry[]): ThoughtEntry | undefined {
164
+ return [...thoughts].reverse().find((entry) => entry.live) ?? thoughts.at(-1);
165
+ }
166
+
167
+ /** Every thought, oldest first: who thought it, then the thought, dimmed. */
168
+ export function thoughtLines(thoughts: readonly ThoughtEntry[], width: number, theme?: LobbyTheme): string[] {
169
+ return thoughts.flatMap((thought) => {
170
+ const lead = `${paint(theme, sourceColor(thought.source), thought.source.padEnd(SOURCE_WIDTH).slice(0, SOURCE_WIDTH))} `;
171
+ return wrapHanging(lead, italic(theme, paint(theme, "dim", thought.text.replace(/\s*\n\s*/g, " "))), width);
172
+ });
173
+ }
174
+
175
+ function thinkingContent(input: HomeInput, thoughts: readonly ThoughtEntry[], width: number, theme?: LobbyTheme): string[] {
176
+ if (thoughts.length > 0) return thoughtLines(thoughts, width, theme);
177
+ return [paint(theme, "dim", input.query ? `No thought matches "${input.query}".` : "Thoughts from the oracle and every agent appear here, and only here.")];
178
+ }
179
+
180
+ function sceneLines(input: HomeInput, width: number, height: number): string[] {
181
+ if (!input.task || !input.scene || !input.panels.scene) return [];
182
+ const budget = Math.min(MAX_SCENE, Math.floor(height * SCENE_SHARE));
183
+ if (budget < 6) return [];
184
+ return input.scene(width, budget).slice(0, budget);
185
+ }
186
+
187
+ function hiddenHint(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
188
+ const keys = input.keys;
189
+ const text = keys
190
+ ? `Every pane is hidden — ${keys.conversation} conversation · ${keys.activity} activity · ${keys.thinking} thinking${input.task ? ` · ${keys.scene} scene` : ""}`
191
+ : "Every pane is hidden.";
192
+ const lines = wrap(paint(theme, "dim", text), width);
193
+ const top = Math.max(0, Math.floor((height - lines.length) / 2));
194
+ return fill([...Array.from({ length: top }, () => ""), ...lines], height, width);
195
+ }
196
+
197
+ /** `↓12` when a pane is scrolled back from its newest line, before the pane's usual note. */
198
+ function rightNote(offset: number, note: string | undefined): string | undefined {
199
+ const parts = [offset > 0 ? `↓${offset}` : "", note ?? ""].filter(Boolean);
200
+ return parts.length > 0 ? parts.join(" · ") : undefined;
201
+ }
202
+
203
+ /** The whole tab, exactly `height` lines of `width` columns; hidden panes give their room to the rest. */
204
+ export function renderHome(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
205
+ if (height <= 0) return [];
206
+ const { panels } = input;
207
+ const feed = filterFeed(input);
208
+ const scene = sceneLines(input, width, height);
209
+ const rest = height - scene.length;
210
+ const showMain = panels.conversation || panels.activity;
211
+ const thinkHeight = !panels.thinking ? 0 : !showMain ? rest : rest >= 18 ? Math.max(5, Math.floor(rest * 0.25)) : rest >= 10 ? 4 : 0;
212
+ const main = rest - thinkHeight;
213
+ if (!showMain && !panels.thinking) return fill([...scene, ...hiddenHint(input, width, rest, theme)], height, width);
214
+ const matches = (count: number) => `${count} match${count === 1 ? "" : "es"}`;
215
+ /** One scrollable pane: its lines, tailed to its rows and scrolled back by its offset, in a box that records where it sits. */
216
+ const pane = (name: HomePane, title: string, note: string | undefined, lines: (inner: number) => string[]) => (top: number, left: number, w: number, h: number): string[] => {
217
+ const all = lines(Math.max(1, w - 4));
218
+ const view = tailWindow(all, h - 2, input.offsets?.[name] ?? 0);
219
+ notePane(input.panes, name, top, left, w, h, all.length);
220
+ const right = rightNote(view.offset, note);
221
+ return box(w, h, view.shown, { title, ...(right ? { right } : {}), focused: input.focus === name, scroll: { total: all.length, start: view.start }, theme });
222
+ };
223
+ const chatBox = pane("conversation", input.task ? `Conversation · ${input.task.id}` : "Conversation", input.query ? matches(feed.chat.length) : input.keys?.conversation, (inner) => conversation(input, feed.chat, inner, theme));
224
+ const activityBox = pane("activity", "Activity", input.query ? matches(feed.activity.length) : input.keys?.activity, (inner) => activity(input, feed.activity, inner, theme));
225
+ const thought = currentThought(feed.thoughts);
226
+ const thinkingNote = thought ? `${thought.source} · ${thought.live ? "thinking" : since(input.now - thought.at)}` : input.keys?.thinking;
227
+ const thinkingBox = pane("thinking", "Thinking", thinkingNote, (inner) => thinkingContent(input, feed.thoughts, inner, theme));
228
+ const top = scene.length;
229
+ let body: string[] = [];
230
+ if (main > 0 && panels.conversation && panels.activity) {
231
+ if (width >= HOME_COLUMNS_MIN) {
232
+ const left = Math.round((width - 1) * 0.56);
233
+ body = beside([chatBox(top, 0, left, main), activityBox(top, left + 1, width - 1 - left, main)]);
234
+ } else {
235
+ const chatHeight = Math.max(3, Math.ceil(main * 0.6));
236
+ body = [...chatBox(top, 0, width, chatHeight), ...(main - chatHeight >= 3 ? activityBox(top + chatHeight, 0, width, main - chatHeight) : [])];
237
+ }
238
+ } else if (main > 0 && panels.conversation) body = chatBox(top, 0, width, main);
239
+ else if (main > 0 && panels.activity) body = activityBox(top, 0, width, main);
240
+ const think = thinkHeight >= 3 ? thinkingBox(top + main, 0, width, thinkHeight) : [];
241
+ return fill([...scene, ...fill(body, main), ...think], height, width);
242
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The Issues tab: the repository's open GitHub issues through `gh`, the
3
+ * selected issue with its comments, and the way into planning it (p) before it
4
+ * becomes a task.
5
+ */
6
+ import type { IssueDetail, IssueSummary } from "../issues.ts";
7
+ import { bold, since, columns, fill, paint, rule, selectRow, spinner, split, windowStart, wrap, type LobbyTheme } from "../layout.ts";
8
+
9
+ export interface IssuesTabInput {
10
+ issues: readonly IssueSummary[];
11
+ selected: number;
12
+ detail?: IssueDetail;
13
+ focus: "list" | "detail";
14
+ detailOffset: number;
15
+ loading: boolean;
16
+ loaded: boolean;
17
+ error?: string;
18
+ notice?: string;
19
+ tick: number;
20
+ now: number;
21
+ }
22
+
23
+ export const ISSUES_COLUMNS_MIN = 90;
24
+
25
+ export function issueDetailLines(issue: IssueDetail, width: number, now: number, theme?: LobbyTheme): string[] {
26
+ const facts = [
27
+ issue.state ? paint(theme, issue.state === "OPEN" ? "success" : "dim", issue.state.toLowerCase()) : "",
28
+ issue.author ? `by ${issue.author}` : "",
29
+ issue.updatedAt ? `updated ${since(now - Date.parse(issue.updatedAt))}` : "",
30
+ issue.labels.length > 0 ? paint(theme, "warning", issue.labels.join(", ")) : "",
31
+ ].filter(Boolean);
32
+ const lines = [...wrap(bold(theme, `#${issue.number} ${issue.title}`), width), facts.join(paint(theme, "dim", " · "))];
33
+ if (issue.url) lines.push(paint(theme, "dim", issue.url));
34
+ lines.push("", paint(theme, "dim", "p plans it with the planning panel, then save it as a task"), "", ...wrap(issue.body.trim() || paint(theme, "dim", "(no description)"), width));
35
+ for (const comment of issue.comments) {
36
+ lines.push("", rule(width, comment.author ?? "comment", theme, comment.createdAt ? since(now - Date.parse(comment.createdAt)) : ""), ...wrap(comment.body.trim(), width));
37
+ }
38
+ return lines;
39
+ }
40
+
41
+ function empty(input: IssuesTabInput, width: number, theme?: LobbyTheme): string[] {
42
+ if (input.loading) return [`${paint(theme, "accent", spinner(input.tick))} loading issues from GitHub…`];
43
+ if (input.error) return [];
44
+ if (!input.loaded) return wrap(paint(theme, "dim", "Press r to load open issues with the GitHub CLI (gh)."), width);
45
+ return wrap(paint(theme, "dim", "No open issues. Press n to file one."), width);
46
+ }
47
+
48
+ export function renderIssues(input: IssuesTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
49
+ if (height <= 0) return [];
50
+ const banner = [
51
+ ...(input.error ? wrap(paint(theme, "error", `✗ ${input.error}`), width) : []),
52
+ ...(input.notice ? wrap(paint(theme, "success", input.notice), width) : []),
53
+ ];
54
+ const bodyHeight = Math.max(0, height - banner.length);
55
+ if (input.issues.length === 0) return fill([...banner, rule(width, "Issues", theme), ...empty(input, width, theme)], height, width);
56
+ const selected = Math.min(Math.max(0, input.selected), input.issues.length - 1);
57
+ const wide = width >= ISSUES_COLUMNS_MIN;
58
+ const [listWidth, detailWidth] = wide ? split(width, 0.4, 3, 40) : [width, width];
59
+ const rows = input.issues.map((issue, index) => {
60
+ const labels = issue.labels.length > 0 ? ` ${paint(theme, "warning", `[${issue.labels.slice(0, 2).join(", ")}]`)}` : "";
61
+ const title = index === selected ? bold(theme, issue.title) : issue.title;
62
+ return selectRow(theme, `${paint(theme, "dim", `#${issue.number}`)} ${title}${labels}`, listWidth, index === selected, input.focus === "list");
63
+ });
64
+ const right = input.loading ? `${spinner(input.tick)} refreshing` : `${input.issues.length} open`;
65
+ const listPane = fill([rule(listWidth, "Issues", theme, right), ...rows.slice(windowStart(selected, rows.length, bodyHeight - 1))], bodyHeight);
66
+ const detail = input.detail && input.detail.number === input.issues[selected]!.number
67
+ ? issueDetailLines(input.detail, detailWidth, input.now, theme)
68
+ : [`${paint(theme, "accent", spinner(input.tick))} loading #${input.issues[selected]!.number}…`];
69
+ const detailPane = fill([rule(detailWidth, input.focus === "detail" ? "Issue ◂" : "Issue", theme), ...detail.slice(Math.max(0, Math.min(input.detailOffset, detail.length - 1)))], bodyHeight);
70
+ if (!wide) return fill([...banner, ...(input.focus === "detail" ? detailPane : listPane)], height, width);
71
+ return fill([...banner, ...columns(listPane, detailPane, listWidth, detailWidth, " │ ", theme)], height, width);
72
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The Metrics tab: how each model performs at each thinking level, as a
3
+ * dashboard — a row of stat tiles, average run time as bars (one hue),
4
+ * success rate as meters with a status icon, where the time goes by agent as
5
+ * a stacked bar with a legend, and the full table underneath (columns drop
6
+ * from the right as the terminal narrows). It covers the Master, every
7
+ * subagent role, quick fixes and planning rounds.
8
+ */
9
+ import { shortDuration } from "../../text.ts";
10
+ import { tokens } from "../../pi/run-summary.ts";
11
+ import type { GroupBy, MetricGroup, MetricRecord, SortKey, TaskStats, TaskTimeGroup } from "../../state/metrics.ts";
12
+ import { bar, beside, bold, box, fill, fit, meter, notePane, paint, selectRow, sparkline, stackedBar, windowStart, wrap, type LobbyTheme, type PaneLayout } from "../layout.ts";
13
+ import { sourceColor } from "./home.ts";
14
+
15
+ export interface MetricsTabInput {
16
+ groups: readonly MetricGroup[];
17
+ /** Completed tasks' request-to-done time by the oracle's model and thinking. */
18
+ taskTimes?: readonly TaskTimeGroup[];
19
+ records: readonly MetricRecord[];
20
+ stats: TaskStats;
21
+ by: GroupBy;
22
+ sort: SortKey;
23
+ selected: number;
24
+ /** The search in force; records and groups are already narrowed by it. */
25
+ query?: string;
26
+ /** Filled with where the table landed, for the wheel. */
27
+ panes?: PaneLayout;
28
+ }
29
+
30
+ interface Column {
31
+ title: string;
32
+ width: number;
33
+ align: "left" | "right";
34
+ cell: (group: MetricGroup) => string;
35
+ }
36
+
37
+ function percent(part: number, whole: number): string {
38
+ return whole > 0 ? `${Math.round((part / whole) * 100)}%` : "—";
39
+ }
40
+
41
+ function duration(ms: number): string {
42
+ return ms > 0 ? shortDuration(ms) : "—";
43
+ }
44
+
45
+ function money(value: number): string {
46
+ if (value <= 0) return "—";
47
+ return value < 0.01 ? "<$0.01" : `$${value.toFixed(2)}`;
48
+ }
49
+
50
+ const KIND_LABELS: Record<string, string> = {
51
+ master: "master",
52
+ scout: "scout",
53
+ worker: "worker",
54
+ reviewer: "QA gate",
55
+ researcher: "research",
56
+ quickfix: "quick fix",
57
+ planner: "oracle (plan)",
58
+ panel: "panel",
59
+ };
60
+
61
+ /** Columns in priority order; the table keeps as many as the width allows. */
62
+ const COLUMNS: readonly Column[] = [
63
+ { title: "Runs", width: 5, align: "right", cell: (g) => String(g.runs) },
64
+ { title: "OK", width: 5, align: "right", cell: (g) => percent(g.successes, g.runs) },
65
+ { title: "Avg", width: 8, align: "right", cell: (g) => duration(g.avgMs) },
66
+ { title: "p50", width: 8, align: "right", cell: (g) => duration(g.p50Ms) },
67
+ { title: "p90", width: 8, align: "right", cell: (g) => duration(g.p90Ms) },
68
+ { title: "Turns", width: 6, align: "right", cell: (g) => (g.avgTurns > 0 ? g.avgTurns.toFixed(1) : "—") },
69
+ { title: "Tools", width: 6, align: "right", cell: (g) => (g.avgTools > 0 ? g.avgTools.toFixed(1) : "—") },
70
+ { title: "Tokens", width: 7, align: "right", cell: (g) => (g.avgTokens > 0 ? tokens(g.avgTokens) : "—") },
71
+ { title: "tok/s", width: 6, align: "right", cell: (g) => (g.tokensPerSecond > 0 ? g.tokensPerSecond.toFixed(0) : "—") },
72
+ { title: "$/run", width: 7, align: "right", cell: (g) => money(g.avgCost) },
73
+ { title: "$ total", width: 8, align: "right", cell: (g) => money(g.totalCost) },
74
+ { title: "Stalls", width: 6, align: "right", cell: (g) => (g.timeouts > 0 ? String(g.timeouts) : "—") },
75
+ ];
76
+
77
+ const MODEL_MIN = 30;
78
+ const THINK_WIDTH = 7;
79
+ const AGENTS_WIDTH = 14;
80
+
81
+ function align(text: string, width: number, side: "left" | "right"): string {
82
+ const cut = fit(text, width).trimEnd();
83
+ return side === "right" ? cut.padStart(width) : fit(cut, width);
84
+ }
85
+
86
+ /** The columns that fit: model, thinking and agents first, then metrics in priority order. */
87
+ export function fittedColumns(width: number): Column[] {
88
+ let used = MODEL_MIN + 1 + THINK_WIDTH + 1 + AGENTS_WIDTH;
89
+ const kept: Column[] = [];
90
+ for (const column of COLUMNS) {
91
+ if (used + 1 + column.width > width) break;
92
+ kept.push(column);
93
+ used += 1 + column.width;
94
+ }
95
+ return kept;
96
+ }
97
+
98
+ function agentsCell(group: MetricGroup, by: GroupBy): string {
99
+ const kinds = group.kinds.map((kind) => KIND_LABELS[kind] ?? kind);
100
+ return by === "model-kind" ? kinds[0] ?? "" : kinds.join(", ");
101
+ }
102
+
103
+ export function tableLines(input: MetricsTabInput, width: number, theme?: LobbyTheme): { header: string; rows: string[] } {
104
+ const kept = fittedColumns(width);
105
+ const fixed = kept.reduce((sum, column) => sum + 1 + column.width, 0);
106
+ const modelWidth = Math.max(MODEL_MIN, width - fixed - 1 - THINK_WIDTH - 1 - AGENTS_WIDTH);
107
+ const head = [fit("Model", modelWidth), fit("Think", THINK_WIDTH), fit(input.by === "model-kind" ? "Agent" : "Agents", AGENTS_WIDTH), ...kept.map((column) => align(column.title, column.width, column.align))].join(" ");
108
+ const rows = input.groups.map((group, index) => {
109
+ const cells = [
110
+ fit(group.model, modelWidth),
111
+ fit(group.thinking, THINK_WIDTH),
112
+ fit(agentsCell(group, input.by), AGENTS_WIDTH),
113
+ ...kept.map((column) => align(column.cell(group), column.width, column.align)),
114
+ ].join(" ");
115
+ return selectRow(theme, index === input.selected ? bold(theme, cells) : cells, width, index === input.selected);
116
+ });
117
+ return { header: bold(theme, paint(theme, "muted", fit(head, width))), rows };
118
+ }
119
+
120
+ /** A stat tile: the label in its border, the value large, one line of context. */
121
+ function tile(width: number, label: string, value: string, sub: string, theme?: LobbyTheme): string[] {
122
+ return box(width, 4, [bold(theme, paint(theme, "accent", value)), paint(theme, "dim", sub)], { title: label, theme });
123
+ }
124
+
125
+ function timed(records: readonly MetricRecord[]): MetricRecord[] {
126
+ return records.filter((record) => record.status !== "cancelled" && record.durationMs > 0);
127
+ }
128
+
129
+ /** The KPI row: runs (with recent run times), success, average run, cost, tasks. */
130
+ export function tileRow(input: MetricsTabInput, width: number, theme?: LobbyTheme): string[] {
131
+ const { records, stats } = input;
132
+ const done = timed(records);
133
+ const ok = records.filter((record) => record.status === "success").length;
134
+ const stalls = records.filter((record) => record.stalled || record.status === "timeout").length;
135
+ const cost = records.reduce((sum, record) => sum + (record.cost ?? 0), 0);
136
+ const avg = done.length > 0 ? done.reduce((sum, record) => sum + record.durationMs, 0) / done.length : 0;
137
+ const sorted = done.map((record) => record.durationMs).sort((a, b) => a - b);
138
+ const p90 = sorted.length > 0 ? sorted[Math.min(sorted.length - 1, Math.ceil(0.9 * sorted.length) - 1)]! : 0;
139
+ const count = width >= 100 ? 5 : width >= 60 ? 3 : 2;
140
+ const tileWidth = Math.floor((width - (count - 1)) / count);
141
+ const rate = records.length > 0 ? ok / records.length : 0;
142
+ const tiles = [
143
+ tile(tileWidth, "Runs", String(records.length), sparkline(done.map((record) => record.durationMs), tileWidth - 4) || "no runs yet", theme),
144
+ tile(tileWidth, "Success", records.length > 0 ? `${status(rate).icon} ${Math.round(rate * 100)}%` : "—", `${records.length - ok} failed${stalls > 0 ? ` · ${stalls} stalled` : ""}`, theme),
145
+ tile(tileWidth, "Avg run", duration(avg), `p90 ${duration(p90)}`, theme),
146
+ tile(tileWidth, "Cost", money(cost), records.length > 0 ? `${money(cost / records.length)} per run` : "—", theme),
147
+ tile(tileWidth, "Tasks", `${stats.completed} done`, stats.avgCompleteMs > 0 ? `avg ${shortDuration(stats.avgCompleteMs)} · ${stats.active} active` : `${stats.active} active`, theme),
148
+ ].slice(0, count);
149
+ return beside(tiles);
150
+ }
151
+
152
+ /** Success thresholds, each with an icon so the state never rests on colour alone. */
153
+ function status(rate: number): { icon: string; color: "success" | "warning" | "error"; word: string } {
154
+ if (rate >= 0.9) return { icon: "✓", color: "success", word: "healthy" };
155
+ if (rate >= 0.7) return { icon: "!", color: "warning", word: "shaky" };
156
+ return { icon: "✗", color: "error", word: "failing" };
157
+ }
158
+
159
+ /** Room for `model · thinking` beside a chart's marks: about a third of it, 10 to 26 columns. */
160
+ function chartLabelWidth(inner: number): number {
161
+ return Math.min(26, Math.max(10, Math.floor(inner * 0.34)));
162
+ }
163
+
164
+ function groupLabel(group: MetricGroup, by: GroupBy): string {
165
+ const agent = by === "model-kind" ? ` · ${agentsCell(group, by)}` : "";
166
+ return `${group.model} · ${group.thinking}${agent}`;
167
+ }
168
+
169
+ /** Average run time per model and thinking level: one hue, longest first, value and p90 beside each bar. */
170
+ export function timeBars(groups: readonly MetricGroup[], by: GroupBy, width: number, height: number, theme?: LobbyTheme): string[] {
171
+ const inner = width - 4;
172
+ const rows = [...groups].filter((group) => group.avgMs > 0).sort((a, b) => b.avgMs - a.avgMs).slice(0, Math.max(0, height - 2));
173
+ const labelWidth = chartLabelWidth(inner);
174
+ // The p90 goes first when room runs out.
175
+ const withP90 = inner - labelWidth >= 34;
176
+ const valueWidth = withP90 ? 19 : 7;
177
+ const barWidth = Math.max(4, inner - labelWidth - valueWidth - 2);
178
+ const max = Math.max(...rows.map((group) => group.avgMs), 1);
179
+ const lines = rows.map((group) => {
180
+ const value = `${duration(group.avgMs).padStart(7)}${withP90 ? ` ${paint(theme, "dim", `p90 ${duration(group.p90Ms)}`)}` : ""}`;
181
+ return `${paint(theme, "muted", fit(groupLabel(group, by), labelWidth))} ${fit(paint(theme, "accent", bar(group.avgMs, max, barWidth)), barWidth)} ${value}`;
182
+ });
183
+ return box(width, height, lines.length > 0 ? lines : [paint(theme, "dim", "No timed runs yet.")], { title: "Average run time", right: "per model · thinking", theme });
184
+ }
185
+
186
+ /** Success rate per model and thinking level: a meter, an icon and the number. */
187
+ export function successMeters(groups: readonly MetricGroup[], by: GroupBy, width: number, height: number, theme?: LobbyTheme): string[] {
188
+ const inner = width - 4;
189
+ const rows = [...groups].sort((a, b) => a.successes / a.runs - b.successes / b.runs).slice(0, Math.max(0, height - 2));
190
+ const labelWidth = chartLabelWidth(inner);
191
+ const meterWidth = Math.max(4, inner - labelWidth - 12);
192
+ const lines = rows.map((group) => {
193
+ const rate = group.runs > 0 ? group.successes / group.runs : 0;
194
+ const state = status(rate);
195
+ const paintFill = (text: string) => paint(theme, state.color, text);
196
+ const paintTrack = (text: string) => paint(theme, "borderMuted", text);
197
+ return `${paint(theme, "muted", fit(groupLabel(group, by), labelWidth))} ${meter(rate, meterWidth, paintFill, paintTrack)} ${paint(theme, state.color, state.icon)} ${`${Math.round(rate * 100)}%`.padStart(4)} ${paint(theme, "dim", `${group.runs}`)}`;
198
+ });
199
+ return box(width, height, lines.length > 0 ? lines : [paint(theme, "dim", "No runs yet.")], { title: "Success rate", right: "✓ ≥90% · ! ≥70% · ✗ below", theme });
200
+ }
201
+
202
+ /** Where the time goes: each agent's share of all run time, then how long a task takes per oracle model. */
203
+ export function timeShare(records: readonly MetricRecord[], taskTimes: readonly TaskTimeGroup[] | undefined, width: number, theme?: LobbyTheme): string[] {
204
+ const inner = width - 4;
205
+ const byAgent = new Map<string, { n: number; ms: number }>();
206
+ for (const record of timed(records)) {
207
+ const entry = byAgent.get(record.agent) ?? { n: 0, ms: 0 };
208
+ entry.n += 1;
209
+ entry.ms += record.durationMs;
210
+ byAgent.set(record.agent, entry);
211
+ }
212
+ const agents = [...byAgent.entries()].sort((a, b) => b[1].ms - a[1].ms);
213
+ const total = agents.reduce((sum, [, entry]) => sum + entry.ms, 0);
214
+ const lines: string[] = [];
215
+ if (total > 0) {
216
+ lines.push(stackedBar(agents.map(([agent, entry]) => ({ value: entry.ms, paint: (text: string) => paint(theme, sourceColor(agent), text) })), inner));
217
+ const legend = agents.map(([agent, entry]) => `${paint(theme, sourceColor(agent), "■")} ${agent} ${Math.round((entry.ms / total) * 100)}% ${paint(theme, "dim", `avg ${shortDuration(entry.ms / entry.n)} ×${entry.n}`)}`);
218
+ lines.push(...wrap(legend.join(" "), inner));
219
+ } else lines.push(paint(theme, "dim", "No timed runs yet."));
220
+ if (taskTimes && taskTimes.length > 0) {
221
+ const max = Math.max(...taskTimes.map((group) => group.avgMs), 1);
222
+ const labelWidth = chartLabelWidth(inner);
223
+ const barWidth = Math.max(4, inner - labelWidth - 18);
224
+ lines.push("", bold(theme, "Request to done, by the oracle's model"));
225
+ for (const group of taskTimes) {
226
+ lines.push(`${paint(theme, "muted", fit(`${group.model} · ${group.thinking}`, labelWidth))} ${fit(paint(theme, "accent", bar(group.avgMs, max, barWidth)), barWidth)} ${shortDuration(group.avgMs).padStart(7)} ${paint(theme, "dim", `×${group.tasks}`)}`);
227
+ }
228
+ }
229
+ return box(width, lines.length + 2, lines, { title: "Where the time goes", right: "share of run time by agent", theme });
230
+ }
231
+
232
+ /** The metrics dashboard: KPI tiles, the charts, and the full table filling what is left. */
233
+ export function renderMetrics(input: MetricsTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
234
+ if (height <= 0) return [];
235
+ if (input.records.length === 0) {
236
+ const empty = input.query ? `No run matches "${input.query}".` : "No runs recorded yet. Every Master turn, subagent run, quick fix and planning round lands here with its model, thinking level, time, tokens and cost.";
237
+ return fill([...(height >= 8 ? tileRow(input, width, theme) : []), ...box(width, Math.max(3, height - (height >= 8 ? 4 : 0)), [paint(theme, "dim", empty)], { title: "Model performance", theme })], height, width);
238
+ }
239
+ const sections: string[] = [];
240
+ let room = height;
241
+ if (room >= 16) {
242
+ sections.push(...tileRow(input, width, theme));
243
+ room -= 4;
244
+ }
245
+ const chartHeight = Math.min(input.groups.length + 2, 8);
246
+ if (room - chartHeight >= 9) {
247
+ if (width >= 100) {
248
+ const left = Math.floor((width - 1) / 2);
249
+ sections.push(...beside([timeBars(input.groups, input.by, left, chartHeight, theme), successMeters(input.groups, input.by, width - 1 - left, chartHeight, theme)]));
250
+ room -= chartHeight;
251
+ } else {
252
+ sections.push(...timeBars(input.groups, input.by, width, chartHeight, theme));
253
+ room -= chartHeight;
254
+ }
255
+ }
256
+ const share = timeShare(input.records, input.taskTimes, width, theme);
257
+ if (room - share.length >= 6) {
258
+ sections.push(...share);
259
+ room -= share.length;
260
+ }
261
+ const table = tableLines(input, width - 4, theme);
262
+ const tableRows = Math.max(0, room - 3);
263
+ const start = windowStart(input.selected, table.rows.length, tableRows);
264
+ const grouping = input.by === "model" ? "by model · thinking" : "by model · thinking · agent";
265
+ notePane(input.panes, "table", sections.length, 0, width, room, table.rows.length);
266
+ sections.push(...box(width, room, [table.header, ...table.rows.slice(start, start + tableRows)], { title: "All models", right: `${grouping} · sorted by ${input.sort} · g s`, scroll: { total: table.rows.length + 1, start }, theme }));
267
+ return fill(sections, height, width);
268
+ }
269
+
270
+ /** Runs whose agent, model, thinking level, kind, status or task mention `query`. */
271
+ export function filterRecords(records: readonly MetricRecord[], query: string | undefined): MetricRecord[] {
272
+ const needle = query?.trim().toLowerCase();
273
+ if (!needle) return [...records];
274
+ return records.filter((record) => [record.agent, record.model ?? "", record.thinking ?? "", record.kind, record.status, record.taskId ?? ""].join(" ").toLowerCase().includes(needle));
275
+ }