@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.
- package/README.md +264 -68
- package/package.json +5 -2
- package/prompts/panel.md +13 -4
- package/prompts/planner.md +31 -13
- package/src/execution/pi-runner.ts +1 -1
- package/src/index.ts +4 -2
- package/src/lobby/ask.ts +226 -0
- package/src/lobby/feed.ts +65 -9
- package/src/lobby/keys.ts +55 -0
- package/src/lobby/layout.ts +208 -4
- package/src/lobby/markdown.ts +48 -0
- package/src/lobby/planner.ts +135 -17
- package/src/lobby/runtime.ts +353 -66
- package/src/lobby/session-files.ts +126 -0
- package/src/lobby/sessions.ts +288 -0
- package/src/lobby/tabs/home.ts +225 -59
- package/src/lobby/tabs/metrics.ts +155 -42
- package/src/lobby/tabs/plan.ts +128 -51
- package/src/lobby/tabs/quickfix.ts +43 -18
- package/src/lobby/tabs/tasks.ts +221 -54
- package/src/lobby/view.ts +1303 -143
- package/src/pi/commands.ts +35 -7
- package/src/pi/events.ts +5 -2
- package/src/pi/notify.ts +18 -0
- package/src/pi/owner.ts +216 -0
- package/src/pi/settings-ui.ts +69 -1
- package/src/pi/start-task.ts +33 -2
- package/src/pi/ui.ts +3 -3
- package/src/pi/zen-large.ts +9 -1
- package/src/pi/zen.ts +7 -3
- package/src/schemas/configuration.ts +43 -4
- package/src/schemas/task.ts +2 -0
- package/src/state/auto.ts +40 -0
- package/src/state/inbox.ts +90 -0
- package/src/workflow/workflow.ts +27 -3
package/src/lobby/tabs/home.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Lobby tab: the zen scene of this session's task on top
|
|
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
|
|
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
|
-
/**
|
|
26
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
return fill([
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
}
|