@a-t-h-i/bot-lobby 0.5.0 → 0.6.0

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.
@@ -1,17 +1,24 @@
1
1
  /**
2
- * The Lobby tab: the zen scene of this session's task on top, then the
2
+ * The Lobby tab: the zen scene of this session's task on top (the oracle
3
+ * and agent animations, or just their status when animations are off), then the
3
4
  * conversation with the oracle (text only, no tool rows, no thinking), the
4
5
  * activity log of plain-words steps from every agent, and the one place where
5
6
  * thoughts show up. Pure: the scene arrives as a callback, the clock as `now`.
6
7
  */
8
+ import { visibleWidth } from "@earendil-works/pi-tui";
7
9
  import type { Task } from "../../schemas/task.ts";
8
10
  import type { ActivityEntry, ChatEntry, ThoughtEntry } from "../feed.ts";
9
- import { bold, since, clock, columns, fill, italic, paint, rule, spinner, split, tail, wrap, wrapHanging, type LobbyColor, type LobbyTheme } from "../layout.ts";
11
+ import type { LobbyPanel } from "../../schemas/configuration.ts";
12
+ import { beside, bold, box, clock, fill, fit, italic, markdownLines, notePane, paint, since, spinner, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
13
+
14
+ /** The Lobby tab's scrollable panes. */
15
+ export const HOME_PANES = ["conversation", "activity", "thinking"] as const;
16
+ export type HomePane = (typeof HOME_PANES)[number];
10
17
 
11
18
  export interface HomeInput {
12
19
  task?: Task;
13
- /** Draws the zen scene into at most `height` lines; absent when no task is active. */
14
- scene?: (width: number, height: number) => string[];
20
+ /** Draws the zen scene into at most `height` lines, animated or still; absent when no task is active. */
21
+ scene?: (width: number, height: number, animated: boolean) => string[];
15
22
  chat: readonly ChatEntry[];
16
23
  /** The oracle's reply while it streams. */
17
24
  liveReply?: string;
@@ -22,17 +29,36 @@ export interface HomeInput {
22
29
  /** Active tasks owned by other sessions, and saved plans waiting to start. */
23
30
  others: number;
24
31
  pending: number;
25
- /** Lines of conversation scrollback. */
26
- chatOffset: number;
32
+ /** How far each pane is scrolled back, in lines from its newest; 0 follows the newest. */
33
+ offsets?: Partial<Record<HomePane, number>>;
34
+ /** The pane the arrow keys scroll; its border lights up. */
35
+ focus?: HomePane;
36
+ /** Filled with where each pane landed and how much it holds. */
37
+ panes?: PaneLayout;
27
38
  tick: number;
28
39
  now: number;
40
+ /** Which panes show. */
41
+ panels: Record<LobbyPanel, boolean>;
42
+ /** The search in force: panes show only matching entries. */
43
+ query?: string;
44
+ /** Key labels for the pane toggles, shown on the panes. */
45
+ keys?: Record<LobbyPanel, string>;
46
+ /** Another session is shown: its name titles the conversation. */
47
+ title?: string;
48
+ /** The scene is another session's still status (no animations to toggle). */
49
+ stillScene?: boolean;
50
+ /** What the empty conversation says instead of the default (a session starting, say). */
51
+ emptyNote?: string;
52
+ /** What the empty activity log says instead of "No activity yet.". */
53
+ activityNote?: string;
29
54
  }
30
55
 
31
56
  /** Wide terminals put the conversation and the activity log side by side. */
32
57
  export const HOME_COLUMNS_MIN = 100;
33
- /** Most lines the scene may take, and its share of the body. */
58
+ /** Most lines the scene may take, and its share of the body; the still status needs far fewer. */
34
59
  const SCENE_SHARE = 0.45;
35
60
  const MAX_SCENE = 36;
61
+ const MAX_STILL = 10;
36
62
 
37
63
  const SOURCE_COLORS: Record<string, LobbyColor> = {
38
64
  MASTER: "accent",
@@ -68,27 +94,93 @@ export function activityLine(entry: ActivityEntry, width: number, tick: number,
68
94
  return `${paint(theme, "dim", clock(entry.at))} ${who} ${mark} ${body}`;
69
95
  }
70
96
 
71
- function chatLead(role: ChatEntry["role"], theme?: LobbyTheme): string {
72
- if (role === "you") return `${bold(theme, paint(theme, "accent", "you"))} ${paint(theme, "dim", "▸")} `;
73
- if (role === "oracle") return `${bold(theme, paint(theme, "toolTitle", "oracle"))} ${paint(theme, "dim", "▸")} `;
74
- return `${paint(theme, "dim", "·")} `;
97
+ /** How each speaker is shown: a mark and a name in its colour. */
98
+ export const SPEAKERS = {
99
+ you: { mark: "●", name: "You", color: "accent" },
100
+ oracle: { mark: "◆", name: "Oracle", color: "toolTitle" },
101
+ panel: { mark: "◆", name: "Panel", color: "toolTitle" },
102
+ } as const satisfies Record<string, { mark: string; name: string; color: LobbyColor }>;
103
+ export type Speaker = keyof typeof SPEAKERS;
104
+
105
+ /** Messages from one speaker within this long of each other share a header. */
106
+ const GROUP_MS = 5 * 60_000;
107
+ /** Message bodies sit under the speaker's name, past its mark. */
108
+ const BODY_INDENT = 2;
109
+
110
+ /** `◆ Oracle ············ 12:04`: who speaks on the left; when, or what they are doing, on the right. */
111
+ export function speakerLine(speaker: Speaker, width: number, theme?: LobbyTheme, right = ""): string {
112
+ const { mark, name, color } = SPEAKERS[speaker];
113
+ const left = `${paint(theme, color, mark)} ${bold(theme, paint(theme, color, name))}`;
114
+ const note = right ? paint(theme, "dim", right) : "";
115
+ const gap = width - visibleWidth(left) - visibleWidth(note);
116
+ return gap >= 1 && note ? `${left}${" ".repeat(gap)}${note}` : left;
117
+ }
118
+
119
+ /**
120
+ * What you wrote, as pi shows it: a band in the user-message background with
121
+ * a column of padding (a bar in its colour where the theme has no background).
122
+ */
123
+ export function youLines(text: string, width: number, theme?: LobbyTheme): string[] {
124
+ const inner = Math.max(1, width - BODY_INDENT - 2);
125
+ const indent = " ".repeat(BODY_INDENT);
126
+ const band = theme?.bg
127
+ ? (line: string) => theme.bg!("userMessageBg", ` ${fit(paint(theme, "userMessageText", line), inner)} `)
128
+ : (line: string) => `${paint(theme, "accent", "▌")} ${line}`;
129
+ return wrap(text, inner).map((line) => `${indent}${band(line)}`);
130
+ }
131
+
132
+ /** The oracle's reply as Markdown, under its name. */
133
+ function oracleLines(text: string, width: number, theme?: LobbyTheme): string[] {
134
+ const indent = " ".repeat(BODY_INDENT);
135
+ return markdownLines(text, Math.max(1, width - BODY_INDENT), theme).map((line) => (line ? `${indent}${line}` : ""));
75
136
  }
76
137
 
77
- /** The conversation as wrapped lines, oldest first, a blank line between turns. */
138
+ /** An event in the conversation (a task starting, a comment sent) as a centred rule; failures stand out instead. */
139
+ export function eventLines(text: string, at: number, width: number, theme?: LobbyTheme): string[] {
140
+ if (text.startsWith("✗")) return wrap(paint(theme, "error", text), width);
141
+ const label = ` ${text}${at > 0 ? ` · ${clock(at)}` : ""} `;
142
+ const room = width - visibleWidth(label);
143
+ if (room < 6) return wrap(paint(theme, "dim", text), width);
144
+ const left = Math.floor(room / 2);
145
+ return [paint(theme, "dim", `${"─".repeat(left)}${label}${"─".repeat(room - left)}`)];
146
+ }
147
+
148
+ /**
149
+ * The conversation, oldest first: each turn under a speaker line with its
150
+ * time, your messages in a band, the oracle's in Markdown, events as rules,
151
+ * and while the oracle works its header says so (streaming the reply under it).
152
+ */
78
153
  export function chatLines(chat: readonly ChatEntry[], width: number, theme?: LobbyTheme, live?: string, busy = false, tick = 0): string[] {
79
154
  const lines: string[] = [];
80
- const push = (role: ChatEntry["role"], text: string) => {
81
- if (lines.length > 0) lines.push("");
82
- const body = role === "note" ? paint(theme, "dim", text) : text;
83
- lines.push(...wrapHanging(chatLead(role, theme), body, width));
155
+ const gap = () => {
156
+ if (lines.length > 0 && lines.at(-1) !== "") lines.push("");
84
157
  };
85
- for (const entry of chat) push(entry.role, entry.text);
86
- if (live?.trim()) push("oracle", `${live.trim()} ${paint(theme, "accent", spinner(tick))}`);
87
- else if (busy) push("oracle", paint(theme, "dim", `${spinner(tick)} working…`));
158
+ let last: ChatEntry | undefined;
159
+ for (const entry of chat) {
160
+ gap();
161
+ if (entry.role === "note") {
162
+ lines.push(...eventLines(entry.text, entry.at, width, theme));
163
+ last = undefined;
164
+ continue;
165
+ }
166
+ const grouped = last?.role === entry.role && entry.at >= last.at && entry.at - last.at < GROUP_MS;
167
+ if (!grouped) lines.push(speakerLine(entry.role, width, theme, entry.at > 0 ? clock(entry.at) : ""));
168
+ lines.push(...(entry.role === "you" ? youLines(entry.text, width, theme) : oracleLines(entry.text, width, theme)));
169
+ last = entry;
170
+ }
171
+ if (live?.trim()) {
172
+ gap();
173
+ lines.push(speakerLine("oracle", width, theme, `${spinner(tick)} writing`), ...oracleLines(live.trim(), width, theme));
174
+ } else if (busy) {
175
+ gap();
176
+ lines.push(speakerLine("oracle", width, theme, `${spinner(tick)} working…`));
177
+ }
88
178
  return lines;
89
179
  }
90
180
 
91
181
  function emptyChat(input: HomeInput, width: number, theme?: LobbyTheme): string[] {
182
+ if (input.query) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${input.query}".`), width);
183
+ if (input.emptyNote) return wrap(paint(theme, "dim", input.emptyNote), width);
92
184
  const lines = input.task
93
185
  ? [paint(theme, "dim", "Nothing said yet. Type below to talk to the oracle about this task.")]
94
186
  : [
@@ -103,16 +195,38 @@ function emptyChat(input: HomeInput, width: number, theme?: LobbyTheme): string[
103
195
  return [...lines.flatMap((line) => wrap(line, width)), ...(extra.length > 0 ? ["", ...extra.flatMap((line) => wrap(paint(theme, "muted", line), width))] : [])];
104
196
  }
105
197
 
106
- function conversation(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
107
- const all = input.chat.length > 0 || input.liveReply || input.busy ? chatLines(input.chat, width, theme, input.liveReply, input.busy, input.tick) : emptyChat(input, width, theme);
108
- const shown = tail(all, height, input.chatOffset);
109
- return fill(shown, height);
198
+ function matches(text: string, query: string | undefined): boolean {
199
+ return !query || text.toLowerCase().includes(query.toLowerCase());
200
+ }
201
+
202
+ /** The feed as the search narrows it: only entries whose text (or source) contains the query. */
203
+ export function filterFeed(input: Pick<HomeInput, "chat" | "activity" | "thoughts" | "query">): { chat: ChatEntry[]; activity: ActivityEntry[]; thoughts: ThoughtEntry[] } {
204
+ const query = input.query?.trim();
205
+ return {
206
+ chat: input.chat.filter((entry) => matches(entry.text, query)),
207
+ activity: input.activity.filter((entry) => matches(`${entry.source} ${entry.text}`, query)),
208
+ thoughts: input.thoughts.filter((entry) => matches(`${entry.source} ${entry.text}`, query)),
209
+ };
210
+ }
211
+
212
+ function conversation(input: HomeInput, chat: readonly ChatEntry[], width: number, theme?: LobbyTheme): string[] {
213
+ const searching = Boolean(input.query);
214
+ const live = searching ? undefined : input.liveReply;
215
+ const busy = !searching && input.busy;
216
+ return chat.length > 0 || live || busy ? chatLines(chat, width, theme, live, busy, input.tick) : emptyChat(input, width, theme);
217
+ }
218
+
219
+ function activity(input: HomeInput, entries: readonly ActivityEntry[], width: number, theme?: LobbyTheme): string[] {
220
+ if (entries.length === 0) return wrap(paint(theme, "dim", input.query ? `No activity matches "${input.query}".` : input.activityNote ?? "No activity yet."), width);
221
+ return entries.map((entry) => activityLine(entry, width, input.tick, theme));
110
222
  }
111
223
 
112
- function activity(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
113
- if (input.activity.length === 0) return fill([paint(theme, "dim", "No activity yet.")], height);
114
- const lines = input.activity.slice(-Math.max(height, 1)).map((entry) => activityLine(entry, width, input.tick, theme));
115
- return fill(tail(lines, height), height);
224
+ /** The last `rows` lines, `offset` lines back from the newest (clamped), and where they start. */
225
+ export function tailWindow(lines: readonly string[], rows: number, offset = 0): { shown: string[]; start: number; offset: number } {
226
+ const max = Math.max(0, lines.length - rows);
227
+ const back = Math.min(Math.max(0, offset), max);
228
+ const start = max - back;
229
+ return { shown: lines.slice(start, start + Math.max(0, rows)), start, offset: back };
116
230
  }
117
231
 
118
232
  /** The thought to show: a live one first, otherwise the newest. */
@@ -120,45 +234,97 @@ export function currentThought(thoughts: readonly ThoughtEntry[]): ThoughtEntry
120
234
  return [...thoughts].reverse().find((entry) => entry.live) ?? thoughts.at(-1);
121
235
  }
122
236
 
123
- function thinking(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
124
- const thought = currentThought(input.thoughts);
125
- const right = thought ? `${thought.source} · ${thought.live ? "thinking" : since(input.now - thought.at)}` : "";
126
- const header = rule(width, "Thinking", theme, right);
127
- if (height <= 1) return [header].slice(0, height);
128
- if (!thought) return fill([header, paint(theme, "dim", "Thoughts from the oracle and every agent appear here, and only here.")], height);
129
- const body = wrap(thought.text, width).map((line) => italic(theme, paint(theme, "dim", line)));
130
- return fill([header, ...tail(body, height - 1)], height);
237
+ /** Every thought, oldest first: who thought it, then the thought, dimmed. */
238
+ export function thoughtLines(thoughts: readonly ThoughtEntry[], width: number, theme?: LobbyTheme): string[] {
239
+ return thoughts.flatMap((thought) => {
240
+ const lead = `${paint(theme, sourceColor(thought.source), thought.source.padEnd(SOURCE_WIDTH).slice(0, SOURCE_WIDTH))} `;
241
+ return wrapHanging(lead, italic(theme, paint(theme, "dim", thought.text.replace(/\s*\n\s*/g, " "))), width);
242
+ });
131
243
  }
132
244
 
133
- function sceneLines(input: HomeInput, width: number, height: number): string[] {
245
+ function thinkingContent(input: HomeInput, thoughts: readonly ThoughtEntry[], width: number, theme?: LobbyTheme): string[] {
246
+ if (thoughts.length > 0) return thoughtLines(thoughts, width, theme);
247
+ return [paint(theme, "dim", input.query ? `No thought matches "${input.query}".` : "Thoughts from the oracle and every agent appear here, and only here.")];
248
+ }
249
+
250
+ /**
251
+ * The scene: with animations off (the `animations` panel, off by default)
252
+ * only the task's status box, what the agents are doing and the checklist;
253
+ * with them on, the animated oracle and agents too. Its first line names the
254
+ * key that toggles the animations.
255
+ */
256
+ function sceneLines(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
134
257
  if (!input.task || !input.scene) return [];
135
- const budget = Math.min(MAX_SCENE, Math.floor(height * SCENE_SHARE));
136
- if (budget < 6) return [];
137
- return input.scene(width, budget).slice(0, budget);
258
+ const animated = input.panels.animations && !input.stillScene;
259
+ const budget = Math.min(animated ? MAX_SCENE : MAX_STILL, Math.floor(height * SCENE_SHARE));
260
+ if (budget < (animated ? 6 : 4)) return [];
261
+ const lines = input.scene(width, budget, animated).slice(0, budget);
262
+ const note = input.keys && !input.stillScene ? `${input.keys.animations} ${animated ? "hides" : "shows"} animations` : undefined;
263
+ return keyNote(lines, width, note, theme);
138
264
  }
139
265
 
140
- /** The whole tab, exactly `height` lines of `width` columns. */
266
+ /** `note` at the right end of the first line, dimmed, when it fits beside what is there. */
267
+ function keyNote(lines: readonly string[], width: number, note: string | undefined, theme?: LobbyTheme): string[] {
268
+ const first = lines[0];
269
+ if (!note || first === undefined) return [...lines];
270
+ const gap = width - visibleWidth(first) - visibleWidth(note) - 1;
271
+ if (gap < 2) return [...lines];
272
+ return [`${first}${" ".repeat(gap)}${paint(theme, "dim", note)}`, ...lines.slice(1)];
273
+ }
274
+
275
+ function hiddenHint(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
276
+ const keys = input.keys;
277
+ const text = keys
278
+ ? `Every pane is hidden — ${keys.conversation} conversation · ${keys.activity} activity · ${keys.thinking} thinking`
279
+ : "Every pane is hidden.";
280
+ const lines = wrap(paint(theme, "dim", text), width);
281
+ const top = Math.max(0, Math.floor((height - lines.length) / 2));
282
+ return fill([...Array.from({ length: top }, () => ""), ...lines], height, width);
283
+ }
284
+
285
+ /** `↓12` when a pane is scrolled back from its newest line, before the pane's usual note. */
286
+ function rightNote(offset: number, note: string | undefined): string | undefined {
287
+ const parts = [offset > 0 ? `↓${offset}` : "", note ?? ""].filter(Boolean);
288
+ return parts.length > 0 ? parts.join(" · ") : undefined;
289
+ }
290
+
291
+ /** The whole tab, exactly `height` lines of `width` columns; hidden panes give their room to the rest. */
141
292
  export function renderHome(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
142
293
  if (height <= 0) return [];
143
- const scene = sceneLines(input, width, height);
294
+ const { panels } = input;
295
+ const feed = filterFeed(input);
296
+ const scene = sceneLines(input, width, height, theme);
144
297
  const rest = height - scene.length;
145
- const thinkHeight = rest >= 16 ? Math.max(4, Math.floor(rest * 0.25)) : rest >= 9 ? 3 : 0;
298
+ const showMain = panels.conversation || panels.activity;
299
+ const thinkHeight = !panels.thinking ? 0 : !showMain ? rest : rest >= 18 ? Math.max(5, Math.floor(rest * 0.25)) : rest >= 10 ? 4 : 0;
146
300
  const main = rest - thinkHeight;
147
- const chatTitle = input.task ? `Conversation · ${input.task.id}` : "Conversation";
148
- let body: string[];
149
- if (width >= HOME_COLUMNS_MIN) {
150
- const [left, right] = split(width, 0.56);
151
- const headers = columns([rule(left, chatTitle, theme)], [rule(right, "Activity", theme)], left, right, " ┬ ", theme);
152
- const rows = columns(conversation(input, left, main - 1, theme), activity(input, right, main - 1, theme), left, right, " │ ", theme);
153
- body = [...headers, ...rows];
154
- } else {
155
- const chatHeight = Math.max(1, Math.ceil(main * 0.6));
156
- const activityHeight = main - chatHeight;
157
- body = [
158
- rule(width, chatTitle, theme),
159
- ...conversation(input, width, chatHeight - 1, theme),
160
- ...(activityHeight > 0 ? [rule(width, "Activity", theme), ...activity(input, width, activityHeight - 1, theme)] : []),
161
- ];
162
- }
163
- return fill([...scene, ...fill(body, main), ...thinking(input, width, thinkHeight, theme)], height, width);
301
+ if (!showMain && !panels.thinking) return fill([...scene, ...hiddenHint(input, width, rest, theme)], height, width);
302
+ const matches = (count: number) => `${count} match${count === 1 ? "" : "es"}`;
303
+ /** One scrollable pane: its lines, tailed to its rows and scrolled back by its offset, in a box that records where it sits. */
304
+ const pane = (name: HomePane, title: string, note: string | undefined, lines: (inner: number) => string[]) => (top: number, left: number, w: number, h: number): string[] => {
305
+ const all = lines(Math.max(1, w - 4));
306
+ const view = tailWindow(all, h - 2, input.offsets?.[name] ?? 0);
307
+ notePane(input.panes, name, top, left, w, h, all.length);
308
+ const right = rightNote(view.offset, note);
309
+ return box(w, h, view.shown, { title, ...(right ? { right } : {}), focused: input.focus === name, scroll: { total: all.length, start: view.start }, theme });
310
+ };
311
+ const chatBox = pane("conversation", input.title ? `Conversation · ${input.title}` : "Conversation", input.query ? matches(feed.chat.length) : input.keys?.conversation, (inner) => conversation(input, feed.chat, inner, theme));
312
+ const activityBox = pane("activity", "Activity", input.query ? matches(feed.activity.length) : input.keys?.activity, (inner) => activity(input, feed.activity, inner, theme));
313
+ const thought = currentThought(feed.thoughts);
314
+ const thinkingNote = thought ? `${thought.source} · ${thought.live ? "thinking" : since(input.now - thought.at)}` : input.keys?.thinking;
315
+ const thinkingBox = pane("thinking", "Thinking", thinkingNote, (inner) => thinkingContent(input, feed.thoughts, inner, theme));
316
+ const top = scene.length;
317
+ let body: string[] = [];
318
+ if (main > 0 && panels.conversation && panels.activity) {
319
+ if (width >= HOME_COLUMNS_MIN) {
320
+ const left = Math.round((width - 1) * 0.56);
321
+ body = beside([chatBox(top, 0, left, main), activityBox(top, left + 1, width - 1 - left, main)]);
322
+ } else {
323
+ const chatHeight = Math.max(3, Math.ceil(main * 0.6));
324
+ body = [...chatBox(top, 0, width, chatHeight), ...(main - chatHeight >= 3 ? activityBox(top + chatHeight, 0, width, main - chatHeight) : [])];
325
+ }
326
+ } else if (main > 0 && panels.conversation) body = chatBox(top, 0, width, main);
327
+ else if (main > 0 && panels.activity) body = activityBox(top, 0, width, main);
328
+ const think = thinkHeight >= 3 ? thinkingBox(top + main, 0, width, thinkHeight) : [];
329
+ return fill([...scene, ...fill(body, main), ...think], height, width);
164
330
  }
@@ -1,14 +1,16 @@
1
1
  /**
2
- * The Metrics tab: how each model performs at each thinking level — how long
3
- * its runs take (mean, median, p90), how often they succeed, how many turns,
4
- * tools and tokens they use, their throughput and cost — across the Master,
5
- * every subagent role, quick fixes and planner turns. Columns drop from the
6
- * right as the terminal narrows.
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.
7
8
  */
8
9
  import { shortDuration } from "../../text.ts";
9
10
  import { tokens } from "../../pi/run-summary.ts";
10
11
  import type { GroupBy, MetricGroup, MetricRecord, SortKey, TaskStats, TaskTimeGroup } from "../../state/metrics.ts";
11
- import { bold, fill, fit, paint, rule, selectRow, windowStart, type LobbyTheme } from "../layout.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";
12
14
 
13
15
  export interface MetricsTabInput {
14
16
  groups: readonly MetricGroup[];
@@ -19,6 +21,10 @@ export interface MetricsTabInput {
19
21
  by: GroupBy;
20
22
  sort: SortKey;
21
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;
22
28
  }
23
29
 
24
30
  interface Column {
@@ -111,52 +117,159 @@ export function tableLines(input: MetricsTabInput, width: number, theme?: LobbyT
111
117
  return { header: bold(theme, paint(theme, "muted", fit(head, width))), rows };
112
118
  }
113
119
 
114
- function summary(input: MetricsTabInput, theme?: LobbyTheme): string[] {
115
- const { stats, records } = input;
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;
116
135
  const cost = records.reduce((sum, record) => sum + (record.cost ?? 0), 0);
117
- const tasks = [
118
- `${stats.completed} completed${stats.completed > 0 && stats.avgCompleteMs > 0 ? ` (avg ${shortDuration(stats.avgCompleteMs)} from request to done)` : ""}`,
119
- `${stats.active} active`,
120
- `${stats.abandoned} abandoned`,
121
- ].join(" · ");
122
- return [
123
- `${bold(theme, "Tasks")} ${tasks}`,
124
- `${bold(theme, "Runs")} ${records.length} recorded · ${money(cost)} total · grouped by ${input.by === "model" ? "model + thinking" : "model + thinking + agent"} · sorted by ${input.sort}`,
125
- ];
126
- }
127
-
128
- /** Per-agent averages across every model, a quick read of where the time goes. */
129
- function agentLine(records: readonly MetricRecord[], theme?: LobbyTheme): string {
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;
130
205
  const byAgent = new Map<string, { n: number; ms: number }>();
131
- for (const record of records) {
132
- if (record.status === "cancelled" || record.durationMs <= 0) continue;
206
+ for (const record of timed(records)) {
133
207
  const entry = byAgent.get(record.agent) ?? { n: 0, ms: 0 };
134
208
  entry.n += 1;
135
209
  entry.ms += record.durationMs;
136
210
  byAgent.set(record.agent, entry);
137
211
  }
138
- const parts = [...byAgent.entries()].sort((a, b) => b[1].n - a[1].n).map(([agent, entry]) => `${agent} ${shortDuration(entry.ms / entry.n)} ${paint(theme, "dim", `×${entry.n}`)}`);
139
- return parts.length > 0 ? `${bold(theme, "Avg by agent")} ${parts.join(paint(theme, "dim", " · "))}` : "";
140
- }
141
-
142
- /** One line: how long a task takes on each oracle model, request to done. */
143
- function taskTimeLine(groups: readonly TaskTimeGroup[] | undefined, theme?: LobbyTheme): string {
144
- if (!groups || groups.length === 0) return "";
145
- const parts = groups.map((group) => `${group.model} ${paint(theme, "dim", group.thinking)} ${shortDuration(group.avgMs)} ${paint(theme, "dim", `×${group.tasks}`)}`);
146
- return `${bold(theme, "Task time by oracle")} ${parts.join(paint(theme, "dim", " · "))}`;
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 });
147
230
  }
148
231
 
232
+ /** The metrics dashboard: KPI tiles, the charts, and the full table filling what is left. */
149
233
  export function renderMetrics(input: MetricsTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
150
234
  if (height <= 0) return [];
151
- const head = [...summary(input, theme), taskTimeLine(input.taskTimes, theme), agentLine(input.records, theme)].filter(Boolean);
152
- if (input.groups.length === 0) {
153
- return fill([
154
- ...head, "", rule(width, "Model performance", theme),
155
- paint(theme, "dim", "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."),
156
- ], height, width);
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
+ }
157
255
  }
158
- const table = tableLines(input, width, theme);
159
- const bodyHeight = Math.max(0, height - head.length - 3);
160
- const start = windowStart(input.selected, table.rows.length, bodyHeight);
161
- return fill([...head, "", rule(width, "Model performance", theme, "g group · s sort"), table.header, ...table.rows.slice(start, start + bodyHeight)], height, width);
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));
162
275
  }