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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +238 -10
  2. package/package.json +5 -2
  3. package/prompts/master.md +12 -0
  4. package/prompts/panel.md +44 -0
  5. package/prompts/planner.md +69 -0
  6. package/prompts/quickfix.md +41 -0
  7. package/src/execution/agent-runner.ts +7 -2
  8. package/src/execution/pi-runner.ts +25 -0
  9. package/src/index.ts +3 -0
  10. package/src/lobby/ask.ts +226 -0
  11. package/src/lobby/feed.ts +253 -0
  12. package/src/lobby/issues.ts +227 -0
  13. package/src/lobby/keys.ts +51 -0
  14. package/src/lobby/layout.ts +363 -0
  15. package/src/lobby/markdown.ts +48 -0
  16. package/src/lobby/planner.ts +581 -0
  17. package/src/lobby/quickfix.ts +227 -0
  18. package/src/lobby/runtime.ts +591 -0
  19. package/src/lobby/tabs/home.ts +242 -0
  20. package/src/lobby/tabs/issues.ts +72 -0
  21. package/src/lobby/tabs/metrics.ts +275 -0
  22. package/src/lobby/tabs/plan.ts +242 -0
  23. package/src/lobby/tabs/quickfix.ts +129 -0
  24. package/src/lobby/tabs/tasks.ts +243 -0
  25. package/src/lobby/view.ts +1610 -0
  26. package/src/pi/activity.ts +120 -0
  27. package/src/pi/commands.ts +19 -57
  28. package/src/pi/events.ts +5 -2
  29. package/src/pi/model-support.ts +34 -0
  30. package/src/pi/run-summary.ts +2 -0
  31. package/src/pi/settings-ui.ts +69 -1
  32. package/src/pi/start-task.ts +63 -0
  33. package/src/pi/tools.ts +2 -2
  34. package/src/pi/ui.ts +116 -55
  35. package/src/schemas/configuration.ts +102 -3
  36. package/src/schemas/findings.ts +6 -0
  37. package/src/schemas/task.ts +1 -0
  38. package/src/state/backlog.ts +106 -0
  39. package/src/state/comments.ts +136 -0
  40. package/src/state/metrics.ts +305 -0
  41. package/src/workflow/workflow.ts +39 -7
@@ -0,0 +1,1610 @@
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 search bar while one is in force, a
4
+ * prompt at the bottom whose target follows the tab (the oracle, a plan
5
+ * comment, the planner, a comment on one draft line, a quick fix) and a
6
+ * key-hint line. Two modes, like a modal editor: typing mode sends keys to the
7
+ * prompt; browsing mode (esc) moves through lists and runs the tab's
8
+ * single-key commands. The shortcuts in `keys.ts` (hide, help, search, tabs,
9
+ * pane toggles, paging) work in both. Everything bot-lobby-specific arrives
10
+ * through `LobbyHost`, so the view renders and reacts the same in tests.
11
+ */
12
+ import { decodeKittyPrintable, Editor, Input, Key, matchesKey, visibleWidth, type Component, type EditorTheme, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
13
+ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
14
+ import type { AgentRun } from "../schemas/findings.ts";
15
+ import type { PlannedTask } from "../state/backlog.ts";
16
+ import type { PlanComment } from "../state/comments.ts";
17
+ import { aggregateMetrics, collectMetrics, SORT_KEYS, sortGroups, taskStats, taskTimesByModel, type GroupBy, type MetricRecord, type SortKey } from "../state/metrics.ts";
18
+ import { PANEL_MEMBERS, type LobbyAgentKind, type LobbyPanel, type PanelMember } from "../schemas/configuration.ts";
19
+ import type { LobbyFeed } from "./feed.ts";
20
+ import type { QuickFixQueue } from "./quickfix.ts";
21
+ import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } from "./planner.ts";
22
+ import { issueText, type IssuesState } from "./issues.ts";
23
+ import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, type KeyMap, type LobbyAction } from "./keys.ts";
24
+ import { beside, bold, box, fit, highlight, paint, spinner, wrapHanging, type LobbyTheme, type PaneLayout } from "./layout.ts";
25
+ import { HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
26
+ import { filterRows, planDetailLines, renderTasks, taskDetailLines, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
27
+ import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
28
+ import { filterJobs, newestFirst, renderQuickFix } from "./tabs/quickfix.ts";
29
+ import { renderIssues } from "./tabs/issues.ts";
30
+ import { filterRecords, renderMetrics } from "./tabs/metrics.ts";
31
+
32
+ export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "metrics"] as const;
33
+ export type TabId = (typeof TAB_IDS)[number];
34
+
35
+ export const TAB_LABELS: Record<TabId, string> = {
36
+ lobby: "Lobby",
37
+ tasks: "Tasks",
38
+ plan: "Plan",
39
+ quickfix: "Quick fix",
40
+ issues: "Issues",
41
+ metrics: "Metrics",
42
+ };
43
+
44
+ /** The tabs on show: Issues only while `lobby.issues` switches it on. */
45
+ export function visibleTabs(issues: boolean): TabId[] {
46
+ return TAB_IDS.filter((tab) => issues || tab !== "issues");
47
+ }
48
+
49
+ /** Tabs where the prompt is the point: they open in typing mode and typing in browsing mode resumes it. */
50
+ const PROMPT_FIRST: ReadonlySet<TabId> = new Set(["lobby", "plan", "quickfix"]);
51
+
52
+ export type LobbyMode = "type" | "browse";
53
+
54
+ /** What each pane toggle is called in notices and help. */
55
+ const PANEL_NAMES: Record<LobbyPanel, string> = { scene: "zen scene", conversation: "conversation", activity: "activity log", thinking: "thinking" };
56
+ const PANEL_ACTIONS: Record<LobbyPanel, LobbyAction> = { scene: "toggleScene", conversation: "toggleConversation", activity: "toggleActivity", thinking: "toggleThinking" };
57
+
58
+ /** Everything the view needs from pi and bot-lobby. */
59
+ export interface LobbyHost {
60
+ rows(): number;
61
+ theme(): LobbyTheme;
62
+ sessionId(): string | undefined;
63
+ /** This session's active task and its runs, as the zen widget sees them. */
64
+ zen(): { task?: Task; runs: readonly AgentRun[] };
65
+ /** Draw the zen scene into at most `height` lines. */
66
+ scene(width: number, height: number): string[];
67
+ /** Advance the scene's clock; returns the delay it wants until the next step. */
68
+ advanceScene(now: number): number;
69
+ feed: LobbyFeed;
70
+ masterBusy(): boolean;
71
+ tasks(): Task[];
72
+ plans(): PlannedTask[];
73
+ comments(taskId: string): PlanComment[];
74
+ metrics(): MetricRecord[];
75
+ /** Send text to the oracle, or start a task when none is active; returns a notice. */
76
+ toOracle(text: string): string | undefined;
77
+ comment(taskId: string, text: string): string;
78
+ startPlanned(plan: PlannedTask): string;
79
+ discardPlan(id: string): void;
80
+ abortMaster(): void;
81
+ hide(): void;
82
+ quickfix: QuickFixQueue;
83
+ planner(): PlanningSession | undefined;
84
+ /** Start a planning session (replacing any other) with these seats on the panel. */
85
+ newPlanner(seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession;
86
+ /** The oracle puts the panel's open questions to the user, one questionnaire at a time; returns a notice. */
87
+ answerPanel(): Promise<string>;
88
+ /** The seats a new session starts with, from settings. */
89
+ defaultPanel(): readonly PanelMember[];
90
+ /** `model · thinking` a panel seat runs on. */
91
+ seatLabel(member: PanelMember): string;
92
+ issues: IssuesState;
93
+ /** The Issues tab is switched on (`lobby.issues`). */
94
+ issuesEnabled(): boolean;
95
+ /** Which panes show (`lobby.panels`), and remembering a change. */
96
+ panels(): Record<LobbyPanel, boolean>;
97
+ savePanels(panels: Record<LobbyPanel, boolean>): void;
98
+ /** Key overrides from the config (`lobby.keys`). */
99
+ keys(): Readonly<Record<string, string>>;
100
+ /** Open bot-lobby's settings (or one agent's entry); the lobby steps aside and rereads the config after. */
101
+ openSettings(entry?: "quickfix" | "planner"): Promise<void>;
102
+ profileLabel(kind: LobbyAgentKind): string;
103
+ requestRender(): void;
104
+ now?(): number;
105
+ }
106
+
107
+ /** Live work speeds the clock up so spinners and the scene move. */
108
+ export const LIVE_MS = 250;
109
+ export const IDLE_MS = 1000;
110
+ /** How often task and metrics data is reread from disk while the lobby is open. */
111
+ export const DATA_REFRESH_MS = 2000;
112
+ const NOTICE_MS = 6000;
113
+ /** Lines one wheel notch scrolls. */
114
+ const WHEEL_LINES = 3;
115
+
116
+ /** The prompt editor, with its target written into the top border. */
117
+ class LobbyEditor extends Editor {
118
+ label = "";
119
+ paintLabel: (text: string) => string = (text) => text;
120
+
121
+ protected override renderTopBorder(width: number, hiddenLineCount: number): string {
122
+ if (!this.label) return super.renderTopBorder(width, hiddenLineCount);
123
+ const label = ` ${this.label} `;
124
+ const more = hiddenLineCount > 0 ? ` ↑${hiddenLineCount} ` : "";
125
+ const rest = width - 2 - visibleWidth(label) - visibleWidth(more);
126
+ if (rest < 1) return super.renderTopBorder(width, hiddenLineCount);
127
+ return `${this.borderColor("──")}${this.paintLabel(label)}${this.borderColor("─".repeat(rest))}${more ? this.borderColor(more) : ""}`;
128
+ }
129
+ }
130
+
131
+ /** The editor's reverse-video block cursor. */
132
+ const REVERSE_VIDEO = /\x1b\[7m([^\x1b]*)\x1b\[0m/g;
133
+
134
+ interface Notice {
135
+ text: string;
136
+ at: number;
137
+ kind: "info" | "warning";
138
+ }
139
+
140
+ /** A key and what it does, for the help screen; `hint` is the short form on the hint line. */
141
+ interface KeyHelp {
142
+ key: string;
143
+ text: string;
144
+ hint?: string;
145
+ }
146
+
147
+ function isPrintable(data: string): string | undefined {
148
+ const kitty = decodeKittyPrintable(data);
149
+ if (kitty !== undefined) return kitty;
150
+ return data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) !== 127 ? data : undefined;
151
+ }
152
+
153
+ /** A mouse report in SGR encoding (`ESC [ < b ; x ; y M` or `m`), zero-based cells. */
154
+ export interface MouseReport {
155
+ kind: "press" | "release" | "wheel";
156
+ button: number;
157
+ x: number;
158
+ y: number;
159
+ /** Wheel direction: -1 up, 1 down. */
160
+ delta: number;
161
+ }
162
+
163
+ const SGR_MOUSE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
164
+
165
+ /** Parse an SGR mouse report, the form terminals send once the lobby turns mouse reporting on. */
166
+ export function parseMouse(data: string): MouseReport | undefined {
167
+ const match = SGR_MOUSE.exec(data);
168
+ if (!match) return undefined;
169
+ const code = Number(match[1]);
170
+ const x = Number(match[2]) - 1;
171
+ const y = Number(match[3]) - 1;
172
+ if (code & 64) return { kind: "wheel", button: code, x, y, delta: (code & 1) === 1 ? 1 : -1 };
173
+ return { kind: match[4] === "M" ? "press" : "release", button: code & 3, x, y, delta: 0 };
174
+ }
175
+
176
+ function clip(text: string, max: number): string {
177
+ const flat = text.replace(/\s+/g, " ").trim();
178
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
179
+ }
180
+
181
+ export class LobbyView implements Component, Focusable {
182
+ tab: TabId = "lobby";
183
+ mode: LobbyMode = "type";
184
+ private readonly host: LobbyHost;
185
+ private readonly editor: LobbyEditor;
186
+ private readonly search: Input;
187
+ private focusedState = false;
188
+ private tick = 0;
189
+ private timer: ReturnType<typeof setTimeout> | undefined;
190
+ private running = false;
191
+ private notice: Notice | undefined;
192
+ private readonly drafts: Partial<Record<TabId, string>> = {};
193
+ /** Double-press confirmations, such as discarding a plan. */
194
+ private armed: string | undefined;
195
+ /** The keys, the panes on show and whether Issues is on, read once from the config. */
196
+ private keys: KeyMap;
197
+ private panels: Record<LobbyPanel, boolean>;
198
+ private issuesOn: boolean;
199
+ /** The help screen covers the body. */
200
+ help = false;
201
+ /** The search bar has the keys. */
202
+ searching = false;
203
+ /** Each tab's search, kept while you move between tabs. */
204
+ private readonly queries: Partial<Record<TabId, string>> = {};
205
+ /** Where the body starts and where each tab's label sits in the tab bar, for clicks. */
206
+ private bodyTop = 1;
207
+ private promptTop = Number.POSITIVE_INFINITY;
208
+ private tabSpans: Array<{ tab: TabId; from: number; to: number }> = [];
209
+
210
+ /** Each Lobby pane's scroll, in lines back from its newest, and the pane the keys scroll. */
211
+ private readonly homeOffsets: Record<HomePane, number> = { conversation: 0, activity: 0, thinking: 0 };
212
+ homeFocus: HomePane = "conversation";
213
+ /** How many lines each pane held last frame, so a pane scrolled back stays on what you are reading as lines arrive. */
214
+ private readonly seenTotals = new Map<string, number>();
215
+ /** Where the current tab's scrollable panes landed in the last frame. */
216
+ private readonly panes: PaneLayout = new Map();
217
+ private tasksSelected = 0;
218
+ private tasksFocus: "list" | "detail" = "list";
219
+ private tasksDetailOffset = 0;
220
+ private commentTarget: string | undefined;
221
+ private planOffset = 0;
222
+ private planFocus: "talk" | "draft" = "talk";
223
+ private planDraftOffset = 0;
224
+ /** The draft line the cursor is on, and the one a comment is being written for. */
225
+ planCursor = 0;
226
+ private lineTarget: string | undefined;
227
+ private readonly planLayout: PlanLayout = { draftTop: 0, draftLeft: 0, draftWidth: 0, draftRows: 0, draftStart: 0, draftText: [] };
228
+ /** Seats for the next planning session, until one exists. */
229
+ private seats: Set<PanelMember> | undefined;
230
+ private fixSelected = 0;
231
+ private fixFocus: "list" | "detail" = "list";
232
+ private fixDetailOffset = 0;
233
+ private issueSelected = 0;
234
+ private issueFocus: "list" | "detail" = "list";
235
+ private issueDetailOffset = 0;
236
+ private issueDraft = false;
237
+ private metricsSelected = 0;
238
+ private metricsBy: GroupBy = "model";
239
+ private metricsSort: SortKey = "runs";
240
+
241
+ private data: { tasks: Task[]; plans: PlannedTask[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; at: number } = {
242
+ tasks: [],
243
+ plans: [],
244
+ comments: new Map(),
245
+ metrics: [],
246
+ at: 0,
247
+ };
248
+
249
+ constructor(tui: TUI, host: LobbyHost, editorTheme: EditorTheme) {
250
+ this.host = host;
251
+ this.editor = new LobbyEditor(tui, editorTheme, { paddingX: 1 });
252
+ this.editor.onSubmit = (text) => this.submit(text);
253
+ this.search = new Input({ prompt: "", placeholder: "type to search this tab" });
254
+ this.keys = keyMap(host.keys());
255
+ this.panels = { ...host.panels() };
256
+ this.issuesOn = host.issuesEnabled();
257
+ }
258
+
259
+ get focused(): boolean {
260
+ return this.focusedState;
261
+ }
262
+
263
+ set focused(value: boolean) {
264
+ this.focusedState = value;
265
+ this.syncFocus();
266
+ }
267
+
268
+ private syncFocus(): void {
269
+ this.editor.focused = this.focusedState && this.mode === "type" && !this.searching;
270
+ this.search.focused = this.focusedState && this.searching;
271
+ }
272
+
273
+ private now(): number {
274
+ return this.host.now?.() ?? Date.now();
275
+ }
276
+
277
+ /** Start the lobby clock (while visible). */
278
+ start(): void {
279
+ if (this.running) return;
280
+ this.running = true;
281
+ this.refreshData(true);
282
+ this.schedule(0);
283
+ }
284
+
285
+ stop(): void {
286
+ this.running = false;
287
+ if (this.timer) clearTimeout(this.timer);
288
+ this.timer = undefined;
289
+ }
290
+
291
+ dispose(): void {
292
+ this.stop();
293
+ }
294
+
295
+ private schedule(delay: number): void {
296
+ if (this.timer) clearTimeout(this.timer);
297
+ if (!this.running) return;
298
+ this.timer = setTimeout(() => this.step(), delay);
299
+ this.timer.unref?.();
300
+ }
301
+
302
+ private isLive(): boolean {
303
+ const zen = this.host.zen();
304
+ return this.host.masterBusy()
305
+ || Boolean(this.host.quickfix.running)
306
+ || Boolean(this.host.planner()?.busy)
307
+ || (this.issuesOn && this.host.issues.loading)
308
+ || zen.runs.some((run) => run.status === "running")
309
+ || Boolean(zen.task && !zen.task.paused && !TERMINAL_STATES.includes(zen.task.state));
310
+ }
311
+
312
+ /** One clock step: advance the scene, reread data when due, repaint. */
313
+ private step(): void {
314
+ if (!this.running) return;
315
+ const now = this.now();
316
+ this.tick += 1;
317
+ let delay = this.isLive() ? LIVE_MS : IDLE_MS;
318
+ if (this.tab === "lobby" && this.panels.scene && this.host.zen().task) delay = Math.min(delay, this.host.advanceScene(now));
319
+ this.refreshData(false);
320
+ this.host.requestRender();
321
+ this.schedule(delay);
322
+ }
323
+
324
+ /** Reread tasks, plans, comments and metrics from disk (throttled unless forced). */
325
+ refreshData(force: boolean): void {
326
+ const now = this.now();
327
+ if (!force && now - this.data.at < DATA_REFRESH_MS) return;
328
+ this.data = {
329
+ tasks: this.host.tasks(),
330
+ plans: this.host.plans(),
331
+ comments: new Map(),
332
+ metrics: this.tab === "metrics" ? this.host.metrics() : this.data.metrics,
333
+ at: now,
334
+ };
335
+ if (this.tab === "tasks") {
336
+ const row = this.taskRowList()[this.tasksSelected];
337
+ if (row?.kind === "task") this.data.comments.set(row.id, this.host.comments(row.id));
338
+ }
339
+ }
340
+
341
+ private say(text: string, kind: Notice["kind"] = "info"): void {
342
+ this.notice = { text, at: this.now(), kind };
343
+ }
344
+
345
+ /** The search in force on this tab. */
346
+ query(): string | undefined {
347
+ return this.queries[this.tab]?.trim() || undefined;
348
+ }
349
+
350
+ /* ---------------------------------------------------------------- tabs */
351
+
352
+ tabs(): TabId[] {
353
+ return visibleTabs(this.issuesOn);
354
+ }
355
+
356
+ setTab(tab: TabId): void {
357
+ if (tab === this.tab) return;
358
+ if (!this.tabs().includes(tab)) return this.say(`the ${TAB_LABELS[tab]} tab is off — set lobby.${tab} to true in the config to bring it back`, "warning");
359
+ this.drafts[this.tab] = this.editor.getText();
360
+ this.tab = tab;
361
+ this.editor.setText(this.drafts[tab] ?? "");
362
+ this.commentTarget = undefined;
363
+ this.lineTarget = undefined;
364
+ this.issueDraft = false;
365
+ this.armed = undefined;
366
+ this.help = false;
367
+ this.closeSearch();
368
+ this.setMode(PROMPT_FIRST.has(tab) ? "type" : "browse");
369
+ this.refreshData(true);
370
+ if (tab === "issues" && !this.host.issues.loaded && !this.host.issues.loading) void this.host.issues.refresh().then(() => this.loadIssueDetail());
371
+ this.host.requestRender();
372
+ }
373
+
374
+ private cycleTab(direction: 1 | -1): void {
375
+ const tabs = this.tabs();
376
+ const index = Math.max(0, tabs.indexOf(this.tab));
377
+ this.setTab(tabs[(index + direction + tabs.length) % tabs.length]!);
378
+ }
379
+
380
+ private setMode(mode: LobbyMode): void {
381
+ this.mode = this.tab === "metrics" ? "browse" : mode;
382
+ this.syncFocus();
383
+ }
384
+
385
+ /** Open the prompt for a comment on `taskId`'s plan (from any tab). */
386
+ commentOn(taskId: string): void {
387
+ this.setTab("tasks");
388
+ delete this.queries.tasks;
389
+ const rows = this.taskRowList();
390
+ const index = rows.findIndex((row) => row.id === taskId);
391
+ if (index >= 0) this.tasksSelected = index;
392
+ this.refreshData(true);
393
+ this.commentTarget = taskId;
394
+ this.setMode("type");
395
+ }
396
+
397
+ /** Plan an issue: seed a planning session with it and switch to the Plan tab. */
398
+ async planIssue(number: number): Promise<void> {
399
+ const detail = await this.host.issues.detail(number);
400
+ if (!detail) return this.say(`could not load issue #${number}`, "warning");
401
+ const session = this.host.newPlanner({ issue: { number: detail.number, title: detail.title, ...(detail.url ? { url: detail.url } : {}) }, body: issueText(detail) }, [...this.seatSet()]);
402
+ this.resetPlanScroll();
403
+ this.setTab("plan");
404
+ void session.open();
405
+ }
406
+
407
+ /** Show or hide one pane and remember it in the config. */
408
+ togglePanel(panel: LobbyPanel): void {
409
+ const shown = !this.panels[panel];
410
+ this.panels = { ...this.panels, [panel]: shown };
411
+ this.host.savePanels(this.panels);
412
+ const key = keyLabel(this.keys[PANEL_ACTIONS[panel]]);
413
+ const where = this.tab === "lobby" ? "" : " on the Lobby tab";
414
+ this.say(`${PANEL_NAMES[panel]} ${shown ? "shown" : "hidden"}${where} · ${key} ${shown ? "hides" : "shows"} it`);
415
+ }
416
+
417
+ panelShown(panel: LobbyPanel): boolean {
418
+ return this.panels[panel];
419
+ }
420
+
421
+ /** Reread keys, panes and the Issues switch, after the settings changed them. */
422
+ reloadConfig(): void {
423
+ this.keys = keyMap(this.host.keys());
424
+ this.panels = { ...this.host.panels() };
425
+ this.issuesOn = this.host.issuesEnabled();
426
+ if (!this.tabs().includes(this.tab)) this.setTab("lobby");
427
+ }
428
+
429
+ private openSettings(entry?: "quickfix" | "planner"): void {
430
+ void this.host.openSettings(entry).then(() => this.host.requestRender());
431
+ }
432
+
433
+ /* -------------------------------------------------------------- search */
434
+
435
+ private openSearch(): void {
436
+ this.help = false;
437
+ this.searching = true;
438
+ this.search.setValue(this.queries[this.tab] ?? "");
439
+ this.syncFocus();
440
+ }
441
+
442
+ /** Close the search bar; the query stays in force unless `clear`. */
443
+ private closeSearch(clear = false): void {
444
+ if (clear) delete this.queries[this.tab];
445
+ this.searching = false;
446
+ this.syncFocus();
447
+ }
448
+
449
+ private searchKey(data: string): void {
450
+ if (matchesKey(data, Key.escape)) {
451
+ const had = Boolean(this.query());
452
+ this.closeSearch(true);
453
+ if (had) this.say("search cleared");
454
+ return;
455
+ }
456
+ if (matchesKey(data, Key.enter)) {
457
+ if (!this.query()) delete this.queries[this.tab];
458
+ return this.closeSearch();
459
+ }
460
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
461
+ // Keep the query, let the arrows move through what it found.
462
+ this.closeSearch();
463
+ return this.scroll(matchesKey(data, Key.up) ? -1 : 1);
464
+ }
465
+ this.search.handleInput(data);
466
+ const value = this.search.getValue();
467
+ if (value) this.queries[this.tab] = value;
468
+ else delete this.queries[this.tab];
469
+ this.resetSelections();
470
+ }
471
+
472
+ /** A new search starts every list at its top. */
473
+ private resetSelections(): void {
474
+ for (const pane of HOME_PANES) this.homeOffsets[pane] = 0;
475
+ this.planOffset = 0;
476
+ switch (this.tab) {
477
+ case "tasks":
478
+ this.tasksSelected = 0;
479
+ this.tasksDetailOffset = 0;
480
+ return;
481
+ case "quickfix":
482
+ this.fixSelected = 0;
483
+ this.fixDetailOffset = 0;
484
+ return;
485
+ case "metrics":
486
+ this.metricsSelected = 0;
487
+ return;
488
+ default:
489
+ return;
490
+ }
491
+ }
492
+
493
+ /* --------------------------------------------------------------- input */
494
+
495
+ handleInput(data: string): void {
496
+ this.handleKey(data);
497
+ this.host.requestRender();
498
+ }
499
+
500
+ private handleKey(data: string): void {
501
+ const mouse = parseMouse(data);
502
+ if (mouse) return this.mouseReport(mouse);
503
+ const action = actionFor(data, this.keys);
504
+ if (this.help) {
505
+ this.help = false;
506
+ // Esc, ?, q and the help key only close it; anything else also does its job.
507
+ if (action === "help" || matchesKey(data, Key.escape) || data === "?" || data === "q" || matchesKey(data, Key.enter)) return;
508
+ }
509
+ // While searching, the shortcuts still work; everything else edits the query.
510
+ if (this.searching) return action ? this.runAction(action) : this.searchKey(data);
511
+ if (action) return this.runAction(action);
512
+ const tabs = this.tabs();
513
+ for (const [index, tab] of tabs.entries()) {
514
+ if (matchesKey(data, Key.alt(String(index + 1) as "1"))) return this.setTab(tab);
515
+ }
516
+ if (matchesKey(data, Key.ctrl("c"))) {
517
+ if (this.editor.getText()) return this.editor.setText("");
518
+ return this.host.hide();
519
+ }
520
+ if (this.mode === "type") return this.typeKey(data);
521
+ return this.browseKey(data);
522
+ }
523
+
524
+ private runAction(action: LobbyAction): void {
525
+ switch (action) {
526
+ case "hide":
527
+ return this.host.hide();
528
+ case "help":
529
+ this.help = !this.help;
530
+ return;
531
+ case "settings":
532
+ return this.openSettings();
533
+ case "search":
534
+ return this.searching ? this.closeSearch() : this.openSearch();
535
+ case "nextTab":
536
+ return this.cycleTab(1);
537
+ case "prevTab":
538
+ return this.cycleTab(-1);
539
+ case "toggleScene":
540
+ return this.togglePanel("scene");
541
+ case "toggleConversation":
542
+ return this.togglePanel("conversation");
543
+ case "toggleActivity":
544
+ return this.togglePanel("activity");
545
+ case "toggleThinking":
546
+ return this.togglePanel("thinking");
547
+ case "scrollUp":
548
+ return this.scroll(-this.pageSize());
549
+ case "scrollDown":
550
+ return this.scroll(this.pageSize());
551
+ }
552
+ }
553
+
554
+ private typeKey(data: string): void {
555
+ if (matchesKey(data, Key.escape)) {
556
+ if (this.tab === "lobby" && this.host.masterBusy() && !this.editor.getText().trim()) {
557
+ this.host.abortMaster();
558
+ return this.say("stopping the oracle…");
559
+ }
560
+ this.commentTarget = undefined;
561
+ this.issueDraft = false;
562
+ if (this.lineTarget) {
563
+ this.lineTarget = undefined;
564
+ this.planFocus = "draft";
565
+ }
566
+ return this.setMode("browse");
567
+ }
568
+ this.editor.handleInput(data);
569
+ }
570
+
571
+ private browseKey(data: string): void {
572
+ if (matchesKey(data, Key.escape) && this.query()) {
573
+ delete this.queries[this.tab];
574
+ return this.say("search cleared");
575
+ }
576
+ if (data === "/") return this.openSearch();
577
+ if (data === "?") {
578
+ this.help = true;
579
+ return;
580
+ }
581
+ if (matchesKey(data, Key.up) || (!PROMPT_FIRST.has(this.tab) && data === "k")) return this.scroll(-1);
582
+ if (matchesKey(data, Key.down) || (!PROMPT_FIRST.has(this.tab) && data === "j")) return this.scroll(1);
583
+ if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) return this.movePaneFocus(matchesKey(data, Key.left) ? -1 : 1);
584
+ if (matchesKey(data, Key.home) || matchesKey(data, Key.end)) return this.scrollToEdge(matchesKey(data, Key.home));
585
+ const tabs = this.tabs();
586
+ if (!PROMPT_FIRST.has(this.tab) && /^[1-9]$/.test(data) && Number(data) <= tabs.length) return this.setTab(tabs[Number(data) - 1]!);
587
+ if (data === "i" && this.tab !== "metrics" && this.tab !== "tasks" && this.tab !== "issues") return this.setMode("type");
588
+ if (this.tabCommand(data)) return;
589
+ const printable = isPrintable(data);
590
+ if (printable && PROMPT_FIRST.has(this.tab)) {
591
+ this.setMode("type");
592
+ this.editor.handleInput(data);
593
+ }
594
+ }
595
+
596
+ /** The pane the keys scroll on this tab. */
597
+ private focusedPane(): string {
598
+ switch (this.tab) {
599
+ case "lobby":
600
+ return this.homePane();
601
+ case "plan":
602
+ return this.planFocus;
603
+ case "tasks":
604
+ return this.tasksFocus;
605
+ case "quickfix":
606
+ return this.fixFocus;
607
+ case "issues":
608
+ return this.issueFocus;
609
+ case "metrics":
610
+ return "table";
611
+ }
612
+ }
613
+
614
+ /** The focused Lobby pane, or the first one showing when it is hidden. */
615
+ private homePane(): HomePane {
616
+ const shown = this.shownHomePanes();
617
+ if (!shown.includes(this.homeFocus) && shown.length > 0) this.homeFocus = shown[0]!;
618
+ return this.homeFocus;
619
+ }
620
+
621
+ private shownHomePanes(): HomePane[] {
622
+ const drawn = HOME_PANES.filter((pane) => this.tab === "lobby" && this.panes.has(pane));
623
+ return drawn.length > 0 ? drawn : HOME_PANES.filter((pane) => this.panels[pane]);
624
+ }
625
+
626
+ /** A page of the focused pane: its rows less one, so a line of context carries over. */
627
+ private pageSize(): number {
628
+ const pane = this.panes.get(this.focusedPane());
629
+ return pane && pane.rows > 2 ? pane.rows - 1 : 10;
630
+ }
631
+
632
+ /** ← → move between the tab's panes. */
633
+ private movePaneFocus(direction: -1 | 1): void {
634
+ switch (this.tab) {
635
+ case "lobby": {
636
+ const shown = this.shownHomePanes();
637
+ if (shown.length === 0) return;
638
+ const index = Math.max(0, shown.indexOf(this.homePane()));
639
+ this.homeFocus = shown[(index + direction + shown.length) % shown.length]!;
640
+ return;
641
+ }
642
+ case "plan":
643
+ this.planFocus = direction < 0 ? "talk" : "draft";
644
+ if (this.planFocus === "draft") this.cursorToView();
645
+ return;
646
+ case "tasks":
647
+ this.tasksFocus = direction < 0 ? "list" : "detail";
648
+ return;
649
+ case "quickfix":
650
+ this.fixFocus = direction < 0 ? "list" : "detail";
651
+ return;
652
+ case "issues":
653
+ this.issueFocus = direction < 0 ? "list" : "detail";
654
+ return;
655
+ case "metrics":
656
+ return;
657
+ }
658
+ }
659
+
660
+ /** Arrow keys (±1) and page keys (± a page): scroll or select in the tab's focused pane. */
661
+ private scroll(delta: number): void {
662
+ switch (this.tab) {
663
+ case "lobby":
664
+ return this.scrollPane(this.homePane(), delta);
665
+ case "plan":
666
+ return this.scrollPane(this.planFocus, delta);
667
+ case "tasks":
668
+ // Page keys always page the detail; the arrows follow the focus.
669
+ return this.scrollPane(Math.abs(delta) > 1 ? "detail" : this.tasksFocus, delta);
670
+ case "quickfix":
671
+ return this.scrollPane(Math.abs(delta) > 1 ? "detail" : this.fixFocus, delta);
672
+ case "issues":
673
+ if (this.issueFocus === "detail" || Math.abs(delta) > 1) this.issueDetailOffset = Math.max(0, this.issueDetailOffset + delta);
674
+ else {
675
+ this.issueSelected = Math.max(0, Math.min(this.host.issues.issues.length - 1, this.issueSelected + delta));
676
+ this.issueDetailOffset = 0;
677
+ void this.loadIssueDetail();
678
+ }
679
+ return;
680
+ case "metrics":
681
+ return this.scrollPane("table", delta);
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Scroll one pane of the current tab by `delta` lines, positive toward its
687
+ * end. Lists move their selection instead; the draft moves its cursor (the
688
+ * wheel scrolls it and the cursor follows only when it would leave the screen).
689
+ */
690
+ private scrollPane(pane: string, delta: number, wheel = false): void {
691
+ if (delta === 0) return;
692
+ switch (pane) {
693
+ case "conversation":
694
+ case "activity":
695
+ case "thinking":
696
+ this.homeOffsets[pane] = Math.max(0, this.homeOffsets[pane] - delta);
697
+ break;
698
+ case "talk":
699
+ this.planOffset = Math.max(0, this.planOffset - delta);
700
+ break;
701
+ case "draft":
702
+ if (wheel) this.wheelDraft(delta);
703
+ else this.moveCursor(delta);
704
+ break;
705
+ case "list":
706
+ if (this.tab === "tasks") this.selectTask(this.tasksSelected + Math.sign(delta));
707
+ else if (this.tab === "quickfix") {
708
+ this.fixSelected = Math.max(0, Math.min(this.fixJobs().length - 1, this.fixSelected + Math.sign(delta)));
709
+ this.fixDetailOffset = 0;
710
+ }
711
+ break;
712
+ case "detail":
713
+ if (this.tab === "tasks") this.tasksDetailOffset = Math.max(0, this.tasksDetailOffset + delta);
714
+ else if (this.tab === "quickfix") this.fixDetailOffset = Math.max(0, this.fixDetailOffset + delta);
715
+ break;
716
+ case "table":
717
+ this.metricsSelected = Math.max(0, this.metricsSelected + (wheel ? Math.sign(delta) : delta));
718
+ break;
719
+ }
720
+ this.clampScroll();
721
+ }
722
+
723
+ /** Home and End: the oldest or first line of the focused pane, or back to its newest or last. */
724
+ private scrollToEdge(start: boolean): void {
725
+ const pane = this.focusedPane();
726
+ const far = start ? -1e9 : 1e9;
727
+ if (pane === "draft") {
728
+ const last = this.planLayout.draftText.length - 1;
729
+ this.planCursor = start ? 0 : Math.max(0, last);
730
+ this.moveCursor(0);
731
+ return;
732
+ }
733
+ if (pane === "list" && this.tab === "tasks") return this.selectTask(start ? 0 : this.taskRowList().length - 1);
734
+ if (pane === "list" && this.tab === "quickfix") {
735
+ this.fixSelected = start ? 0 : Math.max(0, this.fixJobs().length - 1);
736
+ this.fixDetailOffset = 0;
737
+ return;
738
+ }
739
+ if (pane === "table") {
740
+ this.metricsSelected = start ? 0 : Number.MAX_SAFE_INTEGER;
741
+ return;
742
+ }
743
+ this.scrollPane(pane, far);
744
+ }
745
+
746
+ /** The wheel over the draft scrolls it; the cursor follows only when it would leave the screen. */
747
+ private wheelDraft(delta: number): void {
748
+ const rows = Math.max(1, this.planLayout.draftRows);
749
+ const max = Math.max(0, this.planLayout.draftText.length - rows);
750
+ this.planDraftOffset = Math.max(0, Math.min(max, this.planLayout.draftStart + delta));
751
+ if (this.planCursor < this.planDraftOffset) this.planCursor = this.planDraftOffset;
752
+ if (this.planCursor >= this.planDraftOffset + rows) this.planCursor = this.planDraftOffset + rows - 1;
753
+ }
754
+
755
+ /**
756
+ * Keep every offset within what its pane holds, so scrolling back the other
757
+ * way responds at once; a pane scrolled back from its newest line stays on
758
+ * the lines being read as new ones arrive. True when that moved a pane.
759
+ */
760
+ private clampScroll(): boolean {
761
+ let moved = false;
762
+ const limit = (name: string, offset: number, anchored: boolean): number => {
763
+ const pane = this.panes.get(name);
764
+ if (!pane) return offset;
765
+ const seen = this.seenTotals.get(name);
766
+ this.seenTotals.set(name, pane.total);
767
+ let next = offset;
768
+ if (anchored && next > 0 && seen !== undefined && pane.total > seen) {
769
+ next += pane.total - seen;
770
+ moved = true;
771
+ }
772
+ return Math.min(next, Math.max(0, pane.total - pane.rows));
773
+ };
774
+ switch (this.tab) {
775
+ case "lobby":
776
+ for (const pane of HOME_PANES) this.homeOffsets[pane] = limit(pane, this.homeOffsets[pane], true);
777
+ break;
778
+ case "plan":
779
+ this.planOffset = limit("talk", this.planOffset, true);
780
+ break;
781
+ case "tasks":
782
+ this.tasksDetailOffset = limit("detail", this.tasksDetailOffset, false);
783
+ break;
784
+ case "quickfix":
785
+ this.fixDetailOffset = limit("detail", this.fixDetailOffset, false);
786
+ break;
787
+ default:
788
+ break;
789
+ }
790
+ return moved;
791
+ }
792
+
793
+ /** Move the draft cursor by `delta` lines, skipping blank ones, and keep it on screen. */
794
+ private moveCursor(delta: number): void {
795
+ const lines = this.planLayout.draftText;
796
+ if (lines.length === 0) return;
797
+ const step = Math.sign(delta) || 1;
798
+ let target = Math.max(0, Math.min(lines.length - 1, this.planCursor + delta));
799
+ while (target > 0 && target < lines.length - 1 && !lines[target]!.trim()) target += step;
800
+ if (!lines[target]?.trim()) {
801
+ // Ran into a blank edge: look back the other way for the nearest line with text.
802
+ let back = target;
803
+ while (back >= 0 && back < lines.length && !lines[back]!.trim()) back -= step;
804
+ if (back >= 0 && back < lines.length) target = back;
805
+ }
806
+ this.planCursor = target;
807
+ this.keepCursorVisible();
808
+ }
809
+
810
+ private keepCursorVisible(): void {
811
+ const rows = Math.max(1, this.planLayout.draftRows);
812
+ const start = Math.min(this.planDraftOffset, Math.max(0, this.planLayout.draftText.length - rows));
813
+ if (this.planCursor < start) this.planDraftOffset = this.planCursor;
814
+ else if (this.planCursor >= start + rows) this.planDraftOffset = this.planCursor - rows + 1;
815
+ else this.planDraftOffset = start;
816
+ }
817
+
818
+ /** Put the draft cursor on the first line with text on screen. */
819
+ private cursorToView(): void {
820
+ const lines = this.planLayout.draftText;
821
+ const start = this.planLayout.draftStart;
822
+ const end = Math.min(lines.length, start + Math.max(1, this.planLayout.draftRows));
823
+ if (this.planCursor >= start && this.planCursor < end && lines[this.planCursor]?.trim()) return;
824
+ for (let index = start; index < end; index++) {
825
+ if (lines[index]?.trim()) {
826
+ this.planCursor = index;
827
+ return;
828
+ }
829
+ }
830
+ this.planCursor = start;
831
+ }
832
+
833
+ private selectTask(index: number): void {
834
+ const rows = this.taskRowList();
835
+ this.tasksSelected = Math.max(0, Math.min(rows.length - 1, index));
836
+ this.tasksDetailOffset = 0;
837
+ this.refreshData(true);
838
+ }
839
+
840
+ private async loadIssueDetail(): Promise<void> {
841
+ const issue = this.host.issues.issues[this.issueSelected];
842
+ if (issue) await this.host.issues.detail(issue.number);
843
+ }
844
+
845
+ /** The tab's single-key commands in browsing mode; true when the key was one. */
846
+ private tabCommand(data: string): boolean {
847
+ const enter = matchesKey(data, Key.enter);
848
+ const escape = matchesKey(data, Key.escape);
849
+ switch (this.tab) {
850
+ case "lobby":
851
+ if (escape && this.host.masterBusy()) {
852
+ this.host.abortMaster();
853
+ this.say("stopping the oracle…");
854
+ return true;
855
+ }
856
+ if (data === "c") {
857
+ const task = this.host.zen().task;
858
+ if (task) this.commentOn(task.id);
859
+ else this.say("no task in this session to comment on", "warning");
860
+ return true;
861
+ }
862
+ return enter ? (this.setMode("type"), true) : false;
863
+ case "tasks":
864
+ return this.tasksCommand(data, enter, escape);
865
+ case "plan":
866
+ return this.planCommand(data, enter, escape);
867
+ case "quickfix":
868
+ if (enter) return (this.fixFocus = this.fixFocus === "list" ? "detail" : "list"), true;
869
+ if (escape && this.fixFocus === "detail") return (this.fixFocus = "list"), true;
870
+ if (data === "x") {
871
+ const job = this.fixJobs()[this.fixSelected];
872
+ if (job && this.host.quickfix.cancel(job.id)) this.say(`cancelling ${job.id}`);
873
+ return true;
874
+ }
875
+ if (data === "m") return this.openSettings("quickfix"), true;
876
+ return false;
877
+ case "issues":
878
+ return this.issuesCommand(data, enter, escape);
879
+ case "metrics":
880
+ if (data === "g") return (this.metricsBy = this.metricsBy === "model" ? "model-kind" : "model", this.metricsSelected = 0), true;
881
+ if (data === "s") return (this.metricsSort = SORT_KEYS[(SORT_KEYS.indexOf(this.metricsSort) + 1) % SORT_KEYS.length]!), true;
882
+ if (data === "r") return this.refreshData(true), true;
883
+ return false;
884
+ }
885
+ }
886
+
887
+ private tasksCommand(data: string, enter: boolean, escape: boolean): boolean {
888
+ const row = this.taskRowList()[this.tasksSelected];
889
+ if (enter) return (this.tasksFocus = this.tasksFocus === "list" ? "detail" : "list"), true;
890
+ if (escape) return (this.tasksFocus = "list"), true;
891
+ if (data === "r") return this.refreshData(true), true;
892
+ if (!row) return false;
893
+ if (data === "c") {
894
+ const task = this.data.tasks.find((entry) => entry.id === row.id);
895
+ if (row.kind !== "task" || !task) this.say("pick a task to comment on its plan", "warning");
896
+ else if (TERMINAL_STATES.includes(task.state)) this.say(`${task.id} is ${task.state}; there is no plan left to change`, "warning");
897
+ else {
898
+ this.commentTarget = task.id;
899
+ this.setMode("type");
900
+ }
901
+ return true;
902
+ }
903
+ const plan = row.kind === "plan" ? this.data.plans.find((entry) => entry.id === row.id) : undefined;
904
+ if (data === "s" && plan) {
905
+ this.say(this.host.startPlanned(plan));
906
+ this.refreshData(true);
907
+ return true;
908
+ }
909
+ if (data === "d" && plan) {
910
+ if (this.armed !== `discard:${plan.id}`) {
911
+ this.armed = `discard:${plan.id}`;
912
+ this.say(`press d again to discard ${plan.id}`, "warning");
913
+ return true;
914
+ }
915
+ this.armed = undefined;
916
+ this.host.discardPlan(plan.id);
917
+ this.say(`discarded ${plan.id}`);
918
+ this.refreshData(true);
919
+ return true;
920
+ }
921
+ return false;
922
+ }
923
+
924
+ /** Seats for the next round: the session's, or the ones chosen before it started. */
925
+ private seatSet(): Set<PanelMember> {
926
+ const session = this.host.planner();
927
+ if (session) return session.seats;
928
+ this.seats ??= new Set(this.host.defaultPanel());
929
+ return this.seats;
930
+ }
931
+
932
+ private resetPlanScroll(): void {
933
+ this.planOffset = 0;
934
+ this.planDraftOffset = 0;
935
+ this.planCursor = 0;
936
+ }
937
+
938
+ /** The oracle puts the round's questions to the user through the questionnaire. */
939
+ answerQuestions(): void {
940
+ const session = this.host.planner();
941
+ if (!session?.awaitingAnswers) return this.say(session?.busy ? "the panel is still thinking" : "no open questions", "warning");
942
+ void this.host.answerPanel().then((notice) => {
943
+ this.say(notice);
944
+ this.host.requestRender();
945
+ });
946
+ }
947
+
948
+ /** Start a comment on the draft line under the cursor. */
949
+ private commentOnCursor(): void {
950
+ const line = this.planLayout.draftText[this.planCursor];
951
+ if (!this.host.planner()?.reply?.plan) return this.say("there is no draft to comment on yet", "warning");
952
+ if (!line?.trim()) return this.say("that line is blank — ↑↓ picks another", "warning");
953
+ this.lineTarget = line.trim();
954
+ this.setMode("type");
955
+ }
956
+
957
+ private planCommand(data: string, enter: boolean, escape: boolean): boolean {
958
+ const session = this.host.planner();
959
+ if (enter) {
960
+ this.planFocus = this.planFocus === "talk" ? "draft" : "talk";
961
+ if (this.planFocus === "draft") this.cursorToView();
962
+ return true;
963
+ }
964
+ if (data === "a") {
965
+ this.answerQuestions();
966
+ return true;
967
+ }
968
+ if (data === "m") {
969
+ this.openSettings("planner");
970
+ return true;
971
+ }
972
+ if (data === "c") {
973
+ if (this.planFocus !== "draft") {
974
+ this.planFocus = "draft";
975
+ this.cursorToView();
976
+ this.say("↑↓ picks a draft line, c comments on it (or click the line)");
977
+ return true;
978
+ }
979
+ this.commentOnCursor();
980
+ return true;
981
+ }
982
+ const seat = /^[1-4]$/.test(data) ? PANEL_MEMBERS[Number(data) - 1] : undefined;
983
+ if (seat) {
984
+ let seated: boolean;
985
+ if (session) seated = session.toggle(seat);
986
+ else {
987
+ const seats = this.seatSet();
988
+ seated = !seats.delete(seat);
989
+ if (seated) seats.add(seat);
990
+ }
991
+ this.say(`${MEMBER_LABELS[seat]} ${seated ? "joins" : "leaves"} the panel from the next round`);
992
+ return true;
993
+ }
994
+ if (data === "x" || (escape && session?.busy)) {
995
+ if (session?.busy) {
996
+ session.cancel();
997
+ this.say("stopping the panel…");
998
+ }
999
+ return true;
1000
+ }
1001
+ if (escape && this.planFocus === "draft") {
1002
+ this.planFocus = "talk";
1003
+ return true;
1004
+ }
1005
+ if (data === "r") {
1006
+ if (session?.retryable) void session.retry();
1007
+ else this.say("nothing to retry", "warning");
1008
+ return true;
1009
+ }
1010
+ if (data === "s") {
1011
+ try {
1012
+ const saved = session?.save();
1013
+ this.say(saved ? `saved ${saved.id} — start it from the Tasks tab when you are ready` : "nothing to save yet", saved ? "info" : "warning");
1014
+ } catch (error) {
1015
+ this.say((error as Error).message, "warning");
1016
+ }
1017
+ this.refreshData(true);
1018
+ return true;
1019
+ }
1020
+ if (data === "n") {
1021
+ const unsaved = session && session.messages.length > 0 && !session.saved;
1022
+ if (unsaved && this.armed !== "new-plan") {
1023
+ this.armed = "new-plan";
1024
+ this.say("this plan is not saved — press n again to start over anyway", "warning");
1025
+ return true;
1026
+ }
1027
+ this.armed = undefined;
1028
+ session?.cancel();
1029
+ this.host.newPlanner(undefined, [...this.seatSet()]);
1030
+ this.resetPlanScroll();
1031
+ this.setMode("type");
1032
+ return true;
1033
+ }
1034
+ return false;
1035
+ }
1036
+
1037
+ private issuesCommand(data: string, enter: boolean, escape: boolean): boolean {
1038
+ const issue = this.host.issues.issues[this.issueSelected];
1039
+ if (enter) {
1040
+ this.issueFocus = this.issueFocus === "list" ? "detail" : "list";
1041
+ void this.loadIssueDetail();
1042
+ return true;
1043
+ }
1044
+ if (escape) return (this.issueFocus = "list"), true;
1045
+ if (data === "r") {
1046
+ this.host.issues.details.clear();
1047
+ void this.host.issues.refresh().then(() => this.loadIssueDetail());
1048
+ return true;
1049
+ }
1050
+ if (data === "n") {
1051
+ this.issueDraft = true;
1052
+ this.setMode("type");
1053
+ return true;
1054
+ }
1055
+ if (data === "p" && issue) {
1056
+ void this.planIssue(issue.number);
1057
+ return true;
1058
+ }
1059
+ return false;
1060
+ }
1061
+
1062
+ /* --------------------------------------------------------------- mouse */
1063
+
1064
+ /** Mouse events when pi runs full screen (pi-tui dispatches them to the overlay). */
1065
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
1066
+ if (event.type === "wheel") this.wheel(Math.sign(event.wheelDelta ?? 0) || 1, event.x, event.y);
1067
+ else if (event.type === "click" && event.button === "left") this.click(event.x, event.y);
1068
+ else return undefined;
1069
+ this.host.requestRender();
1070
+ return { handled: true };
1071
+ }
1072
+
1073
+ /** Mouse reports in pi's regular screen, where the lobby turns reporting on itself. */
1074
+ private mouseReport(report: MouseReport): void {
1075
+ if (report.kind === "wheel") this.wheel(report.delta, report.x, report.y);
1076
+ else if (report.kind === "press" && report.button === 0) this.click(report.x, report.y);
1077
+ }
1078
+
1079
+ /** The pane of the current tab under a body cell, if any. */
1080
+ private paneAt(x: number, y: number): string | undefined {
1081
+ const row = y - this.bodyTop;
1082
+ for (const [name, pane] of this.panes) {
1083
+ if (row >= pane.top && row < pane.top + pane.height && x >= pane.left && x < pane.left + pane.width) return name;
1084
+ }
1085
+ return undefined;
1086
+ }
1087
+
1088
+ /** The wheel scrolls the pane under the pointer (the focused one when it is over none). */
1089
+ private wheel(direction: number, x = -1, y = -1): void {
1090
+ if (this.help) return;
1091
+ if (this.tab === "issues") return this.scroll(direction * WHEEL_LINES);
1092
+ this.scrollPane(this.paneAt(x, y) ?? this.focusedPane(), direction * WHEEL_LINES, true);
1093
+ }
1094
+
1095
+ /** A click: a tab in the tab bar, the prompt, or a line of the draft plan to comment on. */
1096
+ click(x: number, y: number): void {
1097
+ this.help = false;
1098
+ if (y === 0) {
1099
+ const span = this.tabSpans.find((entry) => x >= entry.from && x < entry.to);
1100
+ if (span) this.setTab(span.tab);
1101
+ return;
1102
+ }
1103
+ if (y >= this.promptTop) {
1104
+ if (this.searching) this.closeSearch();
1105
+ if (this.tab !== "metrics" && this.tab !== "tasks") this.setMode("type");
1106
+ return;
1107
+ }
1108
+ const row = y - this.bodyTop;
1109
+ // A click on a pane gives it the keys.
1110
+ const pane = this.paneAt(x, y);
1111
+ if (this.tab === "lobby" && pane) this.homeFocus = pane as HomePane;
1112
+ if (this.tab === "tasks" && (pane === "list" || pane === "detail")) this.tasksFocus = pane;
1113
+ if (this.tab === "quickfix" && (pane === "list" || pane === "detail")) this.fixFocus = pane;
1114
+ if (this.tab !== "plan" || row < 0 || !this.host.planner()) return;
1115
+ const layout = this.planLayout;
1116
+ // The draft pane: its border sits one row above its first line and two columns left of its text.
1117
+ const inDraftColumns = x >= layout.draftLeft - 2 && x < layout.draftLeft + layout.draftWidth + 2;
1118
+ const line = row - layout.draftTop;
1119
+ if (inDraftColumns && line >= -1 && line <= layout.draftRows) {
1120
+ this.planFocus = "draft";
1121
+ const index = layout.draftStart + line;
1122
+ if (line < 0 || line >= layout.draftRows || index >= layout.draftText.length) return;
1123
+ this.planCursor = index;
1124
+ if (this.searching) this.closeSearch();
1125
+ this.commentOnCursor();
1126
+ return;
1127
+ }
1128
+ // The conversation sits left of the draft on wide terminals, above it on narrow ones.
1129
+ const inTalk = layout.draftLeft > 2 ? x < layout.draftLeft - 2 : line < -1;
1130
+ if (inTalk) this.planFocus = "talk";
1131
+ }
1132
+
1133
+ /* -------------------------------------------------------------- submit */
1134
+
1135
+ private submit(text: string): void {
1136
+ const body = text.trim();
1137
+ if (!body) {
1138
+ // An empty enter on the Plan tab opens the panel's questions.
1139
+ if (this.tab === "plan" && !this.lineTarget && this.host.planner()?.awaitingAnswers) this.answerQuestions();
1140
+ return;
1141
+ }
1142
+ this.editor.addToHistory(body);
1143
+ switch (this.tab) {
1144
+ case "lobby": {
1145
+ const notice = this.host.toOracle(body);
1146
+ if (notice) this.say(notice);
1147
+ this.homeOffsets.conversation = 0;
1148
+ return;
1149
+ }
1150
+ case "tasks":
1151
+ if (!this.commentTarget) {
1152
+ this.editor.setText(body);
1153
+ return this.say("press c on a task to comment on its plan", "warning");
1154
+ }
1155
+ this.say(this.host.comment(this.commentTarget, body));
1156
+ this.commentTarget = undefined;
1157
+ this.setMode("browse");
1158
+ this.refreshData(true);
1159
+ return;
1160
+ case "plan":
1161
+ return this.submitPlan(body);
1162
+ case "quickfix": {
1163
+ const job = this.host.quickfix.submit(body);
1164
+ this.fixSelected = 0;
1165
+ this.fixDetailOffset = 0;
1166
+ delete this.queries.quickfix;
1167
+ this.say(this.host.quickfix.running?.id === job.id ? `${job.id} started` : `${job.id} queued behind the running quick fix`);
1168
+ return;
1169
+ }
1170
+ case "issues":
1171
+ if (!this.issueDraft) {
1172
+ this.editor.setText(body);
1173
+ return this.say("press n to file a new issue", "warning");
1174
+ }
1175
+ this.issueDraft = false;
1176
+ this.setMode("browse");
1177
+ this.say("filing the issue…");
1178
+ void this.host.issues.create(body);
1179
+ return;
1180
+ case "metrics":
1181
+ return;
1182
+ }
1183
+ }
1184
+
1185
+ private submitPlan(body: string): void {
1186
+ if (this.lineTarget) {
1187
+ const line = this.lineTarget;
1188
+ this.lineTarget = undefined;
1189
+ const session = this.host.planner();
1190
+ if (!session) return this.say("there is no draft to comment on", "warning");
1191
+ const sent = session.commentOnLine(line, body);
1192
+ this.planFocus = "draft";
1193
+ this.setMode("browse");
1194
+ if (sent) this.say("comment sent — the panel revises the draft");
1195
+ else if (session.busy) this.say("comment kept — it goes to the panel after this round");
1196
+ else this.say("comment kept — it goes with your answers (a answers the questions)");
1197
+ return;
1198
+ }
1199
+ const session = this.host.planner() ?? this.host.newPlanner(undefined, [...this.seatSet()]);
1200
+ if (session.busy) {
1201
+ this.editor.setText(body);
1202
+ return this.say("the panel is still thinking — x stops it", "warning");
1203
+ }
1204
+ this.planOffset = 0;
1205
+ this.planDraftOffset = 0;
1206
+ session.send(body).catch((error: Error) => this.say(error.message, "warning"));
1207
+ }
1208
+
1209
+ /* -------------------------------------------------------------- render */
1210
+
1211
+ /** Task rows, narrowed by the Tasks tab's search. */
1212
+ private taskRowList(): TaskRow[] {
1213
+ const rows = taskRows(this.data.tasks, this.data.plans, this.host.sessionId(), this.now());
1214
+ return filterRows(rows, this.data.tasks, this.data.plans, this.queries.tasks);
1215
+ }
1216
+
1217
+ /** Quick fix jobs as listed: newest first, narrowed by the search. */
1218
+ private fixJobs() {
1219
+ return newestFirst(filterJobs(this.host.quickfix.jobs, this.queries.quickfix));
1220
+ }
1221
+
1222
+ private tabBar(width: number, theme: LobbyTheme): string {
1223
+ const zen = this.host.zen();
1224
+ const badges: Partial<Record<TabId, string>> = {};
1225
+ const active = this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state)).length;
1226
+ if (active > 0) badges.tasks = String(active);
1227
+ const session = this.host.planner();
1228
+ if (session?.busy) badges.plan = spinner(this.tick);
1229
+ else if (session?.awaitingAnswers) badges.plan = `${session.questions.length}?`;
1230
+ if (this.host.quickfix.running) badges.quickfix = spinner(this.tick);
1231
+ if (this.issuesOn && this.host.issues.issues.length > 0) badges.issues = String(this.host.issues.issues.length);
1232
+ const brand = bold(theme, paint(theme, "accent", " ◆ bot-lobby "));
1233
+ let left = `${brand}${paint(theme, "borderMuted", "│")}`;
1234
+ const spans: Array<{ tab: TabId; from: number; to: number }> = [];
1235
+ for (const [index, tab] of this.tabs().entries()) {
1236
+ const number = paint(theme, tab === this.tab ? "accent" : "dim", String(index + 1));
1237
+ const name = tab === this.tab ? bold(theme, paint(theme, "text", TAB_LABELS[tab])) : paint(theme, "muted", TAB_LABELS[tab]);
1238
+ const badge = badges[tab] ? ` ${paint(theme, tab === "plan" && session?.awaitingAnswers ? "warning" : "accent", badges[tab]!)}` : "";
1239
+ let cell = ` ${number} ${name}${badge} `;
1240
+ if (tab === this.tab) cell = theme.bg ? theme.bg("selectedBg", cell) : `[${cell}]`;
1241
+ const from = visibleWidth(left);
1242
+ left += cell;
1243
+ spans.push({ tab, from, to: visibleWidth(left) });
1244
+ }
1245
+ this.tabSpans = spans;
1246
+ const room = width - visibleWidth(left) - 1;
1247
+ const dot = paint(theme, this.host.masterBusy() ? "accent" : "dim", this.host.masterBusy() ? spinner(this.tick) : "●");
1248
+ const state = zen.task ? paint(theme, "muted", zen.task.paused ? `${zen.task.state} (paused)` : zen.task.state) : "";
1249
+ const help = paint(theme, "dim", `${keyLabel(this.keys.help)} keys `);
1250
+ // Long task ids give way to the state, then to the dot alone.
1251
+ const choices = zen.task
1252
+ ? [`${dot} ${zen.task.id} ${state} ${help}`, `${dot} ${zen.task.id} ${state} `, `${dot} ${state} `, `${dot} `]
1253
+ : [`${paint(theme, "dim", "no task in this session")} ${help}`, paint(theme, "dim", "no task in this session "), help, ""];
1254
+ const status = choices.find((choice) => visibleWidth(choice) <= room) ?? "";
1255
+ const gap = width - visibleWidth(left) - visibleWidth(status);
1256
+ return gap >= 1 ? `${left}${" ".repeat(gap)}${status}` : fit(left, width);
1257
+ }
1258
+
1259
+ private promptLabel(): string {
1260
+ const zen = this.host.zen();
1261
+ const browsing = this.mode === "browse";
1262
+ switch (this.tab) {
1263
+ case "lobby":
1264
+ if (!zen.task) return browsing ? "describe a task to start — i to type" : "describe a task to start · enter starts it";
1265
+ return browsing ? "message the oracle — i to type" : this.host.masterBusy() ? "message the oracle · enter steers the running turn" : "message the oracle";
1266
+ case "tasks":
1267
+ return this.commentTarget ? `comment on ${this.commentTarget}'s plan · enter sends it to the oracle` : "c comments on the selected task's plan";
1268
+ case "plan": {
1269
+ if (this.lineTarget) return `comment on “${clip(this.lineTarget, 48)}” · enter adds it`;
1270
+ const session = this.host.planner();
1271
+ if (session?.busy) return "the panel is thinking · x stops it";
1272
+ if (session?.awaitingAnswers) {
1273
+ const count = session.questions.length;
1274
+ return `enter answers the panel's ${count} question${count === 1 ? "" : "s"} one at a time · or type a reply`;
1275
+ }
1276
+ return session && session.messages.length > 0 ? "reply to the panel" : "describe the task you want to plan";
1277
+ }
1278
+ case "quickfix":
1279
+ return this.host.quickfix.running ? "describe a quick change · queues behind the running one" : "describe a quick change · runs now, beside any task";
1280
+ case "issues":
1281
+ return this.issueDraft ? "new issue · first line is the title" : "n files a new issue";
1282
+ case "metrics":
1283
+ return "";
1284
+ }
1285
+ }
1286
+
1287
+ private promptLines(width: number, theme: LobbyTheme): string[] {
1288
+ if (this.tab === "metrics") return [];
1289
+ const typing = this.mode === "type" && !this.searching;
1290
+ this.editor.label = this.promptLabel();
1291
+ this.editor.paintLabel = (text) => paint(theme, typing ? (this.lineTarget ? "warning" : "accent") : "dim", text);
1292
+ this.editor.borderColor = (text) => paint(theme, typing ? "border" : "borderMuted", text);
1293
+ const lines = this.editor.render(width);
1294
+ // The editor always draws its block cursor; while browsing it would suggest the prompt has focus.
1295
+ return typing ? lines : lines.map((line) => line.replace(REVERSE_VIDEO, "$1"));
1296
+ }
1297
+
1298
+ /** The search bar: the query being typed, or the one in force. */
1299
+ private searchLines(width: number, theme: LobbyTheme): string[] {
1300
+ const query = this.query();
1301
+ if (!this.searching && !query) return [];
1302
+ const lead = `${paint(theme, "accent", " / ")}`;
1303
+ const tail = this.searching
1304
+ ? paint(theme, "dim", " enter keep · esc clear · ↑↓ browse results ")
1305
+ : paint(theme, "dim", ` ${keyLabel(this.keys.search)} or / edits · esc clears `);
1306
+ const room = Math.max(4, width - visibleWidth(lead) - visibleWidth(tail));
1307
+ const field = this.searching ? this.search.render(room)[0] ?? "" : bold(theme, fit(query ?? "", room));
1308
+ return [fit(`${lead}${fit(field, room)}${tail}`, width)];
1309
+ }
1310
+
1311
+ /** Every key the current tab understands while browsing. */
1312
+ private tabKeys(): KeyHelp[] {
1313
+ const session = this.host.planner();
1314
+ switch (this.tab) {
1315
+ case "lobby":
1316
+ return [
1317
+ { key: "type", text: "talk to the oracle (starts a task when none is running)", hint: "talk" },
1318
+ { key: "↑ ↓", text: "scroll the focused pane (PageUp/PageDown a page)", hint: "scroll" },
1319
+ { key: "← →", text: "move between the conversation, activity log and thinking", hint: "pane" },
1320
+ { key: "Home End", text: "the oldest lines, or back to the newest" },
1321
+ { key: "c", text: "comment on this task's plan", hint: "comment on plan" },
1322
+ { key: "esc", text: "stop the oracle while it works" },
1323
+ ];
1324
+ case "tasks":
1325
+ return [
1326
+ { key: "↑ ↓", text: "select a task or plan, or scroll the detail (PageUp/PageDown)", hint: "select" },
1327
+ { key: "enter / ← →", text: "move between the list and the detail", hint: "detail" },
1328
+ { key: "c", text: "comment on the selected task's plan", hint: "comment" },
1329
+ { key: "s", text: "start the selected planned task", hint: "start" },
1330
+ { key: "d d", text: "discard the selected planned task", hint: "discard" },
1331
+ { key: "r", text: "reread tasks from disk" },
1332
+ ];
1333
+ case "plan":
1334
+ return [
1335
+ { key: "a", text: "answer the panel's questions, one questionnaire at a time (enter on an empty prompt too)", ...(session?.awaitingAnswers ? { hint: "answer questions" } : {}) },
1336
+ { key: "enter / ← →", text: "move between the conversation and the draft", hint: "pane" },
1337
+ { key: "↑ ↓", text: "pick a draft line (draft) or scroll (conversation); PageUp/PageDown a page", hint: "line" },
1338
+ { key: "c / click", text: "comment on the picked draft line", hint: "comment on line" },
1339
+ { key: "1-4", text: "seat or unseat DEV, DESIGN, QA, RESEARCH", hint: "seats" },
1340
+ { key: "s", text: "save the plan to the pending tasks", hint: "save" },
1341
+ { key: "n", text: "start a new plan", hint: "new" },
1342
+ { key: "x", text: "stop the round" },
1343
+ { key: "r", text: "retry a failed round or seat" },
1344
+ { key: "m", text: "the oracle's model, thinking and time limit (Planner settings)" },
1345
+ ];
1346
+ case "quickfix":
1347
+ return [
1348
+ { key: "type", text: "describe a quick change", hint: "new fix" },
1349
+ { key: "↑ ↓", text: "select a quick fix, or scroll the detail (PageUp/PageDown)", hint: "select" },
1350
+ { key: "enter / ← →", text: "move between the list and the detail", hint: "detail" },
1351
+ { key: "x", text: "cancel the selected quick fix", hint: "cancel" },
1352
+ { key: "m", text: "the quick fix agent's model, thinking, time limit and instructions", hint: "model" },
1353
+ ];
1354
+ case "issues":
1355
+ return [
1356
+ { key: "↑ ↓", text: "select an issue", hint: "select" },
1357
+ { key: "enter", text: "read the selected issue", hint: "read" },
1358
+ { key: "p", text: "plan the selected issue", hint: "plan it" },
1359
+ { key: "n", text: "file a new issue", hint: "new" },
1360
+ { key: "r", text: "reload issues", hint: "refresh" },
1361
+ ];
1362
+ case "metrics":
1363
+ return [
1364
+ { key: "↑ ↓", text: "select a row of the table", hint: "select" },
1365
+ { key: "g", text: "group by model, or by model and agent", hint: "group" },
1366
+ { key: "s", text: "change the sort", hint: "sort" },
1367
+ { key: "r", text: "reread the metrics log", hint: "refresh" },
1368
+ ];
1369
+ }
1370
+ }
1371
+
1372
+ private chips(entries: ReadonlyArray<readonly [string, string]>, theme: LobbyTheme): string {
1373
+ return entries.map(([key, text]) => `${paint(theme, "accent", key)} ${paint(theme, "dim", text)}`).join(paint(theme, "dim", " "));
1374
+ }
1375
+
1376
+ private hintLine(width: number, theme: LobbyTheme): string {
1377
+ const notice = this.notice && this.now() - this.notice.at < NOTICE_MS ? this.notice : undefined;
1378
+ if (notice) return fit(paint(theme, notice.kind === "warning" ? "warning" : "accent", ` ${notice.text}`), width);
1379
+ const k = (action: LobbyAction) => keyLabel(this.keys[action]).toLowerCase();
1380
+ const badge = (text: string, color: "accent" | "muted" | "warning") => {
1381
+ const label = bold(theme, paint(theme, color, ` ${text} `));
1382
+ return theme.bg ? theme.bg("selectedBg", label) : label;
1383
+ };
1384
+ if (this.help) return fit(`${badge("KEYS", "accent")} ${this.chips([["any key", "closes"]], theme)}`, width);
1385
+ if (this.searching) return fit(`${badge("SEARCH", "warning")} ${this.chips([["enter", "keep"], ["esc", "clear"], ["↑↓", "results"], [k("nextTab"), "next tab"]], theme)}`, width);
1386
+ if (this.mode === "type") {
1387
+ const esc = this.tab === "lobby" && this.host.masterBusy() ? "stop the oracle" : this.lineTarget ? "cancel" : "browse";
1388
+ const extra: Array<[string, string]> = this.tab === "plan" && this.host.planner()?.awaitingAnswers && !this.lineTarget ? [["enter", "answer questions"]] : [["enter", "send"]];
1389
+ return fit(`${badge("TYPE", "accent")} ${this.chips([...extra, ["shift+enter", "newline"], ["esc", esc], [k("nextTab"), "next tab"], [k("help"), "keys"], [k("hide"), "hide"]], theme)}`, width);
1390
+ }
1391
+ const tabHints = this.tabKeys().filter((entry) => entry.hint).map((entry) => [entry.key, entry.hint!] as const);
1392
+ const panes: Array<[string, string]> = this.tab === "lobby" ? [[k("toggleActivity"), "activity"], [k("toggleThinking"), "thinking"]] : [];
1393
+ return fit(`${badge("BROWSE", "muted")} ${this.chips([...tabHints, ...panes, ["/", "search"], ["?", "keys"], [k("hide"), "hide"]], theme)}`, width);
1394
+ }
1395
+
1396
+ /** The help screen: every shortcut, the typing and browsing keys, and this tab's commands. */
1397
+ private helpBody(width: number, height: number, theme: LobbyTheme): string[] {
1398
+ const keyWidth = 13;
1399
+ const row = (key: string, text: string, inner: number) => wrapHanging(`${paint(theme, "accent", fit(key, keyWidth))} `, text, inner);
1400
+ const section = (title: string) => bold(theme, paint(theme, "mdHeading", title));
1401
+ const tabs = this.tabs();
1402
+ const everywhere = (inner: number) => [
1403
+ section("Everywhere"),
1404
+ ...(Object.keys(LOBBY_ACTIONS) as LobbyAction[]).flatMap((action) => row(keyLabel(this.keys[action]), LOBBY_ACTIONS[action].help, inner)),
1405
+ ...row(`Alt+1…${tabs.length}`, "jump to a tab", inner),
1406
+ ...row("Ctrl+C", "clear the prompt, or hide the lobby", inner),
1407
+ ...row("click", "a tab to open it, a pane to give it the keys, a draft plan line to comment on it", inner),
1408
+ ...row("wheel", "scroll the pane under the pointer", inner),
1409
+ ];
1410
+ const modes = (inner: number) => [
1411
+ section("Typing"),
1412
+ ...row("Enter", "send", inner),
1413
+ ...row("Shift+Enter", "new line", inner),
1414
+ ...row("Esc", "stop typing and browse", inner),
1415
+ "",
1416
+ section("Browsing"),
1417
+ ...row("i", "type", inner),
1418
+ ...row("/", "search this tab (esc clears)", inner),
1419
+ ...row("?", "these keys", inner),
1420
+ "",
1421
+ section(`${TAB_LABELS[this.tab]} tab`),
1422
+ ...this.tabKeys().flatMap((entry) => row(entry.key, entry.text, inner)),
1423
+ ];
1424
+ const footer = paint(theme, "dim", "Rebind any shortcut under lobby.keys in the bot-lobby config, e.g. { \"toggleThinking\": \"alt+t\" }. Hidden panes stay hidden next time (lobby.panels); lobby.mouse turns clicks off.");
1425
+ const title = { title: "Keys", right: "any key closes", focused: true, theme } as const;
1426
+ if (width >= 100) {
1427
+ const left = Math.floor((width - 1) / 2);
1428
+ const inner = left - 4;
1429
+ const columns = beside([
1430
+ box(left, height, [...everywhere(inner), "", ...wrapHanging("", footer, inner)], title),
1431
+ box(width - 1 - left, height, modes(width - 1 - left - 4), { focused: true, theme }),
1432
+ ]);
1433
+ return columns;
1434
+ }
1435
+ const inner = width - 4;
1436
+ return box(width, height, [...everywhere(inner), "", ...modes(inner), "", ...wrapHanging("", footer, inner)], title);
1437
+ }
1438
+
1439
+ private body(width: number, height: number, theme: LobbyTheme): string[] {
1440
+ const now = this.now();
1441
+ const query = this.query();
1442
+ this.panes.clear();
1443
+ switch (this.tab) {
1444
+ case "lobby":
1445
+ return this.homeBody(width, height, theme, now);
1446
+ case "tasks":
1447
+ return this.tasksBody(width, height, theme, now);
1448
+ case "plan": {
1449
+ const session = this.host.planner();
1450
+ const lines = renderPlan({
1451
+ ...(session ? { session: this.planView(session) } : {}),
1452
+ profile: this.host.profileLabel("planner"),
1453
+ seats: this.seatViews(undefined),
1454
+ offset: this.planOffset,
1455
+ focus: this.planFocus,
1456
+ draftOffset: this.planDraftOffset,
1457
+ cursor: this.planCursor,
1458
+ tick: this.tick,
1459
+ ...(query ? { query } : {}),
1460
+ layout: this.planLayout,
1461
+ panes: this.panes,
1462
+ }, width, height, theme);
1463
+ if (!session) Object.assign(this.planLayout, { draftRows: 0, draftStart: 0, draftText: [] });
1464
+ this.planCursor = Math.min(this.planCursor, Math.max(0, this.planLayout.draftText.length - 1));
1465
+ return lines;
1466
+ }
1467
+ case "quickfix":
1468
+ this.fixSelected = Math.min(this.fixSelected, Math.max(0, this.fixJobs().length - 1));
1469
+ 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, ...(query ? { query } : {}), panes: this.panes }, width, height, theme);
1470
+ case "issues": {
1471
+ const issues = this.host.issues;
1472
+ this.issueSelected = Math.min(this.issueSelected, Math.max(0, issues.issues.length - 1));
1473
+ const selected = issues.issues[this.issueSelected];
1474
+ const detail = selected ? issues.details.get(selected.number) : undefined;
1475
+ return renderIssues({
1476
+ issues: issues.issues, selected: this.issueSelected, ...(detail ? { detail } : {}), focus: this.issueFocus, detailOffset: this.issueDetailOffset,
1477
+ loading: issues.loading, loaded: issues.loaded, ...(issues.error ? { error: issues.error } : {}), ...(issues.notice ? { notice: issues.notice } : {}), tick: this.tick, now,
1478
+ }, width, height, theme);
1479
+ }
1480
+ case "metrics": {
1481
+ const records = filterRecords(collectMetrics(this.data.metrics, this.data.tasks), query);
1482
+ const groups = sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort);
1483
+ this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
1484
+ const taskTimes = taskTimesByModel(this.data.tasks, records);
1485
+ return renderMetrics({ groups, taskTimes, records, stats: taskStats(this.data.tasks), by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected, ...(query ? { query } : {}), panes: this.panes }, width, height, theme);
1486
+ }
1487
+ }
1488
+ }
1489
+
1490
+ /** The roster: the oracle chairing, then every domain seat in order, seated or not. */
1491
+ private seatViews(session: PlanningSession | undefined): SeatView[] {
1492
+ const seats = session?.seats ?? this.seatSet();
1493
+ const oracleQuestions = session?.questions.filter((question) => question.from === ORACLE_LABEL).length ?? 0;
1494
+ // While the seats work the oracle waits for them; its own part fails only when the round reports an error.
1495
+ const status: SeatView["status"] = !session || session.turns === 0 ? "idle" : session.busy ? "thinking" : session.error ? "failed" : "done";
1496
+ const oracle: SeatView = {
1497
+ label: ORACLE_LABEL,
1498
+ seated: true,
1499
+ status,
1500
+ ...(session?.busy && session.step ? { step: session.step } : {}),
1501
+ questions: oracleQuestions,
1502
+ ready: session?.reply?.status === "ready",
1503
+ profile: this.host.profileLabel("planner"),
1504
+ };
1505
+ const members = PANEL_MEMBERS.map((member): SeatView => {
1506
+ const state = session?.members.find((entry) => entry.member === member);
1507
+ return {
1508
+ label: MEMBER_LABELS[member],
1509
+ seated: seats.has(member),
1510
+ status: state ? (state.status === "thinking" ? "thinking" : state.status) : "idle",
1511
+ ...(state?.step ? { step: state.step } : {}),
1512
+ questions: state?.reply?.questions.length ?? 0,
1513
+ ready: state?.reply?.status === "ready",
1514
+ profile: this.host.seatLabel(member),
1515
+ };
1516
+ });
1517
+ return [oracle, ...members];
1518
+ }
1519
+
1520
+ private planView(session: PlanningSession): PlanView {
1521
+ return {
1522
+ messages: session.messages,
1523
+ ...(session.reply ? { reply: session.reply } : {}),
1524
+ questions: session.questions,
1525
+ notes: session.notes,
1526
+ seats: this.seatViews(session),
1527
+ busy: session.busy,
1528
+ ...(session.step ? { step: session.step } : {}),
1529
+ ...(session.error ? { error: session.error } : {}),
1530
+ turns: session.turns,
1531
+ ...(session.seed ? { seed: session.seed } : {}),
1532
+ ...(session.saved ? { saved: session.saved } : {}),
1533
+ ...(session.title ? { title: session.title } : {}),
1534
+ awaitingAnswers: session.awaitingAnswers,
1535
+ answeredChunks: session.answered.length,
1536
+ lineComments: session.lineComments,
1537
+ };
1538
+ }
1539
+
1540
+ private homeBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
1541
+ const zen = this.host.zen();
1542
+ const feed = this.host.feed;
1543
+ const sessionId = this.host.sessionId();
1544
+ const others = this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && task.ownerSessionId !== sessionId).length;
1545
+ const query = this.query();
1546
+ return renderHome({
1547
+ ...(zen.task ? { task: zen.task, scene: (w: number, h: number) => this.host.scene(w, h) } : {}),
1548
+ chat: feed.chat,
1549
+ ...(feed.reply ? { liveReply: feed.reply } : {}),
1550
+ activity: feed.activity,
1551
+ thoughts: feed.thoughts,
1552
+ busy: this.host.masterBusy(),
1553
+ others,
1554
+ pending: this.data.plans.filter((plan) => plan.status === "pending").length,
1555
+ offsets: this.homeOffsets,
1556
+ ...(this.mode === "browse" ? { focus: this.homeFocus } : {}),
1557
+ panes: this.panes,
1558
+ tick: this.tick,
1559
+ now,
1560
+ panels: this.panels,
1561
+ ...(query ? { query } : {}),
1562
+ keys: {
1563
+ scene: keyLabel(this.keys.toggleScene),
1564
+ conversation: keyLabel(this.keys.toggleConversation),
1565
+ activity: keyLabel(this.keys.toggleActivity),
1566
+ thinking: keyLabel(this.keys.toggleThinking),
1567
+ },
1568
+ }, width, height, theme);
1569
+ }
1570
+
1571
+ private tasksBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
1572
+ const rows = this.taskRowList();
1573
+ this.tasksSelected = Math.min(this.tasksSelected, Math.max(0, rows.length - 1));
1574
+ const row = rows[this.tasksSelected];
1575
+ const detailWidth = tasksWidths(width).detail - 4;
1576
+ let detail: string[] = [];
1577
+ if (row?.kind === "task") {
1578
+ const task = this.data.tasks.find((entry) => entry.id === row.id);
1579
+ if (task) detail = taskDetailLines(task, this.data.comments.get(task.id) ?? [], this.host.sessionId(), detailWidth, now, theme);
1580
+ } else if (row?.kind === "plan") {
1581
+ const plan = this.data.plans.find((entry) => entry.id === row.id);
1582
+ if (plan) detail = planDetailLines(plan, detailWidth, now, theme);
1583
+ }
1584
+ const query = this.query();
1585
+ return renderTasks({ rows, selected: this.tasksSelected, detail, focus: this.tasksFocus, detailOffset: this.tasksDetailOffset, ...(query ? { query } : {}), panes: this.panes }, width, height, theme);
1586
+ }
1587
+
1588
+ render(width: number): string[] {
1589
+ const theme = this.host.theme();
1590
+ const rows = Math.max(1, this.host.rows());
1591
+ const top = [this.tabBar(width, theme)];
1592
+ const prompt = this.promptLines(width, theme);
1593
+ const search = this.searchLines(width, theme);
1594
+ const hint = this.hintLine(width, theme);
1595
+ const bodyHeight = Math.max(0, rows - top.length - search.length - prompt.length - 1);
1596
+ this.bodyTop = top.length;
1597
+ this.promptTop = top.length + bodyHeight + search.length;
1598
+ let body = this.help ? this.helpBody(width, bodyHeight, theme) : this.body(width, bodyHeight, theme);
1599
+ // A pane scrolled back that gained lines moves with them, so it keeps showing what was being read.
1600
+ if (!this.help && this.clampScroll()) body = this.body(width, bodyHeight, theme);
1601
+ const query = this.query();
1602
+ if (query && !this.help) body = body.map((line) => highlight(line, query));
1603
+ return [...top, ...body, ...search, ...prompt, hint].slice(0, rows).map((line) => fit(line, width));
1604
+ }
1605
+
1606
+ invalidate(): void {
1607
+ this.editor.invalidate();
1608
+ this.search.invalidate();
1609
+ }
1610
+ }