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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lobby/view.ts CHANGED
@@ -1,30 +1,34 @@
1
1
  /**
2
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.
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.
9
11
  */
10
- import { decodeKittyPrintable, Editor, Key, matchesKey, visibleWidth, type Component, type EditorTheme, type Focusable, type TUI } from "@earendil-works/pi-tui";
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";
11
13
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
12
14
  import type { AgentRun } from "../schemas/findings.ts";
13
15
  import type { PlannedTask } from "../state/backlog.ts";
14
16
  import type { PlanComment } from "../state/comments.ts";
15
17
  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 { PANEL_MEMBERS, type LobbyAgentKind, type LobbyPanel, type PanelMember } from "../schemas/configuration.ts";
19
+ import type { ChatEntry, LobbyFeed } from "./feed.ts";
20
+ import type { BackgroundSession } from "./sessions.ts";
18
21
  import type { QuickFixQueue } from "./quickfix.ts";
19
22
  import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } from "./planner.ts";
20
23
  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";
24
+ import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, type KeyMap, type LobbyAction } from "./keys.ts";
25
+ import { beside, bold, box, fit, highlight, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "./layout.ts";
26
+ import { HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
27
+ import { filterRows, planDetailLines, renderTasks, taskDetailLines, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
28
+ import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
29
+ import { filterJobs, newestFirst, renderQuickFix } from "./tabs/quickfix.ts";
26
30
  import { renderIssues } from "./tabs/issues.ts";
27
- import { renderMetrics } from "./tabs/metrics.ts";
31
+ import { filterRecords, renderMetrics } from "./tabs/metrics.ts";
28
32
 
29
33
  export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "metrics"] as const;
30
34
  export type TabId = (typeof TAB_IDS)[number];
@@ -38,11 +42,36 @@ export const TAB_LABELS: Record<TabId, string> = {
38
42
  metrics: "Metrics",
39
43
  };
40
44
 
45
+ /** The tabs on show: Issues only while `lobby.issues` switches it on. */
46
+ export function visibleTabs(issues: boolean): TabId[] {
47
+ return TAB_IDS.filter((tab) => issues || tab !== "issues");
48
+ }
49
+
41
50
  /** Tabs where the prompt is the point: they open in typing mode and typing in browsing mode resumes it. */
42
51
  const PROMPT_FIRST: ReadonlySet<TabId> = new Set(["lobby", "plan", "quickfix"]);
43
52
 
44
53
  export type LobbyMode = "type" | "browse";
45
54
 
55
+ /** What each pane toggle is called in notices and help. */
56
+ const PANEL_NAMES: Record<LobbyPanel, string> = { animations: "oracle and agent animations", conversation: "conversation", activity: "activity log", thinking: "thinking" };
57
+ const PANEL_ACTIONS: Record<LobbyPanel, LobbyAction> = { animations: "toggleScene", conversation: "toggleConversation", activity: "toggleActivity", thinking: "toggleThinking" };
58
+
59
+ /** Which session the Lobby tab shows and talks to: this window, one it started in the background, or one in another terminal. */
60
+ export type SessionView = { kind: "here" } | { kind: "background"; key: string } | { kind: "other"; taskId: string };
61
+
62
+ /** One row of the session switcher. */
63
+ export interface SessionEntry {
64
+ view: SessionView;
65
+ name: string;
66
+ where: "this window" | "background" | "other terminal";
67
+ task?: Task;
68
+ /** What it is doing: working, idle, starting, ended, or its task's state. */
69
+ status: string;
70
+ auto: boolean;
71
+ /** Questions it waits on you for. */
72
+ waiting: number;
73
+ }
74
+
46
75
  /** Everything the view needs from pi and bot-lobby. */
47
76
  export interface LobbyHost {
48
77
  rows(): number;
@@ -50,8 +79,8 @@ export interface LobbyHost {
50
79
  sessionId(): string | undefined;
51
80
  /** This session's active task and its runs, as the zen widget sees them. */
52
81
  zen(): { task?: Task; runs: readonly AgentRun[] };
53
- /** Draw the zen scene into at most `height` lines. */
54
- scene(width: number, height: number): string[];
82
+ /** Draw the zen scene into at most `height` lines: animated, or only the task's status when `animated` is false. */
83
+ scene(width: number, height: number, animated: boolean): string[];
55
84
  /** Advance the scene's clock; returns the delay it wants until the next step. */
56
85
  advanceScene(now: number): number;
57
86
  feed: LobbyFeed;
@@ -71,11 +100,39 @@ export interface LobbyHost {
71
100
  planner(): PlanningSession | undefined;
72
101
  /** Start a planning session (replacing any other) with these seats on the panel. */
73
102
  newPlanner(seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession;
103
+ /** The oracle puts the panel's open questions to the user, one questionnaire at a time; returns a notice. */
104
+ answerPanel(): Promise<string>;
74
105
  /** The seats a new session starts with, from settings. */
75
106
  defaultPanel(): readonly PanelMember[];
76
107
  /** `model · thinking` a panel seat runs on. */
77
108
  seatLabel(member: PanelMember): string;
78
109
  issues: IssuesState;
110
+ /** The Issues tab is switched on (`lobby.issues`). */
111
+ issuesEnabled(): boolean;
112
+ /** Which panes show (`lobby.panels`), and remembering a change. */
113
+ panels(): Record<LobbyPanel, boolean>;
114
+ savePanels(panels: Record<LobbyPanel, boolean>): void;
115
+ /** Key overrides from the config (`lobby.keys`). */
116
+ keys(): Readonly<Record<string, string>>;
117
+ /** Open bot-lobby's settings (or one agent's entry); the lobby steps aside and rereads the config after. */
118
+ openSettings(entry?: "quickfix" | "planner"): Promise<void>;
119
+ /** This window's pi session name, when it has one. */
120
+ sessionName(): string | undefined;
121
+ /** Background sessions this window started, oldest first. */
122
+ sessions(): readonly BackgroundSession[];
123
+ /** Start a task in a new background session, named after the task; the session, or why not. */
124
+ startSession(start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string;
125
+ /** Put the oldest question a background session waits on to the user in this window. */
126
+ answerDialog(session: BackgroundSession): Promise<void>;
127
+ /** Auto mode for any task, whichever session drives it. */
128
+ isAuto(taskId: string): boolean;
129
+ setAuto(taskId: string, on: boolean): void;
130
+ /** Leave a message for the oracle of a task another terminal's session drives; returns a notice. */
131
+ sendToTask(taskId: string, text: string): string;
132
+ /** Another session's conversation, read from its saved session file. */
133
+ sessionChat(sessionId: string): ChatEntry[];
134
+ /** A task's status without animations, for a session other than this window's. */
135
+ taskScene(task: Task, width: number, height: number): string[];
79
136
  profileLabel(kind: LobbyAgentKind): string;
80
137
  requestRender(): void;
81
138
  now?(): number;
@@ -87,6 +144,8 @@ export const IDLE_MS = 1000;
87
144
  /** How often task and metrics data is reread from disk while the lobby is open. */
88
145
  export const DATA_REFRESH_MS = 2000;
89
146
  const NOTICE_MS = 6000;
147
+ /** Lines one wheel notch scrolls. */
148
+ const WHEEL_LINES = 3;
90
149
 
91
150
  /** The prompt editor, with its target written into the top border. */
92
151
  class LobbyEditor extends Editor {
@@ -112,17 +171,52 @@ interface Notice {
112
171
  kind: "info" | "warning";
113
172
  }
114
173
 
174
+ /** A key and what it does, for the help screen. */
175
+ interface KeyHelp {
176
+ key: string;
177
+ text: string;
178
+ }
179
+
115
180
  function isPrintable(data: string): string | undefined {
116
181
  const kitty = decodeKittyPrintable(data);
117
182
  if (kitty !== undefined) return kitty;
118
183
  return data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) !== 127 ? data : undefined;
119
184
  }
120
185
 
186
+ /** A mouse report in SGR encoding (`ESC [ < b ; x ; y M` or `m`), zero-based cells. */
187
+ export interface MouseReport {
188
+ kind: "press" | "release" | "wheel";
189
+ button: number;
190
+ x: number;
191
+ y: number;
192
+ /** Wheel direction: -1 up, 1 down. */
193
+ delta: number;
194
+ }
195
+
196
+ const SGR_MOUSE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
197
+
198
+ /** Parse an SGR mouse report, the form terminals send once the lobby turns mouse reporting on. */
199
+ export function parseMouse(data: string): MouseReport | undefined {
200
+ const match = SGR_MOUSE.exec(data);
201
+ if (!match) return undefined;
202
+ const code = Number(match[1]);
203
+ const x = Number(match[2]) - 1;
204
+ const y = Number(match[3]) - 1;
205
+ if (code & 64) return { kind: "wheel", button: code, x, y, delta: (code & 1) === 1 ? 1 : -1 };
206
+ return { kind: match[4] === "M" ? "press" : "release", button: code & 3, x, y, delta: 0 };
207
+ }
208
+
209
+ function clip(text: string, max: number): string {
210
+ const flat = text.replace(/\s+/g, " ").trim();
211
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
212
+ }
213
+
121
214
  export class LobbyView implements Component, Focusable {
122
215
  tab: TabId = "lobby";
123
216
  mode: LobbyMode = "type";
124
217
  private readonly host: LobbyHost;
125
218
  private readonly editor: LobbyEditor;
219
+ private readonly search: Input;
126
220
  private focusedState = false;
127
221
  private tick = 0;
128
222
  private timer: ReturnType<typeof setTimeout> | undefined;
@@ -131,8 +225,35 @@ export class LobbyView implements Component, Focusable {
131
225
  private readonly drafts: Partial<Record<TabId, string>> = {};
132
226
  /** Double-press confirmations, such as discarding a plan. */
133
227
  private armed: string | undefined;
228
+ /** The keys, the panes on show and whether Issues is on, read once from the config. */
229
+ private keys: KeyMap;
230
+ private panels: Record<LobbyPanel, boolean>;
231
+ private issuesOn: boolean;
232
+ /** The help screen covers the body. */
233
+ help = false;
234
+ /** The session switcher covers the body. */
235
+ picking = false;
236
+ private pickIndex = 0;
237
+ /** The session the Lobby tab shows and talks to. */
238
+ viewing: SessionView = { kind: "here" };
239
+ /** The Lobby prompt starts a task in a new session instead of talking to an oracle. */
240
+ newSession = false;
241
+ /** The search bar has the keys. */
242
+ searching = false;
243
+ /** Each tab's search, kept while you move between tabs. */
244
+ private readonly queries: Partial<Record<TabId, string>> = {};
245
+ /** Where the body starts and where each tab's label sits in the tab bar, for clicks. */
246
+ private bodyTop = 1;
247
+ private promptTop = Number.POSITIVE_INFINITY;
248
+ private tabSpans: Array<{ tab: TabId; from: number; to: number }> = [];
134
249
 
135
- private chatOffset = 0;
250
+ /** Each Lobby pane's scroll, in lines back from its newest, and the pane the keys scroll. */
251
+ private readonly homeOffsets: Record<HomePane, number> = { conversation: 0, activity: 0, thinking: 0 };
252
+ homeFocus: HomePane = "conversation";
253
+ /** How many lines each pane held last frame, so a pane scrolled back stays on what you are reading as lines arrive. */
254
+ private readonly seenTotals = new Map<string, number>();
255
+ /** Where the current tab's scrollable panes landed in the last frame. */
256
+ private readonly panes: PaneLayout = new Map();
136
257
  private tasksSelected = 0;
137
258
  private tasksFocus: "list" | "detail" = "list";
138
259
  private tasksDetailOffset = 0;
@@ -140,6 +261,10 @@ export class LobbyView implements Component, Focusable {
140
261
  private planOffset = 0;
141
262
  private planFocus: "talk" | "draft" = "talk";
142
263
  private planDraftOffset = 0;
264
+ /** The draft line the cursor is on, and the one a comment is being written for. */
265
+ planCursor = 0;
266
+ private lineTarget: string | undefined;
267
+ private readonly planLayout: PlanLayout = { draftTop: 0, draftLeft: 0, draftWidth: 0, draftRows: 0, draftStart: 0, draftText: [] };
143
268
  /** Seats for the next planning session, until one exists. */
144
269
  private seats: Set<PanelMember> | undefined;
145
270
  private fixSelected = 0;
@@ -165,6 +290,10 @@ export class LobbyView implements Component, Focusable {
165
290
  this.host = host;
166
291
  this.editor = new LobbyEditor(tui, editorTheme, { paddingX: 1 });
167
292
  this.editor.onSubmit = (text) => this.submit(text);
293
+ this.search = new Input({ prompt: "", placeholder: "type to search this tab" });
294
+ this.keys = keyMap(host.keys());
295
+ this.panels = { ...host.panels() };
296
+ this.issuesOn = host.issuesEnabled();
168
297
  }
169
298
 
170
299
  get focused(): boolean {
@@ -173,7 +302,12 @@ export class LobbyView implements Component, Focusable {
173
302
 
174
303
  set focused(value: boolean) {
175
304
  this.focusedState = value;
176
- this.editor.focused = value && this.mode === "type";
305
+ this.syncFocus();
306
+ }
307
+
308
+ private syncFocus(): void {
309
+ this.editor.focused = this.focusedState && this.mode === "type" && !this.searching;
310
+ this.search.focused = this.focusedState && this.searching;
177
311
  }
178
312
 
179
313
  private now(): number {
@@ -210,7 +344,8 @@ export class LobbyView implements Component, Focusable {
210
344
  return this.host.masterBusy()
211
345
  || Boolean(this.host.quickfix.running)
212
346
  || Boolean(this.host.planner()?.busy)
213
- || this.host.issues.loading
347
+ || this.host.sessions().some((session) => session.busy)
348
+ || (this.issuesOn && this.host.issues.loading)
214
349
  || zen.runs.some((run) => run.status === "running")
215
350
  || Boolean(zen.task && !zen.task.paused && !TERMINAL_STATES.includes(zen.task.state));
216
351
  }
@@ -221,7 +356,7 @@ export class LobbyView implements Component, Focusable {
221
356
  const now = this.now();
222
357
  this.tick += 1;
223
358
  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));
359
+ if (this.tab === "lobby" && this.panels.animations && this.host.zen().task) delay = Math.min(delay, this.host.advanceScene(now));
225
360
  this.refreshData(false);
226
361
  this.host.requestRender();
227
362
  this.schedule(delay);
@@ -231,35 +366,48 @@ export class LobbyView implements Component, Focusable {
231
366
  refreshData(force: boolean): void {
232
367
  const now = this.now();
233
368
  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
369
  this.data = {
241
- tasks,
370
+ tasks: this.host.tasks(),
242
371
  plans: this.host.plans(),
243
- comments,
372
+ comments: new Map(),
244
373
  metrics: this.tab === "metrics" ? this.host.metrics() : this.data.metrics,
245
374
  at: now,
246
375
  };
376
+ if (this.tab === "tasks") {
377
+ const row = this.taskRowList()[this.tasksSelected];
378
+ if (row?.kind === "task") this.data.comments.set(row.id, this.host.comments(row.id));
379
+ }
247
380
  }
248
381
 
249
382
  private say(text: string, kind: Notice["kind"] = "info"): void {
250
383
  this.notice = { text, at: this.now(), kind };
251
384
  }
252
385
 
386
+ /** The search in force on this tab. */
387
+ query(): string | undefined {
388
+ return this.queries[this.tab]?.trim() || undefined;
389
+ }
390
+
253
391
  /* ---------------------------------------------------------------- tabs */
254
392
 
393
+ tabs(): TabId[] {
394
+ return visibleTabs(this.issuesOn);
395
+ }
396
+
255
397
  setTab(tab: TabId): void {
256
398
  if (tab === this.tab) return;
399
+ 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");
257
400
  this.drafts[this.tab] = this.editor.getText();
258
401
  this.tab = tab;
259
402
  this.editor.setText(this.drafts[tab] ?? "");
260
403
  this.commentTarget = undefined;
404
+ this.lineTarget = undefined;
261
405
  this.issueDraft = false;
262
406
  this.armed = undefined;
407
+ this.help = false;
408
+ this.picking = false;
409
+ this.newSession = false;
410
+ this.closeSearch();
263
411
  this.setMode(PROMPT_FIRST.has(tab) ? "type" : "browse");
264
412
  this.refreshData(true);
265
413
  if (tab === "issues" && !this.host.issues.loaded && !this.host.issues.loading) void this.host.issues.refresh().then(() => this.loadIssueDetail());
@@ -267,19 +415,21 @@ export class LobbyView implements Component, Focusable {
267
415
  }
268
416
 
269
417
  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]!);
418
+ const tabs = this.tabs();
419
+ const index = Math.max(0, tabs.indexOf(this.tab));
420
+ this.setTab(tabs[(index + direction + tabs.length) % tabs.length]!);
272
421
  }
273
422
 
274
423
  private setMode(mode: LobbyMode): void {
275
424
  this.mode = this.tab === "metrics" ? "browse" : mode;
276
- this.editor.focused = this.focusedState && this.mode === "type";
425
+ this.syncFocus();
277
426
  }
278
427
 
279
428
  /** Open the prompt for a comment on `taskId`'s plan (from any tab). */
280
429
  commentOn(taskId: string): void {
281
430
  this.setTab("tasks");
282
- const rows = this.taskRowList(this.data.tasks);
431
+ delete this.queries.tasks;
432
+ const rows = this.taskRowList();
283
433
  const index = rows.findIndex((row) => row.id === taskId);
284
434
  if (index >= 0) this.tasksSelected = index;
285
435
  this.refreshData(true);
@@ -297,6 +447,92 @@ export class LobbyView implements Component, Focusable {
297
447
  void session.open();
298
448
  }
299
449
 
450
+ /** Show or hide one pane and remember it in the config. */
451
+ togglePanel(panel: LobbyPanel): void {
452
+ const shown = !this.panels[panel];
453
+ this.panels = { ...this.panels, [panel]: shown };
454
+ this.host.savePanels(this.panels);
455
+ const key = keyLabel(this.keys[PANEL_ACTIONS[panel]]);
456
+ const where = this.tab === "lobby" ? "" : " on the Lobby tab";
457
+ this.say(`${PANEL_NAMES[panel]} ${shown ? "shown" : "hidden"}${where} · ${key} ${shown ? "hides" : "shows"} it`);
458
+ }
459
+
460
+ panelShown(panel: LobbyPanel): boolean {
461
+ return this.panels[panel];
462
+ }
463
+
464
+ /** Reread keys, panes and the Issues switch, after the settings changed them. */
465
+ reloadConfig(): void {
466
+ this.keys = keyMap(this.host.keys());
467
+ this.panels = { ...this.host.panels() };
468
+ this.issuesOn = this.host.issuesEnabled();
469
+ if (!this.tabs().includes(this.tab)) this.setTab("lobby");
470
+ }
471
+
472
+ private openSettings(entry?: "quickfix" | "planner"): void {
473
+ void this.host.openSettings(entry).then(() => this.host.requestRender());
474
+ }
475
+
476
+ /* -------------------------------------------------------------- search */
477
+
478
+ private openSearch(): void {
479
+ this.help = false;
480
+ this.searching = true;
481
+ this.search.setValue(this.queries[this.tab] ?? "");
482
+ this.syncFocus();
483
+ }
484
+
485
+ /** Close the search bar; the query stays in force unless `clear`. */
486
+ private closeSearch(clear = false): void {
487
+ if (clear) delete this.queries[this.tab];
488
+ this.searching = false;
489
+ this.syncFocus();
490
+ }
491
+
492
+ private searchKey(data: string): void {
493
+ if (matchesKey(data, Key.escape)) {
494
+ const had = Boolean(this.query());
495
+ this.closeSearch(true);
496
+ if (had) this.say("search cleared");
497
+ return;
498
+ }
499
+ if (matchesKey(data, Key.enter)) {
500
+ if (!this.query()) delete this.queries[this.tab];
501
+ return this.closeSearch();
502
+ }
503
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
504
+ // Keep the query, let the arrows move through what it found.
505
+ this.closeSearch();
506
+ return this.scroll(matchesKey(data, Key.up) ? -1 : 1);
507
+ }
508
+ this.search.handleInput(data);
509
+ const value = this.search.getValue();
510
+ if (value) this.queries[this.tab] = value;
511
+ else delete this.queries[this.tab];
512
+ this.resetSelections();
513
+ }
514
+
515
+ /** A new search starts every list at its top. */
516
+ private resetSelections(): void {
517
+ for (const pane of HOME_PANES) this.homeOffsets[pane] = 0;
518
+ this.planOffset = 0;
519
+ switch (this.tab) {
520
+ case "tasks":
521
+ this.tasksSelected = 0;
522
+ this.tasksDetailOffset = 0;
523
+ return;
524
+ case "quickfix":
525
+ this.fixSelected = 0;
526
+ this.fixDetailOffset = 0;
527
+ return;
528
+ case "metrics":
529
+ this.metricsSelected = 0;
530
+ return;
531
+ default:
532
+ return;
533
+ }
534
+ }
535
+
300
536
  /* --------------------------------------------------------------- input */
301
537
 
302
538
  handleInput(data: string): void {
@@ -305,22 +541,69 @@ export class LobbyView implements Component, Focusable {
305
541
  }
306
542
 
307
543
  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()) {
544
+ const mouse = parseMouse(data);
545
+ if (mouse) return this.mouseReport(mouse);
546
+ const action = actionFor(data, this.keys);
547
+ if (this.help) {
548
+ this.help = false;
549
+ // Esc, ?, q and the help key only close it; anything else also does its job.
550
+ if (action === "help" || matchesKey(data, Key.escape) || data === "?" || data === "q" || matchesKey(data, Key.enter)) return;
551
+ }
552
+ // The session switcher takes the keys while it is open (shortcuts other than its own still work).
553
+ if (this.picking && (!action || action === "sessions" || action === "newSession") && this.pickerKey(data, action)) return;
554
+ // While searching, the shortcuts still work; everything else edits the query.
555
+ if (this.searching) return action ? this.runAction(action) : this.searchKey(data);
556
+ if (action) return this.runAction(action);
557
+ const tabs = this.tabs();
558
+ for (const [index, tab] of tabs.entries()) {
312
559
  if (matchesKey(data, Key.alt(String(index + 1) as "1"))) return this.setTab(tab);
313
560
  }
314
561
  if (matchesKey(data, Key.ctrl("c"))) {
315
562
  if (this.editor.getText()) return this.editor.setText("");
316
563
  return this.host.hide();
317
564
  }
318
- if (matchesKey(data, Key.pageUp)) return this.scroll(-10);
319
- if (matchesKey(data, Key.pageDown)) return this.scroll(10);
320
565
  if (this.mode === "type") return this.typeKey(data);
321
566
  return this.browseKey(data);
322
567
  }
323
568
 
569
+ private runAction(action: LobbyAction): void {
570
+ switch (action) {
571
+ case "hide":
572
+ return this.host.hide();
573
+ case "help":
574
+ this.help = !this.help;
575
+ return;
576
+ case "settings":
577
+ return this.openSettings();
578
+ case "savePlan":
579
+ return this.savePlan();
580
+ case "sessions":
581
+ return this.picking ? void (this.picking = false) : this.openPicker();
582
+ case "newSession":
583
+ return this.startNewSessionPrompt();
584
+ case "toggleAuto":
585
+ return this.toggleAuto();
586
+ case "search":
587
+ return this.searching ? this.closeSearch() : this.openSearch();
588
+ case "nextTab":
589
+ return this.cycleTab(1);
590
+ case "prevTab":
591
+ return this.cycleTab(-1);
592
+ case "toggleScene":
593
+ return this.togglePanel("animations");
594
+ case "toggleConversation":
595
+ return this.togglePanel("conversation");
596
+ case "toggleActivity":
597
+ return this.togglePanel("activity");
598
+ case "toggleThinking":
599
+ return this.togglePanel("thinking");
600
+ case "scrollUp":
601
+ return this.scroll(-this.pageSize());
602
+ case "scrollDown":
603
+ return this.scroll(this.pageSize());
604
+ }
605
+ }
606
+
324
607
  private typeKey(data: string): void {
325
608
  if (matchesKey(data, Key.escape)) {
326
609
  if (this.tab === "lobby" && this.host.masterBusy() && !this.editor.getText().trim()) {
@@ -329,15 +612,32 @@ export class LobbyView implements Component, Focusable {
329
612
  }
330
613
  this.commentTarget = undefined;
331
614
  this.issueDraft = false;
615
+ this.newSession = false;
616
+ if (this.lineTarget) {
617
+ this.lineTarget = undefined;
618
+ this.planFocus = "draft";
619
+ }
332
620
  return this.setMode("browse");
333
621
  }
334
622
  this.editor.handleInput(data);
335
623
  }
336
624
 
337
625
  private browseKey(data: string): void {
626
+ if (matchesKey(data, Key.escape) && this.query()) {
627
+ delete this.queries[this.tab];
628
+ return this.say("search cleared");
629
+ }
630
+ if (data === "/") return this.openSearch();
631
+ if (data === "?") {
632
+ this.help = true;
633
+ return;
634
+ }
338
635
  if (matchesKey(data, Key.up) || (!PROMPT_FIRST.has(this.tab) && data === "k")) return this.scroll(-1);
339
636
  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]!);
637
+ if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) return this.movePaneFocus(matchesKey(data, Key.left) ? -1 : 1);
638
+ if (matchesKey(data, Key.home) || matchesKey(data, Key.end)) return this.scrollToEdge(matchesKey(data, Key.home));
639
+ const tabs = this.tabs();
640
+ if (!PROMPT_FIRST.has(this.tab) && /^[1-9]$/.test(data) && Number(data) <= tabs.length) return this.setTab(tabs[Number(data) - 1]!);
341
641
  if (data === "i" && this.tab !== "metrics" && this.tab !== "tasks" && this.tab !== "issues") return this.setMode("type");
342
642
  if (this.tabCommand(data)) return;
343
643
  const printable = isPrintable(data);
@@ -347,27 +647,82 @@ export class LobbyView implements Component, Focusable {
347
647
  }
348
648
  }
349
649
 
350
- /** Arrow keys and page keys: scroll or select, depending on the tab and its focus. */
351
- private scroll(delta: number): void {
650
+ /** The pane the keys scroll on this tab. */
651
+ private focusedPane(): string {
352
652
  switch (this.tab) {
353
653
  case "lobby":
354
- this.chatOffset = Math.max(0, this.chatOffset - delta);
654
+ return this.homePane();
655
+ case "plan":
656
+ return this.planFocus;
657
+ case "tasks":
658
+ return this.tasksFocus;
659
+ case "quickfix":
660
+ return this.fixFocus;
661
+ case "issues":
662
+ return this.issueFocus;
663
+ case "metrics":
664
+ return "table";
665
+ }
666
+ }
667
+
668
+ /** The focused Lobby pane, or the first one showing when it is hidden. */
669
+ private homePane(): HomePane {
670
+ const shown = this.shownHomePanes();
671
+ if (!shown.includes(this.homeFocus) && shown.length > 0) this.homeFocus = shown[0]!;
672
+ return this.homeFocus;
673
+ }
674
+
675
+ private shownHomePanes(): HomePane[] {
676
+ const drawn = HOME_PANES.filter((pane) => this.tab === "lobby" && this.panes.has(pane));
677
+ return drawn.length > 0 ? drawn : HOME_PANES.filter((pane) => this.panels[pane]);
678
+ }
679
+
680
+ /** A page of the focused pane: its rows less one, so a line of context carries over. */
681
+ private pageSize(): number {
682
+ const pane = this.panes.get(this.focusedPane());
683
+ return pane && pane.rows > 2 ? pane.rows - 1 : 10;
684
+ }
685
+
686
+ /** ← → move between the tab's panes. */
687
+ private movePaneFocus(direction: -1 | 1): void {
688
+ switch (this.tab) {
689
+ case "lobby": {
690
+ const shown = this.shownHomePanes();
691
+ if (shown.length === 0) return;
692
+ const index = Math.max(0, shown.indexOf(this.homePane()));
693
+ this.homeFocus = shown[(index + direction + shown.length) % shown.length]!;
355
694
  return;
695
+ }
356
696
  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);
697
+ this.planFocus = direction < 0 ? "talk" : "draft";
698
+ if (this.planFocus === "draft") this.cursorToView();
359
699
  return;
360
700
  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);
701
+ this.tasksFocus = direction < 0 ? "list" : "detail";
363
702
  return;
364
703
  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
- }
704
+ this.fixFocus = direction < 0 ? "list" : "detail";
370
705
  return;
706
+ case "issues":
707
+ this.issueFocus = direction < 0 ? "list" : "detail";
708
+ return;
709
+ case "metrics":
710
+ return;
711
+ }
712
+ }
713
+
714
+ /** Arrow keys (±1) and page keys (± a page): scroll or select in the tab's focused pane. */
715
+ private scroll(delta: number): void {
716
+ switch (this.tab) {
717
+ case "lobby":
718
+ return this.scrollPane(this.homePane(), delta);
719
+ case "plan":
720
+ return this.scrollPane(this.planFocus, delta);
721
+ case "tasks":
722
+ // Page keys always page the detail; the arrows follow the focus.
723
+ return this.scrollPane(Math.abs(delta) > 1 ? "detail" : this.tasksFocus, delta);
724
+ case "quickfix":
725
+ return this.scrollPane(Math.abs(delta) > 1 ? "detail" : this.fixFocus, delta);
371
726
  case "issues":
372
727
  if (this.issueFocus === "detail" || Math.abs(delta) > 1) this.issueDetailOffset = Math.max(0, this.issueDetailOffset + delta);
373
728
  else {
@@ -377,13 +732,160 @@ export class LobbyView implements Component, Focusable {
377
732
  }
378
733
  return;
379
734
  case "metrics":
380
- this.metricsSelected = Math.max(0, this.metricsSelected + delta);
735
+ return this.scrollPane("table", delta);
736
+ }
737
+ }
738
+
739
+ /**
740
+ * Scroll one pane of the current tab by `delta` lines, positive toward its
741
+ * end. Lists move their selection instead; the draft moves its cursor (the
742
+ * wheel scrolls it and the cursor follows only when it would leave the screen).
743
+ */
744
+ private scrollPane(pane: string, delta: number, wheel = false): void {
745
+ if (delta === 0) return;
746
+ switch (pane) {
747
+ case "conversation":
748
+ case "activity":
749
+ case "thinking":
750
+ this.homeOffsets[pane] = Math.max(0, this.homeOffsets[pane] - delta);
751
+ break;
752
+ case "talk":
753
+ this.planOffset = Math.max(0, this.planOffset - delta);
754
+ break;
755
+ case "draft":
756
+ if (wheel) this.wheelDraft(delta);
757
+ else this.moveCursor(delta);
758
+ break;
759
+ case "list":
760
+ if (this.tab === "tasks") this.selectTask(this.tasksSelected + Math.sign(delta));
761
+ else if (this.tab === "quickfix") {
762
+ this.fixSelected = Math.max(0, Math.min(this.fixJobs().length - 1, this.fixSelected + Math.sign(delta)));
763
+ this.fixDetailOffset = 0;
764
+ }
765
+ break;
766
+ case "detail":
767
+ if (this.tab === "tasks") this.tasksDetailOffset = Math.max(0, this.tasksDetailOffset + delta);
768
+ else if (this.tab === "quickfix") this.fixDetailOffset = Math.max(0, this.fixDetailOffset + delta);
769
+ break;
770
+ case "table":
771
+ this.metricsSelected = Math.max(0, this.metricsSelected + (wheel ? Math.sign(delta) : delta));
772
+ break;
773
+ }
774
+ this.clampScroll();
775
+ }
776
+
777
+ /** Home and End: the oldest or first line of the focused pane, or back to its newest or last. */
778
+ private scrollToEdge(start: boolean): void {
779
+ const pane = this.focusedPane();
780
+ const far = start ? -1e9 : 1e9;
781
+ if (pane === "draft") {
782
+ const last = this.planLayout.draftText.length - 1;
783
+ this.planCursor = start ? 0 : Math.max(0, last);
784
+ this.moveCursor(0);
785
+ return;
786
+ }
787
+ if (pane === "list" && this.tab === "tasks") return this.selectTask(start ? 0 : this.taskRowList().length - 1);
788
+ if (pane === "list" && this.tab === "quickfix") {
789
+ this.fixSelected = start ? 0 : Math.max(0, this.fixJobs().length - 1);
790
+ this.fixDetailOffset = 0;
791
+ return;
792
+ }
793
+ if (pane === "table") {
794
+ this.metricsSelected = start ? 0 : Number.MAX_SAFE_INTEGER;
795
+ return;
796
+ }
797
+ this.scrollPane(pane, far);
798
+ }
799
+
800
+ /** The wheel over the draft scrolls it; the cursor follows only when it would leave the screen. */
801
+ private wheelDraft(delta: number): void {
802
+ const rows = Math.max(1, this.planLayout.draftRows);
803
+ const max = Math.max(0, this.planLayout.draftText.length - rows);
804
+ this.planDraftOffset = Math.max(0, Math.min(max, this.planLayout.draftStart + delta));
805
+ if (this.planCursor < this.planDraftOffset) this.planCursor = this.planDraftOffset;
806
+ if (this.planCursor >= this.planDraftOffset + rows) this.planCursor = this.planDraftOffset + rows - 1;
807
+ }
808
+
809
+ /**
810
+ * Keep every offset within what its pane holds, so scrolling back the other
811
+ * way responds at once; a pane scrolled back from its newest line stays on
812
+ * the lines being read as new ones arrive. True when that moved a pane.
813
+ */
814
+ private clampScroll(): boolean {
815
+ let moved = false;
816
+ const limit = (name: string, offset: number, anchored: boolean): number => {
817
+ const pane = this.panes.get(name);
818
+ if (!pane) return offset;
819
+ const seen = this.seenTotals.get(name);
820
+ this.seenTotals.set(name, pane.total);
821
+ let next = offset;
822
+ if (anchored && next > 0 && seen !== undefined && pane.total > seen) {
823
+ next += pane.total - seen;
824
+ moved = true;
825
+ }
826
+ return Math.min(next, Math.max(0, pane.total - pane.rows));
827
+ };
828
+ switch (this.tab) {
829
+ case "lobby":
830
+ for (const pane of HOME_PANES) this.homeOffsets[pane] = limit(pane, this.homeOffsets[pane], true);
831
+ break;
832
+ case "plan":
833
+ this.planOffset = limit("talk", this.planOffset, true);
834
+ break;
835
+ case "tasks":
836
+ this.tasksDetailOffset = limit("detail", this.tasksDetailOffset, false);
837
+ break;
838
+ case "quickfix":
839
+ this.fixDetailOffset = limit("detail", this.fixDetailOffset, false);
840
+ break;
841
+ default:
842
+ break;
843
+ }
844
+ return moved;
845
+ }
846
+
847
+ /** Move the draft cursor by `delta` lines, skipping blank ones, and keep it on screen. */
848
+ private moveCursor(delta: number): void {
849
+ const lines = this.planLayout.draftText;
850
+ if (lines.length === 0) return;
851
+ const step = Math.sign(delta) || 1;
852
+ let target = Math.max(0, Math.min(lines.length - 1, this.planCursor + delta));
853
+ while (target > 0 && target < lines.length - 1 && !lines[target]!.trim()) target += step;
854
+ if (!lines[target]?.trim()) {
855
+ // Ran into a blank edge: look back the other way for the nearest line with text.
856
+ let back = target;
857
+ while (back >= 0 && back < lines.length && !lines[back]!.trim()) back -= step;
858
+ if (back >= 0 && back < lines.length) target = back;
859
+ }
860
+ this.planCursor = target;
861
+ this.keepCursorVisible();
862
+ }
863
+
864
+ private keepCursorVisible(): void {
865
+ const rows = Math.max(1, this.planLayout.draftRows);
866
+ const start = Math.min(this.planDraftOffset, Math.max(0, this.planLayout.draftText.length - rows));
867
+ if (this.planCursor < start) this.planDraftOffset = this.planCursor;
868
+ else if (this.planCursor >= start + rows) this.planDraftOffset = this.planCursor - rows + 1;
869
+ else this.planDraftOffset = start;
870
+ }
871
+
872
+ /** Put the draft cursor on the first line with text on screen. */
873
+ private cursorToView(): void {
874
+ const lines = this.planLayout.draftText;
875
+ const start = this.planLayout.draftStart;
876
+ const end = Math.min(lines.length, start + Math.max(1, this.planLayout.draftRows));
877
+ if (this.planCursor >= start && this.planCursor < end && lines[this.planCursor]?.trim()) return;
878
+ for (let index = start; index < end; index++) {
879
+ if (lines[index]?.trim()) {
880
+ this.planCursor = index;
381
881
  return;
882
+ }
382
883
  }
884
+ this.planCursor = start;
383
885
  }
384
886
 
385
887
  private selectTask(index: number): void {
386
- const rows = this.taskRowList(this.data.tasks);
888
+ const rows = this.taskRowList();
387
889
  this.tasksSelected = Math.max(0, Math.min(rows.length - 1, index));
388
890
  this.tasksDetailOffset = 0;
389
891
  this.refreshData(true);
@@ -399,19 +901,26 @@ export class LobbyView implements Component, Focusable {
399
901
  const enter = matchesKey(data, Key.enter);
400
902
  const escape = matchesKey(data, Key.escape);
401
903
  switch (this.tab) {
402
- case "lobby":
403
- if (escape && this.host.masterBusy()) {
904
+ case "lobby": {
905
+ const background = this.viewedSession();
906
+ if (escape && background?.busy) {
907
+ background.abort();
908
+ this.say(`stopping ${background.name}'s oracle…`);
909
+ return true;
910
+ }
911
+ if (escape && this.viewing.kind === "here" && this.host.masterBusy()) {
404
912
  this.host.abortMaster();
405
913
  this.say("stopping the oracle…");
406
914
  return true;
407
915
  }
408
916
  if (data === "c") {
409
- const task = this.host.zen().task;
410
- if (task) this.commentOn(task.id);
917
+ const task = this.viewedEntry().task;
918
+ if (task && !TERMINAL_STATES.includes(task.state)) this.commentOn(task.id);
411
919
  else this.say("no task in this session to comment on", "warning");
412
920
  return true;
413
921
  }
414
922
  return enter ? (this.setMode("type"), true) : false;
923
+ }
415
924
  case "tasks":
416
925
  return this.tasksCommand(data, enter, escape);
417
926
  case "plan":
@@ -420,10 +929,11 @@ export class LobbyView implements Component, Focusable {
420
929
  if (enter) return (this.fixFocus = this.fixFocus === "list" ? "detail" : "list"), true;
421
930
  if (escape && this.fixFocus === "detail") return (this.fixFocus = "list"), true;
422
931
  if (data === "x") {
423
- const job = newestFirst(this.host.quickfix.jobs)[this.fixSelected];
932
+ const job = this.fixJobs()[this.fixSelected];
424
933
  if (job && this.host.quickfix.cancel(job.id)) this.say(`cancelling ${job.id}`);
425
934
  return true;
426
935
  }
936
+ if (data === "m") return this.openSettings("quickfix"), true;
427
937
  return false;
428
938
  case "issues":
429
939
  return this.issuesCommand(data, enter, escape);
@@ -436,10 +946,11 @@ export class LobbyView implements Component, Focusable {
436
946
  }
437
947
 
438
948
  private tasksCommand(data: string, enter: boolean, escape: boolean): boolean {
439
- const row = this.taskRowList(this.data.tasks)[this.tasksSelected];
949
+ const row = this.taskRowList()[this.tasksSelected];
440
950
  if (enter) return (this.tasksFocus = this.tasksFocus === "list" ? "detail" : "list"), true;
441
951
  if (escape) return (this.tasksFocus = "list"), true;
442
952
  if (data === "r") return this.refreshData(true), true;
953
+ if (data === "n") return this.startNewSessionPrompt(), true;
443
954
  if (!row) return false;
444
955
  if (data === "c") {
445
956
  const task = this.data.tasks.find((entry) => entry.id === row.id);
@@ -452,11 +963,39 @@ export class LobbyView implements Component, Focusable {
452
963
  return true;
453
964
  }
454
965
  const plan = row.kind === "plan" ? this.data.plans.find((entry) => entry.id === row.id) : undefined;
966
+ // A planned task starts in a new session of its own (s), or in this window (h).
455
967
  if (data === "s" && plan) {
968
+ this.startInNewSession({ plan });
969
+ return true;
970
+ }
971
+ if (data === "h" && plan) {
456
972
  this.say(this.host.startPlanned(plan));
457
973
  this.refreshData(true);
458
974
  return true;
459
975
  }
976
+ const task = row.kind === "task" ? this.data.tasks.find((entry) => entry.id === row.id) : undefined;
977
+ if (data === "o" && task) {
978
+ const target = this.sessionEntries().find((entry) => entry.task?.id === task.id);
979
+ if (!target) this.say(`${task.id} is not running in any session now`, "warning");
980
+ else {
981
+ this.view(target.view);
982
+ this.say(target.view.kind === "here" ? "this window's task" : `showing ${target.name}`);
983
+ }
984
+ return true;
985
+ }
986
+ if (data === "x" && task) {
987
+ const session = this.host.sessions().find((entry) => entry.alive && entry.sessionId === task.ownerSessionId);
988
+ if (!session) return this.say("only a background session this window started can be stopped from here", "warning"), true;
989
+ if (this.armed !== `stop:${session.key}`) {
990
+ this.armed = `stop:${session.key}`;
991
+ this.say(`press x again to stop ${session.name} (its task keeps its state)`, "warning");
992
+ return true;
993
+ }
994
+ this.armed = undefined;
995
+ session.stop();
996
+ this.say(`stopping ${session.name}`);
997
+ return true;
998
+ }
460
999
  if (data === "d" && plan) {
461
1000
  if (this.armed !== `discard:${plan.id}`) {
462
1001
  this.armed = `discard:${plan.id}`;
@@ -483,11 +1022,229 @@ export class LobbyView implements Component, Focusable {
483
1022
  private resetPlanScroll(): void {
484
1023
  this.planOffset = 0;
485
1024
  this.planDraftOffset = 0;
1025
+ this.planCursor = 0;
1026
+ }
1027
+
1028
+ /** The Lobby prompt: a new task in its own session, or a message to the session in view. */
1029
+ private submitLobby(body: string): void {
1030
+ this.homeOffsets.conversation = 0;
1031
+ if (this.newSession) {
1032
+ this.newSession = false;
1033
+ return this.startInNewSession({ request: body });
1034
+ }
1035
+ const view = this.viewing;
1036
+ if (view.kind === "background") {
1037
+ const session = this.viewedSession();
1038
+ if (!session?.alive) {
1039
+ this.editor.setText(body);
1040
+ return this.say(`${session?.name ?? "that session"} has ended — alt+n starts a new one`, "warning");
1041
+ }
1042
+ session.send(body);
1043
+ return;
1044
+ }
1045
+ if (view.kind === "other") return this.say(this.host.sendToTask(view.taskId, body));
1046
+ const notice = this.host.toOracle(body);
1047
+ if (notice) this.say(notice);
1048
+ }
1049
+
1050
+ /** Put the question the background session in view waits on to the user. */
1051
+ private answerSessionDialog(): void {
1052
+ const session = this.viewedSession();
1053
+ if (!session?.dialogs.length) return;
1054
+ void this.host.answerDialog(session).then(() => this.host.requestRender());
1055
+ }
1056
+
1057
+ /* ------------------------------------------------------------ sessions */
1058
+
1059
+ /** Every session the lobby can show: this window, the background sessions it started, then tasks other terminals drive. */
1060
+ sessionEntries(): SessionEntry[] {
1061
+ const me = this.host.sessionId();
1062
+ const zen = this.host.zen();
1063
+ const background = this.host.sessions();
1064
+ const started = new Set(background.map((session) => session.sessionId).filter((id): id is string => Boolean(id)));
1065
+ const auto = (task: Task | undefined) => Boolean(task && !TERMINAL_STATES.includes(task.state) && this.host.isAuto(task.id));
1066
+ const entries: SessionEntry[] = [{
1067
+ view: { kind: "here" },
1068
+ name: this.host.sessionName() ?? zen.task?.title ?? "unnamed session",
1069
+ where: "this window",
1070
+ ...(zen.task ? { task: zen.task } : {}),
1071
+ status: this.host.masterBusy() ? "working" : zen.task?.state ?? "no task",
1072
+ auto: auto(zen.task),
1073
+ waiting: 0,
1074
+ }];
1075
+ for (const session of background) {
1076
+ const task = session.sessionId ? this.data.tasks.find((entry) => entry.ownerSessionId === session.sessionId) : undefined;
1077
+ const status = session.status === "exited" ? "ended" : session.status === "starting" ? "starting" : session.busy ? "working" : task?.state ?? "idle";
1078
+ entries.push({ view: { kind: "background", key: session.key }, name: session.name, where: "background", ...(task ? { task } : {}), status, auto: auto(task), waiting: session.dialogs.length });
1079
+ }
1080
+ for (const task of this.data.tasks) {
1081
+ if (TERMINAL_STATES.includes(task.state) || !task.ownerSessionId || task.ownerSessionId === me || started.has(task.ownerSessionId)) continue;
1082
+ entries.push({ view: { kind: "other", taskId: task.id }, name: task.title, where: "other terminal", task, status: task.state, auto: auto(task), waiting: 0 });
1083
+ }
1084
+ return entries;
1085
+ }
1086
+
1087
+ /** The entry the Lobby tab shows, falling back to this window when that session is gone. */
1088
+ viewedEntry(): SessionEntry {
1089
+ const entries = this.sessionEntries();
1090
+ const view = this.viewing;
1091
+ const found = view.kind === "here" ? undefined : entries.find((entry) => (entry.view.kind === "background" && view.kind === "background" && entry.view.key === view.key) || (entry.view.kind === "other" && view.kind === "other" && entry.view.taskId === view.taskId));
1092
+ if (!found && view.kind !== "here") this.viewing = { kind: "here" };
1093
+ return found ?? entries[0]!;
1094
+ }
1095
+
1096
+ private viewedSession(): BackgroundSession | undefined {
1097
+ const view = this.viewing;
1098
+ return view.kind === "background" ? this.host.sessions().find((session) => session.key === view.key) : undefined;
1099
+ }
1100
+
1101
+ /** Show a session in the Lobby tab, and talk to it from there. */
1102
+ view(target: SessionView): void {
1103
+ this.viewing = target;
1104
+ this.picking = false;
1105
+ this.newSession = false;
1106
+ for (const pane of HOME_PANES) this.homeOffsets[pane] = 0;
1107
+ if (this.tab !== "lobby") this.setTab("lobby");
1108
+ this.setMode("type");
1109
+ }
1110
+
1111
+ private openPicker(): void {
1112
+ this.help = false;
1113
+ this.picking = true;
1114
+ const entries = this.sessionEntries();
1115
+ const current = this.viewedEntry();
1116
+ this.pickIndex = Math.max(0, entries.findIndex((entry) => entry.name === current.name && entry.where === current.where));
1117
+ }
1118
+
1119
+ /** Keys while the session switcher is open; true when the key was handled. */
1120
+ private pickerKey(data: string, action: LobbyAction | undefined): boolean {
1121
+ const entries = this.sessionEntries();
1122
+ this.pickIndex = Math.min(this.pickIndex, Math.max(0, entries.length - 1));
1123
+ if (matchesKey(data, Key.escape) || action === "sessions") {
1124
+ this.picking = false;
1125
+ return true;
1126
+ }
1127
+ if (matchesKey(data, Key.up) || data === "k") return (this.pickIndex = Math.max(0, this.pickIndex - 1)), true;
1128
+ if (matchesKey(data, Key.down) || data === "j") return (this.pickIndex = Math.min(entries.length - 1, this.pickIndex + 1)), true;
1129
+ const entry = entries[this.pickIndex];
1130
+ if (matchesKey(data, Key.enter) && entry) {
1131
+ this.view(entry.view);
1132
+ this.say(entry.view.kind === "here" ? "back to this window" : `showing ${entry.name} — you talk to its oracle from here`);
1133
+ return true;
1134
+ }
1135
+ if (data === "n" || action === "newSession") {
1136
+ this.picking = false;
1137
+ this.startNewSessionPrompt();
1138
+ return true;
1139
+ }
1140
+ if (data === "x" && entry?.view.kind === "background") {
1141
+ const key = entry.view.key;
1142
+ if (this.armed !== `stop:${key}`) {
1143
+ this.armed = `stop:${key}`;
1144
+ this.say(`press x again to stop ${entry.name} (its task keeps its state)`, "warning");
1145
+ return true;
1146
+ }
1147
+ this.armed = undefined;
1148
+ this.host.sessions().find((session) => session.key === key)?.stop();
1149
+ this.say(`stopping ${entry.name}`);
1150
+ return true;
1151
+ }
1152
+ return true;
1153
+ }
1154
+
1155
+ /** Start typing a task that begins in its own new session. */
1156
+ startNewSessionPrompt(): void {
1157
+ this.picking = false;
1158
+ this.help = false;
1159
+ if (this.tab !== "lobby") this.setTab("lobby");
1160
+ this.newSession = true;
1161
+ this.setMode("type");
1162
+ }
1163
+
1164
+ /** Start a task in a new background session and show it. */
1165
+ startInNewSession(start: { request?: string; plan?: PlannedTask }): void {
1166
+ const session = this.host.startSession(start);
1167
+ if (typeof session === "string") return this.say(session, "warning");
1168
+ this.view({ kind: "background", key: session.key });
1169
+ this.say(`started ${session.name} in a new session — alt+o switches back`);
1170
+ this.refreshData(true);
1171
+ }
1172
+
1173
+ /** The task auto mode applies to: the selected row on the Tasks tab, the viewed session's task elsewhere. */
1174
+ private autoTarget(): Task | undefined {
1175
+ if (this.tab === "tasks") {
1176
+ const row = this.taskRowList()[this.tasksSelected];
1177
+ return row?.kind === "task" ? this.data.tasks.find((task) => task.id === row.id) : undefined;
1178
+ }
1179
+ return this.viewedEntry().task;
1180
+ }
1181
+
1182
+ /** Switch auto mode for the task in view. */
1183
+ toggleAuto(): void {
1184
+ const task = this.autoTarget();
1185
+ if (!task || TERMINAL_STATES.includes(task.state)) return this.say("no active task here — auto mode applies to a task", "warning");
1186
+ const on = !this.host.isAuto(task.id);
1187
+ this.host.setAuto(task.id, on);
1188
+ this.say(on ? `auto mode on — the oracle drives ${task.id} to completion without asking` : `auto mode off — the oracle asks you again on ${task.id}`);
1189
+ }
1190
+
1191
+ /** Save the planning session's draft to the pending tasks, from any tab and in either mode. */
1192
+ savePlan(): void {
1193
+ const session = this.host.planner();
1194
+ if (!session?.reply?.plan) return this.say(session?.busy ? "the first draft is still being written" : "no plan to save yet — describe a task in the Plan tab", "warning");
1195
+ try {
1196
+ const saved = session.save();
1197
+ this.say(`saved ${saved.id} to the pending tasks — start it from the Tasks tab${session.reply.status === "ready" ? "" : " (the panel had not agreed yet)"}`);
1198
+ } catch (error) {
1199
+ this.say((error as Error).message, "warning");
1200
+ }
1201
+ this.refreshData(true);
1202
+ }
1203
+
1204
+ /** The oracle puts the round's questions to the user through the questionnaire. */
1205
+ answerQuestions(): void {
1206
+ const session = this.host.planner();
1207
+ if (!session?.awaitingAnswers) return this.say(session?.busy ? "the panel is still thinking" : "no open questions", "warning");
1208
+ void this.host.answerPanel().then((notice) => {
1209
+ this.say(notice);
1210
+ this.host.requestRender();
1211
+ });
1212
+ }
1213
+
1214
+ /** Start a comment on the draft line under the cursor. */
1215
+ private commentOnCursor(): void {
1216
+ const line = this.planLayout.draftText[this.planCursor];
1217
+ if (!this.host.planner()?.reply?.plan) return this.say("there is no draft to comment on yet", "warning");
1218
+ if (!line?.trim()) return this.say("that line is blank — ↑↓ picks another", "warning");
1219
+ this.lineTarget = line.trim();
1220
+ this.setMode("type");
486
1221
  }
487
1222
 
488
1223
  private planCommand(data: string, enter: boolean, escape: boolean): boolean {
489
1224
  const session = this.host.planner();
490
- if (enter) return (this.planFocus = this.planFocus === "talk" ? "draft" : "talk"), true;
1225
+ if (enter) {
1226
+ this.planFocus = this.planFocus === "talk" ? "draft" : "talk";
1227
+ if (this.planFocus === "draft") this.cursorToView();
1228
+ return true;
1229
+ }
1230
+ if (data === "a") {
1231
+ this.answerQuestions();
1232
+ return true;
1233
+ }
1234
+ if (data === "m") {
1235
+ this.openSettings("planner");
1236
+ return true;
1237
+ }
1238
+ if (data === "c") {
1239
+ if (this.planFocus !== "draft") {
1240
+ this.planFocus = "draft";
1241
+ this.cursorToView();
1242
+ this.say("↑↓ picks a draft line, c comments on it (or click the line)");
1243
+ return true;
1244
+ }
1245
+ this.commentOnCursor();
1246
+ return true;
1247
+ }
491
1248
  const seat = /^[1-4]$/.test(data) ? PANEL_MEMBERS[Number(data) - 1] : undefined;
492
1249
  if (seat) {
493
1250
  let seated: boolean;
@@ -507,21 +1264,15 @@ export class LobbyView implements Component, Focusable {
507
1264
  }
508
1265
  return true;
509
1266
  }
1267
+ if (escape && this.planFocus === "draft") {
1268
+ this.planFocus = "talk";
1269
+ return true;
1270
+ }
510
1271
  if (data === "r") {
511
1272
  if (session?.retryable) void session.retry();
512
1273
  else this.say("nothing to retry", "warning");
513
1274
  return true;
514
1275
  }
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
1276
  if (data === "n") {
526
1277
  const unsaved = session && session.messages.length > 0 && !session.saved;
527
1278
  if (unsaved && this.armed !== "new-plan") {
@@ -564,19 +1315,91 @@ export class LobbyView implements Component, Focusable {
564
1315
  return false;
565
1316
  }
566
1317
 
1318
+ /* --------------------------------------------------------------- mouse */
1319
+
1320
+ /** Mouse events when pi runs full screen (pi-tui dispatches them to the overlay). */
1321
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
1322
+ if (event.type === "wheel") this.wheel(Math.sign(event.wheelDelta ?? 0) || 1, event.x, event.y);
1323
+ else if (event.type === "click" && event.button === "left") this.click(event.x, event.y);
1324
+ else return undefined;
1325
+ this.host.requestRender();
1326
+ return { handled: true };
1327
+ }
1328
+
1329
+ /** Mouse reports in pi's regular screen, where the lobby turns reporting on itself. */
1330
+ private mouseReport(report: MouseReport): void {
1331
+ if (report.kind === "wheel") this.wheel(report.delta, report.x, report.y);
1332
+ else if (report.kind === "press" && report.button === 0) this.click(report.x, report.y);
1333
+ }
1334
+
1335
+ /** The pane of the current tab under a body cell, if any. */
1336
+ private paneAt(x: number, y: number): string | undefined {
1337
+ const row = y - this.bodyTop;
1338
+ for (const [name, pane] of this.panes) {
1339
+ if (row >= pane.top && row < pane.top + pane.height && x >= pane.left && x < pane.left + pane.width) return name;
1340
+ }
1341
+ return undefined;
1342
+ }
1343
+
1344
+ /** The wheel scrolls the pane under the pointer (the focused one when it is over none). */
1345
+ private wheel(direction: number, x = -1, y = -1): void {
1346
+ if (this.help) return;
1347
+ if (this.tab === "issues") return this.scroll(direction * WHEEL_LINES);
1348
+ this.scrollPane(this.paneAt(x, y) ?? this.focusedPane(), direction * WHEEL_LINES, true);
1349
+ }
1350
+
1351
+ /** A click: a tab in the tab bar, the prompt, or a line of the draft plan to comment on. */
1352
+ click(x: number, y: number): void {
1353
+ this.help = false;
1354
+ if (y === 0) {
1355
+ const span = this.tabSpans.find((entry) => x >= entry.from && x < entry.to);
1356
+ if (span) this.setTab(span.tab);
1357
+ return;
1358
+ }
1359
+ if (y >= this.promptTop) {
1360
+ if (this.searching) this.closeSearch();
1361
+ if (this.tab !== "metrics" && this.tab !== "tasks") this.setMode("type");
1362
+ return;
1363
+ }
1364
+ const row = y - this.bodyTop;
1365
+ // A click on a pane gives it the keys.
1366
+ const pane = this.paneAt(x, y);
1367
+ if (this.tab === "lobby" && pane) this.homeFocus = pane as HomePane;
1368
+ if (this.tab === "tasks" && (pane === "list" || pane === "detail")) this.tasksFocus = pane;
1369
+ if (this.tab === "quickfix" && (pane === "list" || pane === "detail")) this.fixFocus = pane;
1370
+ if (this.tab !== "plan" || row < 0 || !this.host.planner()) return;
1371
+ const layout = this.planLayout;
1372
+ // The draft pane: its border sits one row above its first line and two columns left of its text.
1373
+ const inDraftColumns = x >= layout.draftLeft - 2 && x < layout.draftLeft + layout.draftWidth + 2;
1374
+ const line = row - layout.draftTop;
1375
+ if (inDraftColumns && line >= -1 && line <= layout.draftRows) {
1376
+ this.planFocus = "draft";
1377
+ const index = layout.draftStart + line;
1378
+ if (line < 0 || line >= layout.draftRows || index >= layout.draftText.length) return;
1379
+ this.planCursor = index;
1380
+ if (this.searching) this.closeSearch();
1381
+ this.commentOnCursor();
1382
+ return;
1383
+ }
1384
+ // The conversation sits left of the draft on wide terminals, above it on narrow ones.
1385
+ const inTalk = layout.draftLeft > 2 ? x < layout.draftLeft - 2 : line < -1;
1386
+ if (inTalk) this.planFocus = "talk";
1387
+ }
1388
+
567
1389
  /* -------------------------------------------------------------- submit */
568
1390
 
569
1391
  private submit(text: string): void {
570
1392
  const body = text.trim();
571
- if (!body) return;
1393
+ if (!body) {
1394
+ // An empty enter on the Plan tab opens the panel's questions; on the Lobby, a background session's question.
1395
+ if (this.tab === "plan" && !this.lineTarget && this.host.planner()?.awaitingAnswers) this.answerQuestions();
1396
+ if (this.tab === "lobby" && !this.newSession) this.answerSessionDialog();
1397
+ return;
1398
+ }
572
1399
  this.editor.addToHistory(body);
573
1400
  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
- }
1401
+ case "lobby":
1402
+ return this.submitLobby(body);
580
1403
  case "tasks":
581
1404
  if (!this.commentTarget) {
582
1405
  this.editor.setText(body);
@@ -587,20 +1410,13 @@ export class LobbyView implements Component, Focusable {
587
1410
  this.setMode("browse");
588
1411
  this.refreshData(true);
589
1412
  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
- }
1413
+ case "plan":
1414
+ return this.submitPlan(body);
600
1415
  case "quickfix": {
601
1416
  const job = this.host.quickfix.submit(body);
602
1417
  this.fixSelected = 0;
603
1418
  this.fixDetailOffset = 0;
1419
+ delete this.queries.quickfix;
604
1420
  this.say(this.host.quickfix.running?.id === job.id ? `${job.id} started` : `${job.id} queued behind the running quick fix`);
605
1421
  return;
606
1422
  }
@@ -619,10 +1435,44 @@ export class LobbyView implements Component, Focusable {
619
1435
  }
620
1436
  }
621
1437
 
1438
+ private submitPlan(body: string): void {
1439
+ if (this.lineTarget) {
1440
+ const line = this.lineTarget;
1441
+ this.lineTarget = undefined;
1442
+ const session = this.host.planner();
1443
+ if (!session) return this.say("there is no draft to comment on", "warning");
1444
+ const sent = session.commentOnLine(line, body);
1445
+ this.planFocus = "draft";
1446
+ this.setMode("browse");
1447
+ if (sent) this.say("comment sent — the panel revises the draft");
1448
+ else if (session.busy) this.say("comment kept — it goes to the panel after this round");
1449
+ else this.say("comment kept — it goes with your answers (a answers the questions)");
1450
+ return;
1451
+ }
1452
+ const session = this.host.planner() ?? this.host.newPlanner(undefined, [...this.seatSet()]);
1453
+ if (session.busy) {
1454
+ this.editor.setText(body);
1455
+ return this.say("the panel is still thinking — x stops it", "warning");
1456
+ }
1457
+ this.planOffset = 0;
1458
+ this.planDraftOffset = 0;
1459
+ session.send(body).catch((error: Error) => this.say(error.message, "warning"));
1460
+ }
1461
+
622
1462
  /* -------------------------------------------------------------- render */
623
1463
 
624
- private taskRowList(tasks: readonly Task[]): TaskRow[] {
625
- return taskRows(tasks, this.data.plans, this.host.sessionId(), this.now());
1464
+ /** Task rows, narrowed by the Tasks tab's search. */
1465
+ private taskRowList(): TaskRow[] {
1466
+ const names = new Map<string, string>();
1467
+ for (const session of this.host.sessions()) if (session.sessionId && session.alive) names.set(session.sessionId, session.name);
1468
+ const auto = new Set(this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && this.host.isAuto(task.id)).map((task) => task.id));
1469
+ const rows = taskRows(this.data.tasks, this.data.plans, this.host.sessionId(), this.now(), { names, auto });
1470
+ return filterRows(rows, this.data.tasks, this.data.plans, this.queries.tasks);
1471
+ }
1472
+
1473
+ /** Quick fix jobs as listed: newest first, narrowed by the search. */
1474
+ private fixJobs() {
1475
+ return newestFirst(filterJobs(this.host.quickfix.jobs, this.queries.quickfix));
626
1476
  }
627
1477
 
628
1478
  private tabBar(width: number, theme: LobbyTheme): string {
@@ -630,24 +1480,46 @@ export class LobbyView implements Component, Focusable {
630
1480
  const badges: Partial<Record<TabId, string>> = {};
631
1481
  const active = this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state)).length;
632
1482
  if (active > 0) badges.tasks = String(active);
633
- if (this.host.planner()?.busy) badges.plan = spinner(this.tick);
1483
+ const session = this.host.planner();
1484
+ if (session?.busy) badges.plan = spinner(this.tick);
1485
+ else if (session?.awaitingAnswers) badges.plan = `${session.questions.length}?`;
634
1486
  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
- });
1487
+ if (this.issuesOn && this.host.issues.issues.length > 0) badges.issues = String(this.host.issues.issues.length);
642
1488
  const brand = bold(theme, paint(theme, "accent", " ◆ bot-lobby "));
643
- const left = `${brand}${paint(theme, "borderMuted", "│")}${tabs.join("")}`;
1489
+ let left = `${brand}${paint(theme, "borderMuted", "│")}`;
1490
+ const spans: Array<{ tab: TabId; from: number; to: number }> = [];
1491
+ for (const [index, tab] of this.tabs().entries()) {
1492
+ const number = paint(theme, tab === this.tab ? "accent" : "dim", String(index + 1));
1493
+ const name = tab === this.tab ? bold(theme, paint(theme, "text", TAB_LABELS[tab])) : paint(theme, "muted", TAB_LABELS[tab]);
1494
+ const badge = badges[tab] ? ` ${paint(theme, tab === "plan" && session?.awaitingAnswers ? "warning" : "accent", badges[tab]!)}` : "";
1495
+ let cell = ` ${number} ${name}${badge} `;
1496
+ if (tab === this.tab) cell = theme.bg ? theme.bg("selectedBg", cell) : `[${cell}]`;
1497
+ const from = visibleWidth(left);
1498
+ left += cell;
1499
+ spans.push({ tab, from, to: visibleWidth(left) });
1500
+ }
1501
+ this.tabSpans = spans;
644
1502
  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 "), ""];
1503
+ const entry = this.viewedEntry();
1504
+ const help = paint(theme, "dim", `${keyLabel(this.keys.help)} keys `);
1505
+ const auto = entry.auto ? ` ${bold(theme, paint(theme, "success", "⟳ AUTO"))}` : "";
1506
+ const waiting = this.host.sessions().reduce((count, session) => count + session.dialogs.length, 0);
1507
+ const asking = waiting > 0 ? ` ${paint(theme, "warning", `● ${waiting} waiting · ${keyLabel(this.keys.sessions)}`)}` : "";
1508
+ let choices: string[];
1509
+ if (entry.view.kind !== "here") {
1510
+ // Another session in view: its name and what it is doing lead, so it is never mistaken for this window.
1511
+ const working = entry.status === "working";
1512
+ const dot = paint(theme, working ? "accent" : "dim", working ? spinner(this.tick) : "◆");
1513
+ const where = paint(theme, "muted", `${entry.name} · ${entry.status}`);
1514
+ choices = [`${dot} ${where}${auto}${asking} ${help}`, `${dot} ${where}${auto}${asking} `, `${dot} ${where}${auto} `, `${dot}${auto} `];
1515
+ } else {
1516
+ const dot = paint(theme, this.host.masterBusy() ? "accent" : "dim", this.host.masterBusy() ? spinner(this.tick) : "●");
1517
+ const state = zen.task ? paint(theme, "muted", zen.task.paused ? `${zen.task.state} (paused)` : zen.task.state) : "";
1518
+ // Long task ids give way to the state, then to the dot alone.
1519
+ choices = zen.task
1520
+ ? [`${dot} ${zen.task.id} ${state}${auto}${asking} ${help}`, `${dot} ${zen.task.id} ${state}${auto}${asking} `, `${dot} ${state}${auto} `, `${dot}${auto} `]
1521
+ : [`${paint(theme, "dim", "no task in this session")}${asking} ${help}`, `${paint(theme, "dim", "no task in this session")}${asking} `, help, ""];
1522
+ }
651
1523
  const status = choices.find((choice) => visibleWidth(choice) <= room) ?? "";
652
1524
  const gap = width - visibleWidth(left) - visibleWidth(status);
653
1525
  return gap >= 1 ? `${left}${" ".repeat(gap)}${status}` : fit(left, width);
@@ -657,18 +1529,33 @@ export class LobbyView implements Component, Focusable {
657
1529
  const zen = this.host.zen();
658
1530
  const browsing = this.mode === "browse";
659
1531
  switch (this.tab) {
660
- case "lobby":
1532
+ case "lobby": {
1533
+ if (this.newSession) return "new task — it starts in its own session, named after it";
1534
+ if (this.viewing.kind !== "here") {
1535
+ const entry = this.viewedEntry();
1536
+ const session = this.viewedSession();
1537
+ if (session && !session.alive) return `${entry.name} has ended`;
1538
+ if (session?.dialogs.length) return `press enter to answer ${entry.name}'s question, or type a message`;
1539
+ return entry.where === "other terminal" ? `message ${entry.name}'s oracle (its own session delivers it)` : `message ${entry.name}'s oracle`;
1540
+ }
661
1541
  if (!zen.task) return browsing ? "describe a task to start — i to type" : "describe a task to start · enter starts it";
662
1542
  return browsing ? "message the oracle — i to type" : this.host.masterBusy() ? "message the oracle · enter steers the running turn" : "message the oracle";
1543
+ }
663
1544
  case "tasks":
664
1545
  return this.commentTarget ? `comment on ${this.commentTarget}'s plan · enter sends it to the oracle` : "c comments on the selected task's plan";
665
1546
  case "plan": {
1547
+ if (this.lineTarget) return `comment on “${clip(this.lineTarget, 48)}”`;
666
1548
  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";
1549
+ if (session?.busy) return "the panel is thinking";
1550
+ if (session?.awaitingAnswers) {
1551
+ const count = session.questions.length;
1552
+ return `press enter to answer ${count} question${count === 1 ? "" : "s"}, or type a reply`;
1553
+ }
1554
+ if (session?.reply?.status === "ready") return `the plan is ready — ${keyLabel(this.keys.savePlan)} saves it, or reply to refine it`;
1555
+ return session && session.messages.length > 0 ? "reply to the panel" : "describe the task to plan";
669
1556
  }
670
1557
  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";
1558
+ return this.host.quickfix.running ? "describe a quick change; it runs after the current one" : "describe a quick change";
672
1559
  case "issues":
673
1560
  return this.issueDraft ? "new issue · first line is the title" : "n files a new issue";
674
1561
  case "metrics":
@@ -678,63 +1565,239 @@ export class LobbyView implements Component, Focusable {
678
1565
 
679
1566
  private promptLines(width: number, theme: LobbyTheme): string[] {
680
1567
  if (this.tab === "metrics") return [];
1568
+ const typing = this.mode === "type" && !this.searching;
681
1569
  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);
1570
+ this.editor.paintLabel = (text) => paint(theme, typing ? (this.lineTarget ? "warning" : "accent") : "dim", text);
1571
+ this.editor.borderColor = (text) => paint(theme, typing ? "border" : "borderMuted", text);
684
1572
  const lines = this.editor.render(width);
685
1573
  // 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"));
1574
+ return typing ? lines : lines.map((line) => line.replace(REVERSE_VIDEO, "$1"));
687
1575
  }
688
1576
 
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";
1577
+ /** The search bar: the query being typed, or the one in force. */
1578
+ private searchLines(width: number, theme: LobbyTheme): string[] {
1579
+ const query = this.query();
1580
+ if (!this.searching && !query) return [];
1581
+ const lead = `${paint(theme, "accent", " / ")}`;
1582
+ const tail = this.searching
1583
+ ? paint(theme, "dim", " enter keep · esc clear · ↑↓ browse results ")
1584
+ : paint(theme, "dim", ` ${keyLabel(this.keys.search)} or / edits · esc clears `);
1585
+ const room = Math.max(4, width - visibleWidth(lead) - visibleWidth(tail));
1586
+ const field = this.searching ? this.search.render(room)[0] ?? "" : bold(theme, fit(query ?? "", room));
1587
+ return [fit(`${lead}${fit(field, room)}${tail}`, width)];
1588
+ }
1589
+
1590
+ /** Every key the current tab understands while browsing. */
1591
+ private tabKeys(): KeyHelp[] {
695
1592
  switch (this.tab) {
696
1593
  case "lobby":
697
- return `type to talk · ↑↓ scroll · c comment on the plan · ${this.host.masterBusy() ? "esc stop the oracle · " : ""}${common}`;
1594
+ return [
1595
+ { key: "type", text: "talk to the oracle of the session in view (in this window, starts a task when none is running)" },
1596
+ { key: "enter", text: "on an empty prompt: answer the question a background session waits on" },
1597
+ { key: "↑ ↓", text: "scroll the focused pane (PageUp/PageDown a page)" },
1598
+ { key: "← →", text: "move between the conversation, activity log and thinking" },
1599
+ { key: "Home End", text: "the oldest lines, or back to the newest" },
1600
+ { key: "c", text: "comment on this task's plan" },
1601
+ { key: "esc", text: "stop the oracle while it works" },
1602
+ ];
698
1603
  case "tasks":
699
- return `↑↓ select · enter detail · c comment · s start plan · d discard plan · r refresh · ${common}`;
1604
+ return [
1605
+ { key: "↑ ↓", text: "select a task or plan, or scroll the detail (PageUp/PageDown)" },
1606
+ { key: "enter / ← →", text: "move between the list and the detail" },
1607
+ { key: "c", text: "comment on the selected task's plan" },
1608
+ { key: "s", text: "start the selected planned task in a new session, named after it" },
1609
+ { key: "h", text: "start the selected planned task here, in this window" },
1610
+ { key: "n", text: "type a new task that starts in its own session" },
1611
+ { key: "o", text: "show the session driving the selected task in the Lobby tab" },
1612
+ { key: "x x", text: "stop the background session driving the selected task" },
1613
+ { key: "d d", text: "discard the selected planned task" },
1614
+ { key: "r", text: "reread tasks from disk" },
1615
+ ];
700
1616
  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}`;
1617
+ return [
1618
+ { key: "a", text: "answer the panel's questions, one questionnaire at a time (enter on an empty prompt too)" },
1619
+ { key: "enter / ← →", text: "move between the conversation and the draft" },
1620
+ { key: "↑ ↓", text: "pick a draft line (draft) or scroll (conversation); PageUp/PageDown a page" },
1621
+ { key: "c / click", text: "comment on the picked draft line" },
1622
+ { key: "1-4", text: "seat or unseat DEV, DESIGN, QA, RESEARCH" },
1623
+ { key: "n", text: "start a new plan" },
1624
+ { key: "x", text: "stop the round" },
1625
+ { key: "r", text: "retry a failed round or seat" },
1626
+ { key: "m", text: "the oracle's model, thinking and time limit (Planner settings)" },
1627
+ ];
702
1628
  case "quickfix":
703
- return `type a change · ↑↓ select · enter detail · x cancel · ${common}`;
1629
+ return [
1630
+ { key: "type", text: "describe a quick change" },
1631
+ { key: "↑ ↓", text: "select a quick fix, or scroll the detail (PageUp/PageDown)" },
1632
+ { key: "enter / ← →", text: "move between the list and the detail" },
1633
+ { key: "x", text: "cancel the selected quick fix" },
1634
+ { key: "m", text: "the quick fix agent's model, thinking, time limit and instructions" },
1635
+ ];
704
1636
  case "issues":
705
- return `↑↓ select · enter read · p plan it · n new issue · r refresh · ${common}`;
1637
+ return [
1638
+ { key: "↑ ↓", text: "select an issue" },
1639
+ { key: "enter", text: "read the selected issue" },
1640
+ { key: "p", text: "plan the selected issue" },
1641
+ { key: "n", text: "file a new issue" },
1642
+ { key: "r", text: "reload issues" },
1643
+ ];
706
1644
  case "metrics":
707
- return `↑↓ select · g group by agent · s sort · r refresh · ${common}`;
1645
+ return [
1646
+ { key: "↑ ↓", text: "select a row of the table" },
1647
+ { key: "g", text: "group by model, or by model and agent" },
1648
+ { key: "s", text: "change the sort" },
1649
+ { key: "r", text: "reread the metrics log" },
1650
+ ];
708
1651
  }
709
1652
  }
710
1653
 
1654
+ private chips(entries: ReadonlyArray<readonly [string, string]>, theme: LobbyTheme): string {
1655
+ return entries.map(([key, text]) => `${paint(theme, "accent", key)} ${paint(theme, "dim", text)}`).join(paint(theme, "dim", " "));
1656
+ }
1657
+
711
1658
  private hintLine(width: number, theme: LobbyTheme): string {
712
1659
  const notice = this.notice && this.now() - this.notice.at < NOTICE_MS ? this.notice : undefined;
713
1660
  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);
1661
+ const k = (action: LobbyAction) => keyLabel(this.keys[action]).toLowerCase();
1662
+ const badge = (text: string, color: "accent" | "muted" | "warning") => {
1663
+ const label = bold(theme, paint(theme, color, ` ${text} `));
1664
+ return theme.bg ? theme.bg("selectedBg", label) : label;
1665
+ };
1666
+ if (this.help) return fit(`${badge("KEYS", "accent")} ${this.chips([["any key", "closes"]], theme)}`, width);
1667
+ if (this.searching) return fit(`${badge("SEARCH", "warning")} ${this.chips([["enter", "keep"], ["esc", "clear"], ["↑↓", "results"], [k("nextTab"), "next tab"]], theme)}`, width);
1668
+ const mode = this.mode === "type" ? badge("TYPE", "accent") : badge("BROWSE", "muted");
1669
+ return fit(`${mode} ${this.chips(this.hintChips(), theme)}`, width);
1670
+ }
1671
+
1672
+ /**
1673
+ * The few keys that matter right now, most useful first: typing names what
1674
+ * enter does here; browsing names the tab's main commands, only those that
1675
+ * apply. Everything else is one `?` away.
1676
+ */
1677
+ private hintChips(): Array<[string, string]> {
1678
+ const k = (action: LobbyAction) => keyLabel(this.keys[action]).toLowerCase();
1679
+ const session = this.host.planner();
1680
+ if (this.mode === "type") {
1681
+ if (this.tab === "lobby" && this.newSession) return [["enter", "start in a new session"], ["esc", "cancel"]];
1682
+ if (this.tab === "lobby" && this.viewing.kind !== "here") {
1683
+ const background = this.viewedSession();
1684
+ if (background?.dialogs.length && !this.editor.getText().trim()) return [["enter", "answer its question"], [k("sessions"), "sessions"], [k("hide"), "hide"]];
1685
+ return [["enter", background?.busy ? "steer" : "send"], [k("sessions"), "sessions"], [k("toggleAuto"), "auto"]];
1686
+ }
1687
+ if (this.tab === "lobby" && this.host.masterBusy()) return [["enter", "steer"], ["esc", "stop the oracle"], [k("hide"), "hide"]];
1688
+ if (this.tab === "plan" && this.lineTarget) return [["enter", "add the comment"], ["esc", "cancel"]];
1689
+ const enter = this.tab === "plan" && session?.awaitingAnswers && !this.editor.getText().trim() ? "answer questions" : this.tab === "quickfix" ? "run it" : "send";
1690
+ return [["enter", enter], ["esc", "browse"], [k("hide"), "hide"]];
1691
+ }
1692
+ const keys: Array<[string, string]> = [];
1693
+ switch (this.tab) {
1694
+ case "lobby":
1695
+ keys.push(["←→", "pane"], ["↑↓", "scroll"], [k("sessions"), "sessions"], [k("newSession"), "new session"]);
1696
+ if (this.viewedEntry().task) keys.push([k("toggleAuto"), "auto"], ["c", "comment on plan"]);
1697
+ break;
1698
+ case "tasks": {
1699
+ const row = this.taskRowList()[this.tasksSelected];
1700
+ keys.push(["↑↓", "select"]);
1701
+ if (row?.kind === "task") keys.push(["c", "comment"], ["o", "open its session"], [k("toggleAuto"), "auto"]);
1702
+ if (row?.kind === "plan") keys.push(["s", "start in a new session"], ["h", "start here"], ["d", "discard"]);
1703
+ keys.push(["n", "new task"]);
1704
+ break;
1705
+ }
1706
+ case "plan":
1707
+ if (session?.awaitingAnswers) keys.push(["a", "answer"]);
1708
+ if (session?.reply?.plan) keys.push([k("savePlan"), "save"], ["c", "comment on a line"]);
1709
+ if (session?.busy) keys.push(["x", "stop"]);
1710
+ keys.push(["n", "new"], ["1-4", "seats"]);
1711
+ break;
1712
+ case "quickfix":
1713
+ if (this.host.quickfix.jobs.length > 0) keys.push(["↑↓", "select"], ["x", "cancel"]);
1714
+ keys.push(["m", "model"]);
1715
+ break;
1716
+ case "issues":
1717
+ keys.push(["↑↓", "select"], ["p", "plan it"], ["n", "new"]);
1718
+ break;
1719
+ case "metrics":
1720
+ keys.push(["↑↓", "select"], ["g", "group"], ["s", "sort"]);
1721
+ break;
1722
+ }
1723
+ return [...keys, ["?", "all keys"]];
1724
+ }
1725
+
1726
+ /** The help screen: every shortcut, the typing and browsing keys, and this tab's commands. */
1727
+ private helpBody(width: number, height: number, theme: LobbyTheme): string[] {
1728
+ const keyWidth = 13;
1729
+ const row = (key: string, text: string, inner: number) => wrapHanging(`${paint(theme, "accent", fit(key, keyWidth))} `, text, inner);
1730
+ const section = (title: string) => bold(theme, paint(theme, "mdHeading", title));
1731
+ const tabs = this.tabs();
1732
+ const everywhere = (inner: number) => [
1733
+ section("Everywhere"),
1734
+ ...(Object.keys(LOBBY_ACTIONS) as LobbyAction[]).flatMap((action) => row(keyLabel(this.keys[action]), LOBBY_ACTIONS[action].help, inner)),
1735
+ ...row(`Alt+1…${tabs.length}`, "jump to a tab", inner),
1736
+ ...row("Ctrl+C", "clear the prompt, or hide the lobby", inner),
1737
+ ...row("click", "a tab to open it, a pane to give it the keys, a draft plan line to comment on it", inner),
1738
+ ...row("wheel", "scroll the pane under the pointer", inner),
1739
+ ];
1740
+ const modes = (inner: number) => [
1741
+ section("Typing"),
1742
+ ...row("Enter", "send", inner),
1743
+ ...row("Shift+Enter", "new line", inner),
1744
+ ...row("Esc", "stop typing and browse", inner),
1745
+ "",
1746
+ section("Browsing"),
1747
+ ...row("i", "type", inner),
1748
+ ...row("/", "search this tab (esc clears)", inner),
1749
+ ...row("?", "these keys", inner),
1750
+ "",
1751
+ section(`${TAB_LABELS[this.tab]} tab`),
1752
+ ...this.tabKeys().flatMap((entry) => row(entry.key, entry.text, inner)),
1753
+ ];
1754
+ 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.");
1755
+ const title = { title: "Keys", right: "any key closes", focused: true, theme } as const;
1756
+ if (width >= 100) {
1757
+ const left = Math.floor((width - 1) / 2);
1758
+ const inner = left - 4;
1759
+ const columns = beside([
1760
+ box(left, height, [...everywhere(inner), "", ...wrapHanging("", footer, inner)], title),
1761
+ box(width - 1 - left, height, modes(width - 1 - left - 4), { focused: true, theme }),
1762
+ ]);
1763
+ return columns;
1764
+ }
1765
+ const inner = width - 4;
1766
+ return box(width, height, [...everywhere(inner), "", ...modes(inner), "", ...wrapHanging("", footer, inner)], title);
716
1767
  }
717
1768
 
718
1769
  private body(width: number, height: number, theme: LobbyTheme): string[] {
719
1770
  const now = this.now();
1771
+ const query = this.query();
1772
+ this.panes.clear();
720
1773
  switch (this.tab) {
721
1774
  case "lobby":
722
1775
  return this.homeBody(width, height, theme, now);
723
1776
  case "tasks":
724
1777
  return this.tasksBody(width, height, theme, now);
725
- case "plan":
726
- return renderPlan({
727
- ...(this.host.planner() ? { session: this.planView(this.host.planner()!) } : {}),
1778
+ case "plan": {
1779
+ const session = this.host.planner();
1780
+ const lines = renderPlan({
1781
+ ...(session ? { session: this.planView(session) } : {}),
728
1782
  profile: this.host.profileLabel("planner"),
729
1783
  seats: this.seatViews(undefined),
730
1784
  offset: this.planOffset,
731
1785
  focus: this.planFocus,
732
1786
  draftOffset: this.planDraftOffset,
1787
+ cursor: this.planCursor,
733
1788
  tick: this.tick,
1789
+ ...(query ? { query } : {}),
1790
+ layout: this.planLayout,
1791
+ panes: this.panes,
1792
+ saveKey: keyLabel(this.keys.savePlan),
734
1793
  }, width, height, theme);
1794
+ if (!session) Object.assign(this.planLayout, { draftRows: 0, draftStart: 0, draftText: [] });
1795
+ this.planCursor = Math.min(this.planCursor, Math.max(0, this.planLayout.draftText.length - 1));
1796
+ return lines;
1797
+ }
735
1798
  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);
1799
+ this.fixSelected = Math.min(this.fixSelected, Math.max(0, this.fixJobs().length - 1));
1800
+ 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);
738
1801
  case "issues": {
739
1802
  const issues = this.host.issues;
740
1803
  this.issueSelected = Math.min(this.issueSelected, Math.max(0, issues.issues.length - 1));
@@ -746,11 +1809,11 @@ export class LobbyView implements Component, Focusable {
746
1809
  }, width, height, theme);
747
1810
  }
748
1811
  case "metrics": {
749
- const records = collectMetrics(this.data.metrics, this.data.tasks);
1812
+ const records = filterRecords(collectMetrics(this.data.metrics, this.data.tasks), query);
750
1813
  const groups = sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort);
751
1814
  this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
752
1815
  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);
1816
+ 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);
754
1817
  }
755
1818
  }
756
1819
  }
@@ -777,7 +1840,8 @@ export class LobbyView implements Component, Focusable {
777
1840
  seated: seats.has(member),
778
1841
  status: state ? (state.status === "thinking" ? "thinking" : state.status) : "idle",
779
1842
  ...(state?.step ? { step: state.step } : {}),
780
- questions: state?.reply?.questions.length ?? 0,
1843
+ // The questions from this seat that reached the user this round.
1844
+ questions: session?.questions.filter((question) => question.from === MEMBER_LABELS[member]).length ?? 0,
781
1845
  ready: state?.reply?.status === "ready",
782
1846
  profile: this.host.seatLabel(member),
783
1847
  };
@@ -799,16 +1863,42 @@ export class LobbyView implements Component, Focusable {
799
1863
  ...(session.seed ? { seed: session.seed } : {}),
800
1864
  ...(session.saved ? { saved: session.saved } : {}),
801
1865
  ...(session.title ? { title: session.title } : {}),
1866
+ awaitingAnswers: session.awaitingAnswers,
1867
+ answeredChunks: session.answered.length,
1868
+ lineComments: session.lineComments,
802
1869
  };
803
1870
  }
804
1871
 
1872
+ /** The session switcher: every session this window can show, with what each is doing. */
1873
+ private pickerBody(width: number, height: number, theme: LobbyTheme): string[] {
1874
+ const entries = this.sessionEntries();
1875
+ this.pickIndex = Math.min(this.pickIndex, Math.max(0, entries.length - 1));
1876
+ const inner = width - 4;
1877
+ const nameWidth = Math.min(34, Math.max(14, Math.floor(inner * 0.36)));
1878
+ const marks = { "this window": "●", background: "◆", "other terminal": "◇" } as const;
1879
+ const lines = entries.map((entry, index) => {
1880
+ const selected = index === this.pickIndex;
1881
+ const mark = paint(theme, entry.where === "this window" ? "accent" : "toolTitle", marks[entry.where]);
1882
+ const name = fit(selected ? bold(theme, entry.name) : entry.name, nameWidth);
1883
+ const status = paint(theme, entry.status === "working" ? "accent" : entry.status === "ended" ? "dim" : "muted", entry.status.replace(/_/g, " "));
1884
+ const badges = [entry.auto ? paint(theme, "success", "⟳ auto") : "", entry.waiting > 0 ? paint(theme, "warning", `● ${entry.waiting} waiting`) : ""].filter(Boolean).join(" ");
1885
+ const where = paint(theme, "dim", entry.where);
1886
+ return selectRow(theme, `${selected ? paint(theme, "accent", "▸") : " "} ${mark} ${name} ${where} ${paint(theme, "dim", "·")} ${status}${badges ? ` ${badges}` : ""}`, inner, selected, true);
1887
+ });
1888
+ const help = paint(theme, "dim", "enter shows it here · n new task in a new session · x x stops a background session · esc closes");
1889
+ return box(width, height, [...lines, "", ...wrap(help, inner)], { title: "Sessions", right: `${entries.length}`, focused: true, theme });
1890
+ }
1891
+
805
1892
  private homeBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
806
1893
  const zen = this.host.zen();
807
1894
  const feed = this.host.feed;
808
1895
  const sessionId = this.host.sessionId();
809
1896
  const others = this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && task.ownerSessionId !== sessionId).length;
1897
+ const query = this.query();
1898
+ const entry = this.viewedEntry();
1899
+ if (entry.view.kind !== "here") return this.otherSessionBody(entry, width, height, theme, now);
810
1900
  return renderHome({
811
- ...(zen.task ? { task: zen.task, scene: (w: number, h: number) => this.host.scene(w, h) } : {}),
1901
+ ...(zen.task ? { task: zen.task, scene: (w: number, h: number, animated: boolean) => this.host.scene(w, h, animated) } : {}),
812
1902
  chat: feed.chat,
813
1903
  ...(feed.reply ? { liveReply: feed.reply } : {}),
814
1904
  activity: feed.activity,
@@ -816,17 +1906,78 @@ export class LobbyView implements Component, Focusable {
816
1906
  busy: this.host.masterBusy(),
817
1907
  others,
818
1908
  pending: this.data.plans.filter((plan) => plan.status === "pending").length,
819
- chatOffset: this.chatOffset,
1909
+ offsets: this.homeOffsets,
1910
+ ...(this.mode === "browse" ? { focus: this.homeFocus } : {}),
1911
+ panes: this.panes,
820
1912
  tick: this.tick,
821
1913
  now,
1914
+ panels: this.panels,
1915
+ ...(query ? { query } : {}),
1916
+ keys: {
1917
+ animations: keyLabel(this.keys.toggleScene),
1918
+ conversation: keyLabel(this.keys.toggleConversation),
1919
+ activity: keyLabel(this.keys.toggleActivity),
1920
+ thinking: keyLabel(this.keys.toggleThinking),
1921
+ },
1922
+ }, width, height, theme);
1923
+ }
1924
+
1925
+ /**
1926
+ * Another session on the Lobby tab: a background session's live feed, or an
1927
+ * other terminal's conversation read from its session file; the task's
1928
+ * status sits on top without animations.
1929
+ */
1930
+ private otherSessionBody(entry: SessionEntry, width: number, height: number, theme: LobbyTheme, now: number): string[] {
1931
+ const session = this.viewedSession();
1932
+ const task = entry.task;
1933
+ const query = this.query();
1934
+ const common = {
1935
+ ...(task ? { task, scene: (w: number, h: number) => this.host.taskScene(task, w, h), stillScene: true } : {}),
1936
+ others: 0,
1937
+ pending: 0,
1938
+ offsets: this.homeOffsets,
1939
+ ...(this.mode === "browse" ? { focus: this.homeFocus } : {}),
1940
+ panes: this.panes,
1941
+ tick: this.tick,
1942
+ now,
1943
+ panels: this.panels,
1944
+ ...(query ? { query } : {}),
1945
+ title: entry.name,
1946
+ keys: {
1947
+ animations: keyLabel(this.keys.toggleScene),
1948
+ conversation: keyLabel(this.keys.toggleConversation),
1949
+ activity: keyLabel(this.keys.toggleActivity),
1950
+ thinking: keyLabel(this.keys.toggleThinking),
1951
+ },
1952
+ };
1953
+ if (session) {
1954
+ const emptyNote = session.status === "starting" ? `starting ${session.name}…` : !session.alive ? `${session.name} has ended.` : `${session.name} has not said anything yet.`;
1955
+ return renderHome({
1956
+ ...common,
1957
+ chat: session.feed.chat,
1958
+ ...(session.feed.reply ? { liveReply: session.feed.reply } : {}),
1959
+ activity: session.feed.activity,
1960
+ thoughts: session.feed.thoughts,
1961
+ busy: session.busy,
1962
+ emptyNote,
1963
+ }, width, height, theme);
1964
+ }
1965
+ return renderHome({
1966
+ ...common,
1967
+ chat: task?.ownerSessionId ? this.host.sessionChat(task.ownerSessionId) : [],
1968
+ activity: [],
1969
+ thoughts: [],
1970
+ busy: false,
1971
+ emptyNote: "Nothing said in this session yet.",
1972
+ activityNote: "This session runs in another terminal: its conversation shows here and your messages reach its oracle, but live activity only streams from sessions started in this window.",
822
1973
  }, width, height, theme);
823
1974
  }
824
1975
 
825
1976
  private tasksBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
826
- const rows = this.taskRowList(this.data.tasks);
1977
+ const rows = this.taskRowList();
827
1978
  this.tasksSelected = Math.min(this.tasksSelected, Math.max(0, rows.length - 1));
828
1979
  const row = rows[this.tasksSelected];
829
- const detailWidth = width >= 90 ? Math.max(24, width - Math.round((width - 3) * 0.38) - 3) : width;
1980
+ const detailWidth = tasksWidths(width).detail - 4;
830
1981
  let detail: string[] = [];
831
1982
  if (row?.kind === "task") {
832
1983
  const task = this.data.tasks.find((entry) => entry.id === row.id);
@@ -835,21 +1986,30 @@ export class LobbyView implements Component, Focusable {
835
1986
  const plan = this.data.plans.find((entry) => entry.id === row.id);
836
1987
  if (plan) detail = planDetailLines(plan, detailWidth, now, theme);
837
1988
  }
838
- return renderTasks({ rows, selected: this.tasksSelected, detail, focus: this.tasksFocus, detailOffset: this.tasksDetailOffset }, width, height, theme);
1989
+ const query = this.query();
1990
+ return renderTasks({ rows, selected: this.tasksSelected, detail, focus: this.tasksFocus, detailOffset: this.tasksDetailOffset, ...(query ? { query } : {}), panes: this.panes }, width, height, theme);
839
1991
  }
840
1992
 
841
1993
  render(width: number): string[] {
842
1994
  const theme = this.host.theme();
843
1995
  const rows = Math.max(1, this.host.rows());
844
- const top = [this.tabBar(width, theme), paint(theme, "borderMuted", "─".repeat(Math.max(0, width)))];
1996
+ const top = [this.tabBar(width, theme)];
845
1997
  const prompt = this.promptLines(width, theme);
1998
+ const search = this.searchLines(width, theme);
846
1999
  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));
2000
+ const bodyHeight = Math.max(0, rows - top.length - search.length - prompt.length - 1);
2001
+ this.bodyTop = top.length;
2002
+ this.promptTop = top.length + bodyHeight + search.length;
2003
+ let body = this.help ? this.helpBody(width, bodyHeight, theme) : this.picking ? this.pickerBody(width, bodyHeight, theme) : this.body(width, bodyHeight, theme);
2004
+ // A pane scrolled back that gained lines moves with them, so it keeps showing what was being read.
2005
+ if (!this.help && !this.picking && this.clampScroll()) body = this.body(width, bodyHeight, theme);
2006
+ const query = this.query();
2007
+ if (query && !this.help) body = body.map((line) => highlight(line, query));
2008
+ return [...top, ...body, ...search, ...prompt, hint].slice(0, rows).map((line) => fit(line, width));
850
2009
  }
851
2010
 
852
2011
  invalidate(): void {
853
2012
  this.editor.invalidate();
2013
+ this.search.invalidate();
854
2014
  }
855
2015
  }