@a-t-h-i/bot-lobby 0.4.0 → 0.5.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.
@@ -0,0 +1,855 @@
1
+ /**
2
+ * The lobby: a full-screen, tabbed view over everything bot-lobby does. A tab
3
+ * bar on top, the active tab's body, a prompt at the bottom whose target
4
+ * follows the tab (the oracle, a plan comment, the planner, a quick fix, a new
5
+ * issue) and a key-hint line. Two modes, like a modal editor: typing mode sends
6
+ * keys to the prompt; browsing mode (esc) moves through lists and runs the
7
+ * tab's single-key commands. Everything bot-lobby-specific arrives through
8
+ * `LobbyHost`, so the view renders and reacts the same in tests.
9
+ */
10
+ import { decodeKittyPrintable, Editor, Key, matchesKey, visibleWidth, type Component, type EditorTheme, type Focusable, type TUI } from "@earendil-works/pi-tui";
11
+ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
12
+ import type { AgentRun } from "../schemas/findings.ts";
13
+ import type { PlannedTask } from "../state/backlog.ts";
14
+ import type { PlanComment } from "../state/comments.ts";
15
+ import { aggregateMetrics, collectMetrics, SORT_KEYS, sortGroups, taskStats, taskTimesByModel, type GroupBy, type MetricRecord, type SortKey } from "../state/metrics.ts";
16
+ import { PANEL_MEMBERS, type LobbyAgentKind, type PanelMember } from "../schemas/configuration.ts";
17
+ import type { LobbyFeed } from "./feed.ts";
18
+ import type { QuickFixQueue } from "./quickfix.ts";
19
+ import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } from "./planner.ts";
20
+ import { issueText, type IssuesState } from "./issues.ts";
21
+ import { bold, fit, paint, spinner, type LobbyTheme } from "./layout.ts";
22
+ import { renderHome } from "./tabs/home.ts";
23
+ import { planDetailLines, renderTasks, taskDetailLines, taskRows, type TaskRow } from "./tabs/tasks.ts";
24
+ import { renderPlan, type PlanView, type SeatView } from "./tabs/plan.ts";
25
+ import { newestFirst, renderQuickFix } from "./tabs/quickfix.ts";
26
+ import { renderIssues } from "./tabs/issues.ts";
27
+ import { renderMetrics } from "./tabs/metrics.ts";
28
+
29
+ export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "metrics"] as const;
30
+ export type TabId = (typeof TAB_IDS)[number];
31
+
32
+ export const TAB_LABELS: Record<TabId, string> = {
33
+ lobby: "Lobby",
34
+ tasks: "Tasks",
35
+ plan: "Plan",
36
+ quickfix: "Quick fix",
37
+ issues: "Issues",
38
+ metrics: "Metrics",
39
+ };
40
+
41
+ /** Tabs where the prompt is the point: they open in typing mode and typing in browsing mode resumes it. */
42
+ const PROMPT_FIRST: ReadonlySet<TabId> = new Set(["lobby", "plan", "quickfix"]);
43
+
44
+ export type LobbyMode = "type" | "browse";
45
+
46
+ /** Everything the view needs from pi and bot-lobby. */
47
+ export interface LobbyHost {
48
+ rows(): number;
49
+ theme(): LobbyTheme;
50
+ sessionId(): string | undefined;
51
+ /** This session's active task and its runs, as the zen widget sees them. */
52
+ zen(): { task?: Task; runs: readonly AgentRun[] };
53
+ /** Draw the zen scene into at most `height` lines. */
54
+ scene(width: number, height: number): string[];
55
+ /** Advance the scene's clock; returns the delay it wants until the next step. */
56
+ advanceScene(now: number): number;
57
+ feed: LobbyFeed;
58
+ masterBusy(): boolean;
59
+ tasks(): Task[];
60
+ plans(): PlannedTask[];
61
+ comments(taskId: string): PlanComment[];
62
+ metrics(): MetricRecord[];
63
+ /** Send text to the oracle, or start a task when none is active; returns a notice. */
64
+ toOracle(text: string): string | undefined;
65
+ comment(taskId: string, text: string): string;
66
+ startPlanned(plan: PlannedTask): string;
67
+ discardPlan(id: string): void;
68
+ abortMaster(): void;
69
+ hide(): void;
70
+ quickfix: QuickFixQueue;
71
+ planner(): PlanningSession | undefined;
72
+ /** Start a planning session (replacing any other) with these seats on the panel. */
73
+ newPlanner(seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession;
74
+ /** The seats a new session starts with, from settings. */
75
+ defaultPanel(): readonly PanelMember[];
76
+ /** `model · thinking` a panel seat runs on. */
77
+ seatLabel(member: PanelMember): string;
78
+ issues: IssuesState;
79
+ profileLabel(kind: LobbyAgentKind): string;
80
+ requestRender(): void;
81
+ now?(): number;
82
+ }
83
+
84
+ /** Live work speeds the clock up so spinners and the scene move. */
85
+ export const LIVE_MS = 250;
86
+ export const IDLE_MS = 1000;
87
+ /** How often task and metrics data is reread from disk while the lobby is open. */
88
+ export const DATA_REFRESH_MS = 2000;
89
+ const NOTICE_MS = 6000;
90
+
91
+ /** The prompt editor, with its target written into the top border. */
92
+ class LobbyEditor extends Editor {
93
+ label = "";
94
+ paintLabel: (text: string) => string = (text) => text;
95
+
96
+ protected override renderTopBorder(width: number, hiddenLineCount: number): string {
97
+ if (!this.label) return super.renderTopBorder(width, hiddenLineCount);
98
+ const label = ` ${this.label} `;
99
+ const more = hiddenLineCount > 0 ? ` ↑${hiddenLineCount} ` : "";
100
+ const rest = width - 2 - visibleWidth(label) - visibleWidth(more);
101
+ if (rest < 1) return super.renderTopBorder(width, hiddenLineCount);
102
+ return `${this.borderColor("──")}${this.paintLabel(label)}${this.borderColor("─".repeat(rest))}${more ? this.borderColor(more) : ""}`;
103
+ }
104
+ }
105
+
106
+ /** The editor's reverse-video block cursor. */
107
+ const REVERSE_VIDEO = /\x1b\[7m([^\x1b]*)\x1b\[0m/g;
108
+
109
+ interface Notice {
110
+ text: string;
111
+ at: number;
112
+ kind: "info" | "warning";
113
+ }
114
+
115
+ function isPrintable(data: string): string | undefined {
116
+ const kitty = decodeKittyPrintable(data);
117
+ if (kitty !== undefined) return kitty;
118
+ return data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) !== 127 ? data : undefined;
119
+ }
120
+
121
+ export class LobbyView implements Component, Focusable {
122
+ tab: TabId = "lobby";
123
+ mode: LobbyMode = "type";
124
+ private readonly host: LobbyHost;
125
+ private readonly editor: LobbyEditor;
126
+ private focusedState = false;
127
+ private tick = 0;
128
+ private timer: ReturnType<typeof setTimeout> | undefined;
129
+ private running = false;
130
+ private notice: Notice | undefined;
131
+ private readonly drafts: Partial<Record<TabId, string>> = {};
132
+ /** Double-press confirmations, such as discarding a plan. */
133
+ private armed: string | undefined;
134
+
135
+ private chatOffset = 0;
136
+ private tasksSelected = 0;
137
+ private tasksFocus: "list" | "detail" = "list";
138
+ private tasksDetailOffset = 0;
139
+ private commentTarget: string | undefined;
140
+ private planOffset = 0;
141
+ private planFocus: "talk" | "draft" = "talk";
142
+ private planDraftOffset = 0;
143
+ /** Seats for the next planning session, until one exists. */
144
+ private seats: Set<PanelMember> | undefined;
145
+ private fixSelected = 0;
146
+ private fixFocus: "list" | "detail" = "list";
147
+ private fixDetailOffset = 0;
148
+ private issueSelected = 0;
149
+ private issueFocus: "list" | "detail" = "list";
150
+ private issueDetailOffset = 0;
151
+ private issueDraft = false;
152
+ private metricsSelected = 0;
153
+ private metricsBy: GroupBy = "model";
154
+ private metricsSort: SortKey = "runs";
155
+
156
+ private data: { tasks: Task[]; plans: PlannedTask[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; at: number } = {
157
+ tasks: [],
158
+ plans: [],
159
+ comments: new Map(),
160
+ metrics: [],
161
+ at: 0,
162
+ };
163
+
164
+ constructor(tui: TUI, host: LobbyHost, editorTheme: EditorTheme) {
165
+ this.host = host;
166
+ this.editor = new LobbyEditor(tui, editorTheme, { paddingX: 1 });
167
+ this.editor.onSubmit = (text) => this.submit(text);
168
+ }
169
+
170
+ get focused(): boolean {
171
+ return this.focusedState;
172
+ }
173
+
174
+ set focused(value: boolean) {
175
+ this.focusedState = value;
176
+ this.editor.focused = value && this.mode === "type";
177
+ }
178
+
179
+ private now(): number {
180
+ return this.host.now?.() ?? Date.now();
181
+ }
182
+
183
+ /** Start the lobby clock (while visible). */
184
+ start(): void {
185
+ if (this.running) return;
186
+ this.running = true;
187
+ this.refreshData(true);
188
+ this.schedule(0);
189
+ }
190
+
191
+ stop(): void {
192
+ this.running = false;
193
+ if (this.timer) clearTimeout(this.timer);
194
+ this.timer = undefined;
195
+ }
196
+
197
+ dispose(): void {
198
+ this.stop();
199
+ }
200
+
201
+ private schedule(delay: number): void {
202
+ if (this.timer) clearTimeout(this.timer);
203
+ if (!this.running) return;
204
+ this.timer = setTimeout(() => this.step(), delay);
205
+ this.timer.unref?.();
206
+ }
207
+
208
+ private isLive(): boolean {
209
+ const zen = this.host.zen();
210
+ return this.host.masterBusy()
211
+ || Boolean(this.host.quickfix.running)
212
+ || Boolean(this.host.planner()?.busy)
213
+ || this.host.issues.loading
214
+ || zen.runs.some((run) => run.status === "running")
215
+ || Boolean(zen.task && !zen.task.paused && !TERMINAL_STATES.includes(zen.task.state));
216
+ }
217
+
218
+ /** One clock step: advance the scene, reread data when due, repaint. */
219
+ private step(): void {
220
+ if (!this.running) return;
221
+ const now = this.now();
222
+ this.tick += 1;
223
+ let delay = this.isLive() ? LIVE_MS : IDLE_MS;
224
+ if (this.tab === "lobby" && this.host.zen().task) delay = Math.min(delay, this.host.advanceScene(now));
225
+ this.refreshData(false);
226
+ this.host.requestRender();
227
+ this.schedule(delay);
228
+ }
229
+
230
+ /** Reread tasks, plans, comments and metrics from disk (throttled unless forced). */
231
+ refreshData(force: boolean): void {
232
+ const now = this.now();
233
+ if (!force && now - this.data.at < DATA_REFRESH_MS) return;
234
+ const tasks = this.host.tasks();
235
+ const comments = new Map<string, PlanComment[]>();
236
+ if (this.tab === "tasks") {
237
+ const row = this.taskRowList(tasks)[this.tasksSelected];
238
+ if (row?.kind === "task") comments.set(row.id, this.host.comments(row.id));
239
+ }
240
+ this.data = {
241
+ tasks,
242
+ plans: this.host.plans(),
243
+ comments,
244
+ metrics: this.tab === "metrics" ? this.host.metrics() : this.data.metrics,
245
+ at: now,
246
+ };
247
+ }
248
+
249
+ private say(text: string, kind: Notice["kind"] = "info"): void {
250
+ this.notice = { text, at: this.now(), kind };
251
+ }
252
+
253
+ /* ---------------------------------------------------------------- tabs */
254
+
255
+ setTab(tab: TabId): void {
256
+ if (tab === this.tab) return;
257
+ this.drafts[this.tab] = this.editor.getText();
258
+ this.tab = tab;
259
+ this.editor.setText(this.drafts[tab] ?? "");
260
+ this.commentTarget = undefined;
261
+ this.issueDraft = false;
262
+ this.armed = undefined;
263
+ this.setMode(PROMPT_FIRST.has(tab) ? "type" : "browse");
264
+ this.refreshData(true);
265
+ if (tab === "issues" && !this.host.issues.loaded && !this.host.issues.loading) void this.host.issues.refresh().then(() => this.loadIssueDetail());
266
+ this.host.requestRender();
267
+ }
268
+
269
+ private cycleTab(direction: 1 | -1): void {
270
+ const index = TAB_IDS.indexOf(this.tab);
271
+ this.setTab(TAB_IDS[(index + direction + TAB_IDS.length) % TAB_IDS.length]!);
272
+ }
273
+
274
+ private setMode(mode: LobbyMode): void {
275
+ this.mode = this.tab === "metrics" ? "browse" : mode;
276
+ this.editor.focused = this.focusedState && this.mode === "type";
277
+ }
278
+
279
+ /** Open the prompt for a comment on `taskId`'s plan (from any tab). */
280
+ commentOn(taskId: string): void {
281
+ this.setTab("tasks");
282
+ const rows = this.taskRowList(this.data.tasks);
283
+ const index = rows.findIndex((row) => row.id === taskId);
284
+ if (index >= 0) this.tasksSelected = index;
285
+ this.refreshData(true);
286
+ this.commentTarget = taskId;
287
+ this.setMode("type");
288
+ }
289
+
290
+ /** Plan an issue: seed a planning session with it and switch to the Plan tab. */
291
+ async planIssue(number: number): Promise<void> {
292
+ const detail = await this.host.issues.detail(number);
293
+ if (!detail) return this.say(`could not load issue #${number}`, "warning");
294
+ const session = this.host.newPlanner({ issue: { number: detail.number, title: detail.title, ...(detail.url ? { url: detail.url } : {}) }, body: issueText(detail) }, [...this.seatSet()]);
295
+ this.resetPlanScroll();
296
+ this.setTab("plan");
297
+ void session.open();
298
+ }
299
+
300
+ /* --------------------------------------------------------------- input */
301
+
302
+ handleInput(data: string): void {
303
+ this.handleKey(data);
304
+ this.host.requestRender();
305
+ }
306
+
307
+ private handleKey(data: string): void {
308
+ if (matchesKey(data, Key.alt("l"))) return this.host.hide();
309
+ if (matchesKey(data, Key.tab)) return this.cycleTab(1);
310
+ if (matchesKey(data, Key.shift("tab"))) return this.cycleTab(-1);
311
+ for (const [index, tab] of TAB_IDS.entries()) {
312
+ if (matchesKey(data, Key.alt(String(index + 1) as "1"))) return this.setTab(tab);
313
+ }
314
+ if (matchesKey(data, Key.ctrl("c"))) {
315
+ if (this.editor.getText()) return this.editor.setText("");
316
+ return this.host.hide();
317
+ }
318
+ if (matchesKey(data, Key.pageUp)) return this.scroll(-10);
319
+ if (matchesKey(data, Key.pageDown)) return this.scroll(10);
320
+ if (this.mode === "type") return this.typeKey(data);
321
+ return this.browseKey(data);
322
+ }
323
+
324
+ private typeKey(data: string): void {
325
+ if (matchesKey(data, Key.escape)) {
326
+ if (this.tab === "lobby" && this.host.masterBusy() && !this.editor.getText().trim()) {
327
+ this.host.abortMaster();
328
+ return this.say("stopping the oracle…");
329
+ }
330
+ this.commentTarget = undefined;
331
+ this.issueDraft = false;
332
+ return this.setMode("browse");
333
+ }
334
+ this.editor.handleInput(data);
335
+ }
336
+
337
+ private browseKey(data: string): void {
338
+ if (matchesKey(data, Key.up) || (!PROMPT_FIRST.has(this.tab) && data === "k")) return this.scroll(-1);
339
+ if (matchesKey(data, Key.down) || (!PROMPT_FIRST.has(this.tab) && data === "j")) return this.scroll(1);
340
+ if (!PROMPT_FIRST.has(this.tab) && /^[1-6]$/.test(data)) return this.setTab(TAB_IDS[Number(data) - 1]!);
341
+ if (data === "i" && this.tab !== "metrics" && this.tab !== "tasks" && this.tab !== "issues") return this.setMode("type");
342
+ if (this.tabCommand(data)) return;
343
+ const printable = isPrintable(data);
344
+ if (printable && PROMPT_FIRST.has(this.tab)) {
345
+ this.setMode("type");
346
+ this.editor.handleInput(data);
347
+ }
348
+ }
349
+
350
+ /** Arrow keys and page keys: scroll or select, depending on the tab and its focus. */
351
+ private scroll(delta: number): void {
352
+ switch (this.tab) {
353
+ case "lobby":
354
+ this.chatOffset = Math.max(0, this.chatOffset - delta);
355
+ return;
356
+ case "plan":
357
+ if (this.planFocus === "draft") this.planDraftOffset = Math.max(0, this.planDraftOffset + delta);
358
+ else this.planOffset = Math.max(0, this.planOffset - delta);
359
+ return;
360
+ case "tasks":
361
+ if (this.tasksFocus === "detail" || Math.abs(delta) > 1) this.tasksDetailOffset = Math.max(0, this.tasksDetailOffset + delta);
362
+ else this.selectTask(this.tasksSelected + delta);
363
+ return;
364
+ case "quickfix":
365
+ if (this.fixFocus === "detail" || Math.abs(delta) > 1) this.fixDetailOffset = Math.max(0, this.fixDetailOffset + delta);
366
+ else {
367
+ this.fixSelected = Math.max(0, Math.min(this.host.quickfix.jobs.length - 1, this.fixSelected + delta));
368
+ this.fixDetailOffset = 0;
369
+ }
370
+ return;
371
+ case "issues":
372
+ if (this.issueFocus === "detail" || Math.abs(delta) > 1) this.issueDetailOffset = Math.max(0, this.issueDetailOffset + delta);
373
+ else {
374
+ this.issueSelected = Math.max(0, Math.min(this.host.issues.issues.length - 1, this.issueSelected + delta));
375
+ this.issueDetailOffset = 0;
376
+ void this.loadIssueDetail();
377
+ }
378
+ return;
379
+ case "metrics":
380
+ this.metricsSelected = Math.max(0, this.metricsSelected + delta);
381
+ return;
382
+ }
383
+ }
384
+
385
+ private selectTask(index: number): void {
386
+ const rows = this.taskRowList(this.data.tasks);
387
+ this.tasksSelected = Math.max(0, Math.min(rows.length - 1, index));
388
+ this.tasksDetailOffset = 0;
389
+ this.refreshData(true);
390
+ }
391
+
392
+ private async loadIssueDetail(): Promise<void> {
393
+ const issue = this.host.issues.issues[this.issueSelected];
394
+ if (issue) await this.host.issues.detail(issue.number);
395
+ }
396
+
397
+ /** The tab's single-key commands in browsing mode; true when the key was one. */
398
+ private tabCommand(data: string): boolean {
399
+ const enter = matchesKey(data, Key.enter);
400
+ const escape = matchesKey(data, Key.escape);
401
+ switch (this.tab) {
402
+ case "lobby":
403
+ if (escape && this.host.masterBusy()) {
404
+ this.host.abortMaster();
405
+ this.say("stopping the oracle…");
406
+ return true;
407
+ }
408
+ if (data === "c") {
409
+ const task = this.host.zen().task;
410
+ if (task) this.commentOn(task.id);
411
+ else this.say("no task in this session to comment on", "warning");
412
+ return true;
413
+ }
414
+ return enter ? (this.setMode("type"), true) : false;
415
+ case "tasks":
416
+ return this.tasksCommand(data, enter, escape);
417
+ case "plan":
418
+ return this.planCommand(data, enter, escape);
419
+ case "quickfix":
420
+ if (enter) return (this.fixFocus = this.fixFocus === "list" ? "detail" : "list"), true;
421
+ if (escape && this.fixFocus === "detail") return (this.fixFocus = "list"), true;
422
+ if (data === "x") {
423
+ const job = newestFirst(this.host.quickfix.jobs)[this.fixSelected];
424
+ if (job && this.host.quickfix.cancel(job.id)) this.say(`cancelling ${job.id}`);
425
+ return true;
426
+ }
427
+ return false;
428
+ case "issues":
429
+ return this.issuesCommand(data, enter, escape);
430
+ case "metrics":
431
+ if (data === "g") return (this.metricsBy = this.metricsBy === "model" ? "model-kind" : "model", this.metricsSelected = 0), true;
432
+ if (data === "s") return (this.metricsSort = SORT_KEYS[(SORT_KEYS.indexOf(this.metricsSort) + 1) % SORT_KEYS.length]!), true;
433
+ if (data === "r") return this.refreshData(true), true;
434
+ return false;
435
+ }
436
+ }
437
+
438
+ private tasksCommand(data: string, enter: boolean, escape: boolean): boolean {
439
+ const row = this.taskRowList(this.data.tasks)[this.tasksSelected];
440
+ if (enter) return (this.tasksFocus = this.tasksFocus === "list" ? "detail" : "list"), true;
441
+ if (escape) return (this.tasksFocus = "list"), true;
442
+ if (data === "r") return this.refreshData(true), true;
443
+ if (!row) return false;
444
+ if (data === "c") {
445
+ const task = this.data.tasks.find((entry) => entry.id === row.id);
446
+ if (row.kind !== "task" || !task) this.say("pick a task to comment on its plan", "warning");
447
+ else if (TERMINAL_STATES.includes(task.state)) this.say(`${task.id} is ${task.state}; there is no plan left to change`, "warning");
448
+ else {
449
+ this.commentTarget = task.id;
450
+ this.setMode("type");
451
+ }
452
+ return true;
453
+ }
454
+ const plan = row.kind === "plan" ? this.data.plans.find((entry) => entry.id === row.id) : undefined;
455
+ if (data === "s" && plan) {
456
+ this.say(this.host.startPlanned(plan));
457
+ this.refreshData(true);
458
+ return true;
459
+ }
460
+ if (data === "d" && plan) {
461
+ if (this.armed !== `discard:${plan.id}`) {
462
+ this.armed = `discard:${plan.id}`;
463
+ this.say(`press d again to discard ${plan.id}`, "warning");
464
+ return true;
465
+ }
466
+ this.armed = undefined;
467
+ this.host.discardPlan(plan.id);
468
+ this.say(`discarded ${plan.id}`);
469
+ this.refreshData(true);
470
+ return true;
471
+ }
472
+ return false;
473
+ }
474
+
475
+ /** Seats for the next round: the session's, or the ones chosen before it started. */
476
+ private seatSet(): Set<PanelMember> {
477
+ const session = this.host.planner();
478
+ if (session) return session.seats;
479
+ this.seats ??= new Set(this.host.defaultPanel());
480
+ return this.seats;
481
+ }
482
+
483
+ private resetPlanScroll(): void {
484
+ this.planOffset = 0;
485
+ this.planDraftOffset = 0;
486
+ }
487
+
488
+ private planCommand(data: string, enter: boolean, escape: boolean): boolean {
489
+ const session = this.host.planner();
490
+ if (enter) return (this.planFocus = this.planFocus === "talk" ? "draft" : "talk"), true;
491
+ const seat = /^[1-4]$/.test(data) ? PANEL_MEMBERS[Number(data) - 1] : undefined;
492
+ if (seat) {
493
+ let seated: boolean;
494
+ if (session) seated = session.toggle(seat);
495
+ else {
496
+ const seats = this.seatSet();
497
+ seated = !seats.delete(seat);
498
+ if (seated) seats.add(seat);
499
+ }
500
+ this.say(`${MEMBER_LABELS[seat]} ${seated ? "joins" : "leaves"} the panel from the next round`);
501
+ return true;
502
+ }
503
+ if (data === "x" || (escape && session?.busy)) {
504
+ if (session?.busy) {
505
+ session.cancel();
506
+ this.say("stopping the panel…");
507
+ }
508
+ return true;
509
+ }
510
+ if (data === "r") {
511
+ if (session?.retryable) void session.retry();
512
+ else this.say("nothing to retry", "warning");
513
+ return true;
514
+ }
515
+ if (data === "s") {
516
+ try {
517
+ const saved = session?.save();
518
+ this.say(saved ? `saved ${saved.id} — start it from Tasks (2) when you are ready` : "nothing to save yet", saved ? "info" : "warning");
519
+ } catch (error) {
520
+ this.say((error as Error).message, "warning");
521
+ }
522
+ this.refreshData(true);
523
+ return true;
524
+ }
525
+ if (data === "n") {
526
+ const unsaved = session && session.messages.length > 0 && !session.saved;
527
+ if (unsaved && this.armed !== "new-plan") {
528
+ this.armed = "new-plan";
529
+ this.say("this plan is not saved — press n again to start over anyway", "warning");
530
+ return true;
531
+ }
532
+ this.armed = undefined;
533
+ session?.cancel();
534
+ this.host.newPlanner(undefined, [...this.seatSet()]);
535
+ this.resetPlanScroll();
536
+ this.setMode("type");
537
+ return true;
538
+ }
539
+ return false;
540
+ }
541
+
542
+ private issuesCommand(data: string, enter: boolean, escape: boolean): boolean {
543
+ const issue = this.host.issues.issues[this.issueSelected];
544
+ if (enter) {
545
+ this.issueFocus = this.issueFocus === "list" ? "detail" : "list";
546
+ void this.loadIssueDetail();
547
+ return true;
548
+ }
549
+ if (escape) return (this.issueFocus = "list"), true;
550
+ if (data === "r") {
551
+ this.host.issues.details.clear();
552
+ void this.host.issues.refresh().then(() => this.loadIssueDetail());
553
+ return true;
554
+ }
555
+ if (data === "n") {
556
+ this.issueDraft = true;
557
+ this.setMode("type");
558
+ return true;
559
+ }
560
+ if (data === "p" && issue) {
561
+ void this.planIssue(issue.number);
562
+ return true;
563
+ }
564
+ return false;
565
+ }
566
+
567
+ /* -------------------------------------------------------------- submit */
568
+
569
+ private submit(text: string): void {
570
+ const body = text.trim();
571
+ if (!body) return;
572
+ this.editor.addToHistory(body);
573
+ switch (this.tab) {
574
+ case "lobby": {
575
+ const notice = this.host.toOracle(body);
576
+ if (notice) this.say(notice);
577
+ this.chatOffset = 0;
578
+ return;
579
+ }
580
+ case "tasks":
581
+ if (!this.commentTarget) {
582
+ this.editor.setText(body);
583
+ return this.say("press c on a task to comment on its plan", "warning");
584
+ }
585
+ this.say(this.host.comment(this.commentTarget, body));
586
+ this.commentTarget = undefined;
587
+ this.setMode("browse");
588
+ this.refreshData(true);
589
+ return;
590
+ case "plan": {
591
+ const session = this.host.planner() ?? this.host.newPlanner(undefined, [...this.seatSet()]);
592
+ if (session.busy) {
593
+ this.editor.setText(body);
594
+ return this.say("the panel is still thinking — x stops it", "warning");
595
+ }
596
+ this.resetPlanScroll();
597
+ session.send(body).catch((error: Error) => this.say(error.message, "warning"));
598
+ return;
599
+ }
600
+ case "quickfix": {
601
+ const job = this.host.quickfix.submit(body);
602
+ this.fixSelected = 0;
603
+ this.fixDetailOffset = 0;
604
+ this.say(this.host.quickfix.running?.id === job.id ? `${job.id} started` : `${job.id} queued behind the running quick fix`);
605
+ return;
606
+ }
607
+ case "issues":
608
+ if (!this.issueDraft) {
609
+ this.editor.setText(body);
610
+ return this.say("press n to file a new issue", "warning");
611
+ }
612
+ this.issueDraft = false;
613
+ this.setMode("browse");
614
+ this.say("filing the issue…");
615
+ void this.host.issues.create(body);
616
+ return;
617
+ case "metrics":
618
+ return;
619
+ }
620
+ }
621
+
622
+ /* -------------------------------------------------------------- render */
623
+
624
+ private taskRowList(tasks: readonly Task[]): TaskRow[] {
625
+ return taskRows(tasks, this.data.plans, this.host.sessionId(), this.now());
626
+ }
627
+
628
+ private tabBar(width: number, theme: LobbyTheme): string {
629
+ const zen = this.host.zen();
630
+ const badges: Partial<Record<TabId, string>> = {};
631
+ const active = this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state)).length;
632
+ if (active > 0) badges.tasks = String(active);
633
+ if (this.host.planner()?.busy) badges.plan = spinner(this.tick);
634
+ if (this.host.quickfix.running) badges.quickfix = spinner(this.tick);
635
+ if (this.host.issues.issues.length > 0) badges.issues = String(this.host.issues.issues.length);
636
+ const tabs = TAB_IDS.map((tab, index) => {
637
+ const label = `${index + 1} ${TAB_LABELS[tab]}${badges[tab] ? ` ${badges[tab]}` : ""}`;
638
+ if (tab !== this.tab) return paint(theme, "muted", ` ${label} `);
639
+ const text = bold(theme, paint(theme, "accent", ` ${label} `));
640
+ return theme.bg ? theme.bg("selectedBg", text) : `[${text}]`;
641
+ });
642
+ const brand = bold(theme, paint(theme, "accent", " ◆ bot-lobby "));
643
+ const left = `${brand}${paint(theme, "borderMuted", "│")}${tabs.join("")}`;
644
+ const room = width - visibleWidth(left) - 1;
645
+ const dot = paint(theme, this.host.masterBusy() ? "accent" : "dim", this.host.masterBusy() ? spinner(this.tick) : "●");
646
+ const state = zen.task ? paint(theme, "muted", zen.task.paused ? `${zen.task.state} (paused)` : zen.task.state) : "";
647
+ // Long task ids give way to the state, then to the dot alone.
648
+ const choices = zen.task
649
+ ? [`${dot} ${zen.task.id} ${state} `, `${dot} ${state} `, `${dot} `]
650
+ : [paint(theme, "dim", "no task in this session "), ""];
651
+ const status = choices.find((choice) => visibleWidth(choice) <= room) ?? "";
652
+ const gap = width - visibleWidth(left) - visibleWidth(status);
653
+ return gap >= 1 ? `${left}${" ".repeat(gap)}${status}` : fit(left, width);
654
+ }
655
+
656
+ private promptLabel(): string {
657
+ const zen = this.host.zen();
658
+ const browsing = this.mode === "browse";
659
+ switch (this.tab) {
660
+ case "lobby":
661
+ if (!zen.task) return browsing ? "describe a task to start — i to type" : "describe a task to start · enter starts it";
662
+ return browsing ? "message the oracle — i to type" : this.host.masterBusy() ? "message the oracle · enter steers the running turn" : "message the oracle";
663
+ case "tasks":
664
+ return this.commentTarget ? `comment on ${this.commentTarget}'s plan · enter sends it to the oracle` : "c comments on the selected task's plan";
665
+ case "plan": {
666
+ const session = this.host.planner();
667
+ if (session?.busy) return "the panel is thinking · x stops it";
668
+ return session && session.messages.length > 0 ? "answer the panel · number your answers to match the questions" : "describe the task you want to plan";
669
+ }
670
+ case "quickfix":
671
+ return this.host.quickfix.running ? "describe a quick change · queues behind the running one" : "describe a quick change · runs now, beside any task";
672
+ case "issues":
673
+ return this.issueDraft ? "new issue · first line is the title" : "n files a new issue";
674
+ case "metrics":
675
+ return "";
676
+ }
677
+ }
678
+
679
+ private promptLines(width: number, theme: LobbyTheme): string[] {
680
+ if (this.tab === "metrics") return [];
681
+ this.editor.label = this.promptLabel();
682
+ this.editor.paintLabel = (text) => paint(theme, this.mode === "type" ? "accent" : "dim", text);
683
+ this.editor.borderColor = (text) => paint(theme, this.mode === "type" ? "border" : "borderMuted", text);
684
+ const lines = this.editor.render(width);
685
+ // The editor always draws its block cursor; while browsing it would suggest the prompt has focus.
686
+ return this.mode === "type" ? lines : lines.map((line) => line.replace(REVERSE_VIDEO, "$1"));
687
+ }
688
+
689
+ private hints(): string {
690
+ if (this.mode === "type") {
691
+ const esc = this.tab === "lobby" && this.host.masterBusy() ? "esc stops the oracle" : "esc browse";
692
+ return `enter send · shift+enter newline · ${esc} · tab next tab · alt+l hide lobby`;
693
+ }
694
+ const common = "tab next · alt+l hide";
695
+ switch (this.tab) {
696
+ case "lobby":
697
+ return `type to talk · ↑↓ scroll · c comment on the plan · ${this.host.masterBusy() ? "esc stop the oracle · " : ""}${common}`;
698
+ case "tasks":
699
+ return `↑↓ select · enter detail · c comment · s start plan · d discard plan · r refresh · ${common}`;
700
+ case "plan":
701
+ return `type to answer · 1-4 seat DEV DESIGN QA RESEARCH · s save · n new · x stop · r retry · enter switch pane · ${common}`;
702
+ case "quickfix":
703
+ return `type a change · ↑↓ select · enter detail · x cancel · ${common}`;
704
+ case "issues":
705
+ return `↑↓ select · enter read · p plan it · n new issue · r refresh · ${common}`;
706
+ case "metrics":
707
+ return `↑↓ select · g group by agent · s sort · r refresh · ${common}`;
708
+ }
709
+ }
710
+
711
+ private hintLine(width: number, theme: LobbyTheme): string {
712
+ const notice = this.notice && this.now() - this.notice.at < NOTICE_MS ? this.notice : undefined;
713
+ if (notice) return fit(paint(theme, notice.kind === "warning" ? "warning" : "accent", ` ${notice.text}`), width);
714
+ const mode = this.mode === "type" ? paint(theme, "accent", " TYPE ") : paint(theme, "muted", " BROWSE ");
715
+ return fit(`${mode}${paint(theme, "dim", ` ${this.hints()}`)}`, width);
716
+ }
717
+
718
+ private body(width: number, height: number, theme: LobbyTheme): string[] {
719
+ const now = this.now();
720
+ switch (this.tab) {
721
+ case "lobby":
722
+ return this.homeBody(width, height, theme, now);
723
+ case "tasks":
724
+ return this.tasksBody(width, height, theme, now);
725
+ case "plan":
726
+ return renderPlan({
727
+ ...(this.host.planner() ? { session: this.planView(this.host.planner()!) } : {}),
728
+ profile: this.host.profileLabel("planner"),
729
+ seats: this.seatViews(undefined),
730
+ offset: this.planOffset,
731
+ focus: this.planFocus,
732
+ draftOffset: this.planDraftOffset,
733
+ tick: this.tick,
734
+ }, width, height, theme);
735
+ case "quickfix":
736
+ this.fixSelected = Math.min(this.fixSelected, Math.max(0, this.host.quickfix.jobs.length - 1));
737
+ return renderQuickFix({ jobs: this.host.quickfix.jobs, selected: this.fixSelected, focus: this.fixFocus, detailOffset: this.fixDetailOffset, profile: this.host.profileLabel("quickfix"), tick: this.tick, now }, width, height, theme);
738
+ case "issues": {
739
+ const issues = this.host.issues;
740
+ this.issueSelected = Math.min(this.issueSelected, Math.max(0, issues.issues.length - 1));
741
+ const selected = issues.issues[this.issueSelected];
742
+ const detail = selected ? issues.details.get(selected.number) : undefined;
743
+ return renderIssues({
744
+ issues: issues.issues, selected: this.issueSelected, ...(detail ? { detail } : {}), focus: this.issueFocus, detailOffset: this.issueDetailOffset,
745
+ loading: issues.loading, loaded: issues.loaded, ...(issues.error ? { error: issues.error } : {}), ...(issues.notice ? { notice: issues.notice } : {}), tick: this.tick, now,
746
+ }, width, height, theme);
747
+ }
748
+ case "metrics": {
749
+ const records = collectMetrics(this.data.metrics, this.data.tasks);
750
+ const groups = sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort);
751
+ this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
752
+ const taskTimes = taskTimesByModel(this.data.tasks, records);
753
+ return renderMetrics({ groups, taskTimes, records, stats: taskStats(this.data.tasks), by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected }, width, height, theme);
754
+ }
755
+ }
756
+ }
757
+
758
+ /** The roster: the oracle chairing, then every domain seat in order, seated or not. */
759
+ private seatViews(session: PlanningSession | undefined): SeatView[] {
760
+ const seats = session?.seats ?? this.seatSet();
761
+ const oracleQuestions = session?.questions.filter((question) => question.from === ORACLE_LABEL).length ?? 0;
762
+ // While the seats work the oracle waits for them; its own part fails only when the round reports an error.
763
+ const status: SeatView["status"] = !session || session.turns === 0 ? "idle" : session.busy ? "thinking" : session.error ? "failed" : "done";
764
+ const oracle: SeatView = {
765
+ label: ORACLE_LABEL,
766
+ seated: true,
767
+ status,
768
+ ...(session?.busy && session.step ? { step: session.step } : {}),
769
+ questions: oracleQuestions,
770
+ ready: session?.reply?.status === "ready",
771
+ profile: this.host.profileLabel("planner"),
772
+ };
773
+ const members = PANEL_MEMBERS.map((member): SeatView => {
774
+ const state = session?.members.find((entry) => entry.member === member);
775
+ return {
776
+ label: MEMBER_LABELS[member],
777
+ seated: seats.has(member),
778
+ status: state ? (state.status === "thinking" ? "thinking" : state.status) : "idle",
779
+ ...(state?.step ? { step: state.step } : {}),
780
+ questions: state?.reply?.questions.length ?? 0,
781
+ ready: state?.reply?.status === "ready",
782
+ profile: this.host.seatLabel(member),
783
+ };
784
+ });
785
+ return [oracle, ...members];
786
+ }
787
+
788
+ private planView(session: PlanningSession): PlanView {
789
+ return {
790
+ messages: session.messages,
791
+ ...(session.reply ? { reply: session.reply } : {}),
792
+ questions: session.questions,
793
+ notes: session.notes,
794
+ seats: this.seatViews(session),
795
+ busy: session.busy,
796
+ ...(session.step ? { step: session.step } : {}),
797
+ ...(session.error ? { error: session.error } : {}),
798
+ turns: session.turns,
799
+ ...(session.seed ? { seed: session.seed } : {}),
800
+ ...(session.saved ? { saved: session.saved } : {}),
801
+ ...(session.title ? { title: session.title } : {}),
802
+ };
803
+ }
804
+
805
+ private homeBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
806
+ const zen = this.host.zen();
807
+ const feed = this.host.feed;
808
+ const sessionId = this.host.sessionId();
809
+ const others = this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && task.ownerSessionId !== sessionId).length;
810
+ return renderHome({
811
+ ...(zen.task ? { task: zen.task, scene: (w: number, h: number) => this.host.scene(w, h) } : {}),
812
+ chat: feed.chat,
813
+ ...(feed.reply ? { liveReply: feed.reply } : {}),
814
+ activity: feed.activity,
815
+ thoughts: feed.thoughts,
816
+ busy: this.host.masterBusy(),
817
+ others,
818
+ pending: this.data.plans.filter((plan) => plan.status === "pending").length,
819
+ chatOffset: this.chatOffset,
820
+ tick: this.tick,
821
+ now,
822
+ }, width, height, theme);
823
+ }
824
+
825
+ private tasksBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
826
+ const rows = this.taskRowList(this.data.tasks);
827
+ this.tasksSelected = Math.min(this.tasksSelected, Math.max(0, rows.length - 1));
828
+ const row = rows[this.tasksSelected];
829
+ const detailWidth = width >= 90 ? Math.max(24, width - Math.round((width - 3) * 0.38) - 3) : width;
830
+ let detail: string[] = [];
831
+ if (row?.kind === "task") {
832
+ const task = this.data.tasks.find((entry) => entry.id === row.id);
833
+ if (task) detail = taskDetailLines(task, this.data.comments.get(task.id) ?? [], this.host.sessionId(), detailWidth, now, theme);
834
+ } else if (row?.kind === "plan") {
835
+ const plan = this.data.plans.find((entry) => entry.id === row.id);
836
+ if (plan) detail = planDetailLines(plan, detailWidth, now, theme);
837
+ }
838
+ return renderTasks({ rows, selected: this.tasksSelected, detail, focus: this.tasksFocus, detailOffset: this.tasksDetailOffset }, width, height, theme);
839
+ }
840
+
841
+ render(width: number): string[] {
842
+ const theme = this.host.theme();
843
+ const rows = Math.max(1, this.host.rows());
844
+ const top = [this.tabBar(width, theme), paint(theme, "borderMuted", "─".repeat(Math.max(0, width)))];
845
+ const prompt = this.promptLines(width, theme);
846
+ const hint = this.hintLine(width, theme);
847
+ const bodyHeight = Math.max(0, rows - top.length - prompt.length - 1);
848
+ const body = this.body(width, bodyHeight, theme);
849
+ return [...top, ...body, ...prompt, hint].slice(0, rows).map((line) => fit(line, width));
850
+ }
851
+
852
+ invalidate(): void {
853
+ this.editor.invalidate();
854
+ }
855
+ }