@a-t-h-i/bot-lobby 0.5.1 → 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
@@ -16,12 +16,13 @@ import type { PlannedTask } from "../state/backlog.ts";
16
16
  import type { PlanComment } from "../state/comments.ts";
17
17
  import { aggregateMetrics, collectMetrics, SORT_KEYS, sortGroups, taskStats, taskTimesByModel, type GroupBy, type MetricRecord, type SortKey } from "../state/metrics.ts";
18
18
  import { PANEL_MEMBERS, type LobbyAgentKind, type LobbyPanel, type PanelMember } from "../schemas/configuration.ts";
19
- import type { LobbyFeed } from "./feed.ts";
19
+ import type { ChatEntry, LobbyFeed } from "./feed.ts";
20
+ import type { BackgroundSession } from "./sessions.ts";
20
21
  import type { QuickFixQueue } from "./quickfix.ts";
21
22
  import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } from "./planner.ts";
22
23
  import { issueText, type IssuesState } from "./issues.ts";
23
24
  import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, type KeyMap, type LobbyAction } from "./keys.ts";
24
- import { beside, bold, box, fit, highlight, paint, spinner, wrapHanging, type LobbyTheme, type PaneLayout } from "./layout.ts";
25
+ import { beside, bold, box, fit, highlight, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "./layout.ts";
25
26
  import { HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
26
27
  import { filterRows, planDetailLines, renderTasks, taskDetailLines, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
27
28
  import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
@@ -52,8 +53,24 @@ const PROMPT_FIRST: ReadonlySet<TabId> = new Set(["lobby", "plan", "quickfix"]);
52
53
  export type LobbyMode = "type" | "browse";
53
54
 
54
55
  /** What each pane toggle is called in notices and help. */
55
- const PANEL_NAMES: Record<LobbyPanel, string> = { scene: "zen scene", conversation: "conversation", activity: "activity log", thinking: "thinking" };
56
- const PANEL_ACTIONS: Record<LobbyPanel, LobbyAction> = { scene: "toggleScene", conversation: "toggleConversation", activity: "toggleActivity", thinking: "toggleThinking" };
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
+ }
57
74
 
58
75
  /** Everything the view needs from pi and bot-lobby. */
59
76
  export interface LobbyHost {
@@ -62,8 +79,8 @@ export interface LobbyHost {
62
79
  sessionId(): string | undefined;
63
80
  /** This session's active task and its runs, as the zen widget sees them. */
64
81
  zen(): { task?: Task; runs: readonly AgentRun[] };
65
- /** Draw the zen scene into at most `height` lines. */
66
- 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[];
67
84
  /** Advance the scene's clock; returns the delay it wants until the next step. */
68
85
  advanceScene(now: number): number;
69
86
  feed: LobbyFeed;
@@ -99,6 +116,23 @@ export interface LobbyHost {
99
116
  keys(): Readonly<Record<string, string>>;
100
117
  /** Open bot-lobby's settings (or one agent's entry); the lobby steps aside and rereads the config after. */
101
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[];
102
136
  profileLabel(kind: LobbyAgentKind): string;
103
137
  requestRender(): void;
104
138
  now?(): number;
@@ -137,11 +171,10 @@ interface Notice {
137
171
  kind: "info" | "warning";
138
172
  }
139
173
 
140
- /** A key and what it does, for the help screen; `hint` is the short form on the hint line. */
174
+ /** A key and what it does, for the help screen. */
141
175
  interface KeyHelp {
142
176
  key: string;
143
177
  text: string;
144
- hint?: string;
145
178
  }
146
179
 
147
180
  function isPrintable(data: string): string | undefined {
@@ -198,6 +231,13 @@ export class LobbyView implements Component, Focusable {
198
231
  private issuesOn: boolean;
199
232
  /** The help screen covers the body. */
200
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;
201
241
  /** The search bar has the keys. */
202
242
  searching = false;
203
243
  /** Each tab's search, kept while you move between tabs. */
@@ -304,6 +344,7 @@ export class LobbyView implements Component, Focusable {
304
344
  return this.host.masterBusy()
305
345
  || Boolean(this.host.quickfix.running)
306
346
  || Boolean(this.host.planner()?.busy)
347
+ || this.host.sessions().some((session) => session.busy)
307
348
  || (this.issuesOn && this.host.issues.loading)
308
349
  || zen.runs.some((run) => run.status === "running")
309
350
  || Boolean(zen.task && !zen.task.paused && !TERMINAL_STATES.includes(zen.task.state));
@@ -315,7 +356,7 @@ export class LobbyView implements Component, Focusable {
315
356
  const now = this.now();
316
357
  this.tick += 1;
317
358
  let delay = this.isLive() ? LIVE_MS : IDLE_MS;
318
- if (this.tab === "lobby" && this.panels.scene && this.host.zen().task) delay = Math.min(delay, this.host.advanceScene(now));
359
+ if (this.tab === "lobby" && this.panels.animations && this.host.zen().task) delay = Math.min(delay, this.host.advanceScene(now));
319
360
  this.refreshData(false);
320
361
  this.host.requestRender();
321
362
  this.schedule(delay);
@@ -364,6 +405,8 @@ export class LobbyView implements Component, Focusable {
364
405
  this.issueDraft = false;
365
406
  this.armed = undefined;
366
407
  this.help = false;
408
+ this.picking = false;
409
+ this.newSession = false;
367
410
  this.closeSearch();
368
411
  this.setMode(PROMPT_FIRST.has(tab) ? "type" : "browse");
369
412
  this.refreshData(true);
@@ -506,6 +549,8 @@ export class LobbyView implements Component, Focusable {
506
549
  // Esc, ?, q and the help key only close it; anything else also does its job.
507
550
  if (action === "help" || matchesKey(data, Key.escape) || data === "?" || data === "q" || matchesKey(data, Key.enter)) return;
508
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;
509
554
  // While searching, the shortcuts still work; everything else edits the query.
510
555
  if (this.searching) return action ? this.runAction(action) : this.searchKey(data);
511
556
  if (action) return this.runAction(action);
@@ -530,6 +575,14 @@ export class LobbyView implements Component, Focusable {
530
575
  return;
531
576
  case "settings":
532
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();
533
586
  case "search":
534
587
  return this.searching ? this.closeSearch() : this.openSearch();
535
588
  case "nextTab":
@@ -537,7 +590,7 @@ export class LobbyView implements Component, Focusable {
537
590
  case "prevTab":
538
591
  return this.cycleTab(-1);
539
592
  case "toggleScene":
540
- return this.togglePanel("scene");
593
+ return this.togglePanel("animations");
541
594
  case "toggleConversation":
542
595
  return this.togglePanel("conversation");
543
596
  case "toggleActivity":
@@ -559,6 +612,7 @@ export class LobbyView implements Component, Focusable {
559
612
  }
560
613
  this.commentTarget = undefined;
561
614
  this.issueDraft = false;
615
+ this.newSession = false;
562
616
  if (this.lineTarget) {
563
617
  this.lineTarget = undefined;
564
618
  this.planFocus = "draft";
@@ -847,19 +901,26 @@ export class LobbyView implements Component, Focusable {
847
901
  const enter = matchesKey(data, Key.enter);
848
902
  const escape = matchesKey(data, Key.escape);
849
903
  switch (this.tab) {
850
- case "lobby":
851
- 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()) {
852
912
  this.host.abortMaster();
853
913
  this.say("stopping the oracle…");
854
914
  return true;
855
915
  }
856
916
  if (data === "c") {
857
- const task = this.host.zen().task;
858
- if (task) this.commentOn(task.id);
917
+ const task = this.viewedEntry().task;
918
+ if (task && !TERMINAL_STATES.includes(task.state)) this.commentOn(task.id);
859
919
  else this.say("no task in this session to comment on", "warning");
860
920
  return true;
861
921
  }
862
922
  return enter ? (this.setMode("type"), true) : false;
923
+ }
863
924
  case "tasks":
864
925
  return this.tasksCommand(data, enter, escape);
865
926
  case "plan":
@@ -889,6 +950,7 @@ export class LobbyView implements Component, Focusable {
889
950
  if (enter) return (this.tasksFocus = this.tasksFocus === "list" ? "detail" : "list"), true;
890
951
  if (escape) return (this.tasksFocus = "list"), true;
891
952
  if (data === "r") return this.refreshData(true), true;
953
+ if (data === "n") return this.startNewSessionPrompt(), true;
892
954
  if (!row) return false;
893
955
  if (data === "c") {
894
956
  const task = this.data.tasks.find((entry) => entry.id === row.id);
@@ -901,11 +963,39 @@ export class LobbyView implements Component, Focusable {
901
963
  return true;
902
964
  }
903
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).
904
967
  if (data === "s" && plan) {
968
+ this.startInNewSession({ plan });
969
+ return true;
970
+ }
971
+ if (data === "h" && plan) {
905
972
  this.say(this.host.startPlanned(plan));
906
973
  this.refreshData(true);
907
974
  return true;
908
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
+ }
909
999
  if (data === "d" && plan) {
910
1000
  if (this.armed !== `discard:${plan.id}`) {
911
1001
  this.armed = `discard:${plan.id}`;
@@ -935,6 +1025,182 @@ export class LobbyView implements Component, Focusable {
935
1025
  this.planCursor = 0;
936
1026
  }
937
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
+
938
1204
  /** The oracle puts the round's questions to the user through the questionnaire. */
939
1205
  answerQuestions(): void {
940
1206
  const session = this.host.planner();
@@ -1007,16 +1273,6 @@ export class LobbyView implements Component, Focusable {
1007
1273
  else this.say("nothing to retry", "warning");
1008
1274
  return true;
1009
1275
  }
1010
- if (data === "s") {
1011
- try {
1012
- const saved = session?.save();
1013
- this.say(saved ? `saved ${saved.id} — start it from the Tasks tab when you are ready` : "nothing to save yet", saved ? "info" : "warning");
1014
- } catch (error) {
1015
- this.say((error as Error).message, "warning");
1016
- }
1017
- this.refreshData(true);
1018
- return true;
1019
- }
1020
1276
  if (data === "n") {
1021
1277
  const unsaved = session && session.messages.length > 0 && !session.saved;
1022
1278
  if (unsaved && this.armed !== "new-plan") {
@@ -1135,18 +1391,15 @@ export class LobbyView implements Component, Focusable {
1135
1391
  private submit(text: string): void {
1136
1392
  const body = text.trim();
1137
1393
  if (!body) {
1138
- // An empty enter on the Plan tab opens the panel's questions.
1394
+ // An empty enter on the Plan tab opens the panel's questions; on the Lobby, a background session's question.
1139
1395
  if (this.tab === "plan" && !this.lineTarget && this.host.planner()?.awaitingAnswers) this.answerQuestions();
1396
+ if (this.tab === "lobby" && !this.newSession) this.answerSessionDialog();
1140
1397
  return;
1141
1398
  }
1142
1399
  this.editor.addToHistory(body);
1143
1400
  switch (this.tab) {
1144
- case "lobby": {
1145
- const notice = this.host.toOracle(body);
1146
- if (notice) this.say(notice);
1147
- this.homeOffsets.conversation = 0;
1148
- return;
1149
- }
1401
+ case "lobby":
1402
+ return this.submitLobby(body);
1150
1403
  case "tasks":
1151
1404
  if (!this.commentTarget) {
1152
1405
  this.editor.setText(body);
@@ -1210,7 +1463,10 @@ export class LobbyView implements Component, Focusable {
1210
1463
 
1211
1464
  /** Task rows, narrowed by the Tasks tab's search. */
1212
1465
  private taskRowList(): TaskRow[] {
1213
- const rows = taskRows(this.data.tasks, this.data.plans, this.host.sessionId(), this.now());
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 });
1214
1470
  return filterRows(rows, this.data.tasks, this.data.plans, this.queries.tasks);
1215
1471
  }
1216
1472
 
@@ -1244,13 +1500,26 @@ export class LobbyView implements Component, Focusable {
1244
1500
  }
1245
1501
  this.tabSpans = spans;
1246
1502
  const room = width - visibleWidth(left) - 1;
1247
- const dot = paint(theme, this.host.masterBusy() ? "accent" : "dim", this.host.masterBusy() ? spinner(this.tick) : "●");
1248
- const state = zen.task ? paint(theme, "muted", zen.task.paused ? `${zen.task.state} (paused)` : zen.task.state) : "";
1503
+ const entry = this.viewedEntry();
1249
1504
  const help = paint(theme, "dim", `${keyLabel(this.keys.help)} keys `);
1250
- // Long task ids give way to the state, then to the dot alone.
1251
- const choices = zen.task
1252
- ? [`${dot} ${zen.task.id} ${state} ${help}`, `${dot} ${zen.task.id} ${state} `, `${dot} ${state} `, `${dot} `]
1253
- : [`${paint(theme, "dim", "no task in this session")} ${help}`, paint(theme, "dim", "no task in this session "), help, ""];
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
+ }
1254
1523
  const status = choices.find((choice) => visibleWidth(choice) <= room) ?? "";
1255
1524
  const gap = width - visibleWidth(left) - visibleWidth(status);
1256
1525
  return gap >= 1 ? `${left}${" ".repeat(gap)}${status}` : fit(left, width);
@@ -1260,23 +1529,33 @@ export class LobbyView implements Component, Focusable {
1260
1529
  const zen = this.host.zen();
1261
1530
  const browsing = this.mode === "browse";
1262
1531
  switch (this.tab) {
1263
- 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
+ }
1264
1541
  if (!zen.task) return browsing ? "describe a task to start — i to type" : "describe a task to start · enter starts it";
1265
1542
  return browsing ? "message the oracle — i to type" : this.host.masterBusy() ? "message the oracle · enter steers the running turn" : "message the oracle";
1543
+ }
1266
1544
  case "tasks":
1267
1545
  return this.commentTarget ? `comment on ${this.commentTarget}'s plan · enter sends it to the oracle` : "c comments on the selected task's plan";
1268
1546
  case "plan": {
1269
- if (this.lineTarget) return `comment on “${clip(this.lineTarget, 48)}” · enter adds it`;
1547
+ if (this.lineTarget) return `comment on “${clip(this.lineTarget, 48)}”`;
1270
1548
  const session = this.host.planner();
1271
- if (session?.busy) return "the panel is thinking · x stops it";
1549
+ if (session?.busy) return "the panel is thinking";
1272
1550
  if (session?.awaitingAnswers) {
1273
1551
  const count = session.questions.length;
1274
- return `enter answers the panel's ${count} question${count === 1 ? "" : "s"} one at a time · or type a reply`;
1552
+ return `press enter to answer ${count} question${count === 1 ? "" : "s"}, or type a reply`;
1275
1553
  }
1276
- return session && session.messages.length > 0 ? "reply to the panel" : "describe the task you want to plan";
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";
1277
1556
  }
1278
1557
  case "quickfix":
1279
- return this.host.quickfix.running ? "describe a quick change · queues behind the running one" : "describe a quick change · runs now, beside any task";
1558
+ return this.host.quickfix.running ? "describe a quick change; it runs after the current one" : "describe a quick change";
1280
1559
  case "issues":
1281
1560
  return this.issueDraft ? "new issue · first line is the title" : "n files a new issue";
1282
1561
  case "metrics":
@@ -1310,61 +1589,64 @@ export class LobbyView implements Component, Focusable {
1310
1589
 
1311
1590
  /** Every key the current tab understands while browsing. */
1312
1591
  private tabKeys(): KeyHelp[] {
1313
- const session = this.host.planner();
1314
1592
  switch (this.tab) {
1315
1593
  case "lobby":
1316
1594
  return [
1317
- { key: "type", text: "talk to the oracle (starts a task when none is running)", hint: "talk" },
1318
- { key: "↑ ↓", text: "scroll the focused pane (PageUp/PageDown a page)", hint: "scroll" },
1319
- { key: "← →", text: "move between the conversation, activity log and thinking", hint: "pane" },
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" },
1320
1599
  { key: "Home End", text: "the oldest lines, or back to the newest" },
1321
- { key: "c", text: "comment on this task's plan", hint: "comment on plan" },
1600
+ { key: "c", text: "comment on this task's plan" },
1322
1601
  { key: "esc", text: "stop the oracle while it works" },
1323
1602
  ];
1324
1603
  case "tasks":
1325
1604
  return [
1326
- { key: "↑ ↓", text: "select a task or plan, or scroll the detail (PageUp/PageDown)", hint: "select" },
1327
- { key: "enter / ← →", text: "move between the list and the detail", hint: "detail" },
1328
- { key: "c", text: "comment on the selected task's plan", hint: "comment" },
1329
- { key: "s", text: "start the selected planned task", hint: "start" },
1330
- { key: "d d", text: "discard the selected planned task", hint: "discard" },
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" },
1331
1614
  { key: "r", text: "reread tasks from disk" },
1332
1615
  ];
1333
1616
  case "plan":
1334
1617
  return [
1335
- { key: "a", text: "answer the panel's questions, one questionnaire at a time (enter on an empty prompt too)", ...(session?.awaitingAnswers ? { hint: "answer questions" } : {}) },
1336
- { key: "enter / ← →", text: "move between the conversation and the draft", hint: "pane" },
1337
- { key: "↑ ↓", text: "pick a draft line (draft) or scroll (conversation); PageUp/PageDown a page", hint: "line" },
1338
- { key: "c / click", text: "comment on the picked draft line", hint: "comment on line" },
1339
- { key: "1-4", text: "seat or unseat DEV, DESIGN, QA, RESEARCH", hint: "seats" },
1340
- { key: "s", text: "save the plan to the pending tasks", hint: "save" },
1341
- { key: "n", text: "start a new plan", hint: "new" },
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" },
1342
1624
  { key: "x", text: "stop the round" },
1343
1625
  { key: "r", text: "retry a failed round or seat" },
1344
1626
  { key: "m", text: "the oracle's model, thinking and time limit (Planner settings)" },
1345
1627
  ];
1346
1628
  case "quickfix":
1347
1629
  return [
1348
- { key: "type", text: "describe a quick change", hint: "new fix" },
1349
- { key: "↑ ↓", text: "select a quick fix, or scroll the detail (PageUp/PageDown)", hint: "select" },
1350
- { key: "enter / ← →", text: "move between the list and the detail", hint: "detail" },
1351
- { key: "x", text: "cancel the selected quick fix", hint: "cancel" },
1352
- { key: "m", text: "the quick fix agent's model, thinking, time limit and instructions", hint: "model" },
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" },
1353
1635
  ];
1354
1636
  case "issues":
1355
1637
  return [
1356
- { key: "↑ ↓", text: "select an issue", hint: "select" },
1357
- { key: "enter", text: "read the selected issue", hint: "read" },
1358
- { key: "p", text: "plan the selected issue", hint: "plan it" },
1359
- { key: "n", text: "file a new issue", hint: "new" },
1360
- { key: "r", text: "reload issues", hint: "refresh" },
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" },
1361
1643
  ];
1362
1644
  case "metrics":
1363
1645
  return [
1364
- { key: "↑ ↓", text: "select a row of the table", hint: "select" },
1365
- { key: "g", text: "group by model, or by model and agent", hint: "group" },
1366
- { key: "s", text: "change the sort", hint: "sort" },
1367
- { key: "r", text: "reread the metrics log", hint: "refresh" },
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" },
1368
1650
  ];
1369
1651
  }
1370
1652
  }
@@ -1383,14 +1665,62 @@ export class LobbyView implements Component, Focusable {
1383
1665
  };
1384
1666
  if (this.help) return fit(`${badge("KEYS", "accent")} ${this.chips([["any key", "closes"]], theme)}`, width);
1385
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();
1386
1680
  if (this.mode === "type") {
1387
- const esc = this.tab === "lobby" && this.host.masterBusy() ? "stop the oracle" : this.lineTarget ? "cancel" : "browse";
1388
- const extra: Array<[string, string]> = this.tab === "plan" && this.host.planner()?.awaitingAnswers && !this.lineTarget ? [["enter", "answer questions"]] : [["enter", "send"]];
1389
- return fit(`${badge("TYPE", "accent")} ${this.chips([...extra, ["shift+enter", "newline"], ["esc", esc], [k("nextTab"), "next tab"], [k("help"), "keys"], [k("hide"), "hide"]], theme)}`, width);
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;
1390
1722
  }
1391
- const tabHints = this.tabKeys().filter((entry) => entry.hint).map((entry) => [entry.key, entry.hint!] as const);
1392
- const panes: Array<[string, string]> = this.tab === "lobby" ? [[k("toggleActivity"), "activity"], [k("toggleThinking"), "thinking"]] : [];
1393
- return fit(`${badge("BROWSE", "muted")} ${this.chips([...tabHints, ...panes, ["/", "search"], ["?", "keys"], [k("hide"), "hide"]], theme)}`, width);
1723
+ return [...keys, ["?", "all keys"]];
1394
1724
  }
1395
1725
 
1396
1726
  /** The help screen: every shortcut, the typing and browsing keys, and this tab's commands. */
@@ -1459,6 +1789,7 @@ export class LobbyView implements Component, Focusable {
1459
1789
  ...(query ? { query } : {}),
1460
1790
  layout: this.planLayout,
1461
1791
  panes: this.panes,
1792
+ saveKey: keyLabel(this.keys.savePlan),
1462
1793
  }, width, height, theme);
1463
1794
  if (!session) Object.assign(this.planLayout, { draftRows: 0, draftStart: 0, draftText: [] });
1464
1795
  this.planCursor = Math.min(this.planCursor, Math.max(0, this.planLayout.draftText.length - 1));
@@ -1509,7 +1840,8 @@ export class LobbyView implements Component, Focusable {
1509
1840
  seated: seats.has(member),
1510
1841
  status: state ? (state.status === "thinking" ? "thinking" : state.status) : "idle",
1511
1842
  ...(state?.step ? { step: state.step } : {}),
1512
- 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,
1513
1845
  ready: state?.reply?.status === "ready",
1514
1846
  profile: this.host.seatLabel(member),
1515
1847
  };
@@ -1537,14 +1869,36 @@ export class LobbyView implements Component, Focusable {
1537
1869
  };
1538
1870
  }
1539
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
+
1540
1892
  private homeBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
1541
1893
  const zen = this.host.zen();
1542
1894
  const feed = this.host.feed;
1543
1895
  const sessionId = this.host.sessionId();
1544
1896
  const others = this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && task.ownerSessionId !== sessionId).length;
1545
1897
  const query = this.query();
1898
+ const entry = this.viewedEntry();
1899
+ if (entry.view.kind !== "here") return this.otherSessionBody(entry, width, height, theme, now);
1546
1900
  return renderHome({
1547
- ...(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) } : {}),
1548
1902
  chat: feed.chat,
1549
1903
  ...(feed.reply ? { liveReply: feed.reply } : {}),
1550
1904
  activity: feed.activity,
@@ -1560,7 +1914,7 @@ export class LobbyView implements Component, Focusable {
1560
1914
  panels: this.panels,
1561
1915
  ...(query ? { query } : {}),
1562
1916
  keys: {
1563
- scene: keyLabel(this.keys.toggleScene),
1917
+ animations: keyLabel(this.keys.toggleScene),
1564
1918
  conversation: keyLabel(this.keys.toggleConversation),
1565
1919
  activity: keyLabel(this.keys.toggleActivity),
1566
1920
  thinking: keyLabel(this.keys.toggleThinking),
@@ -1568,6 +1922,57 @@ export class LobbyView implements Component, Focusable {
1568
1922
  }, width, height, theme);
1569
1923
  }
1570
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.",
1973
+ }, width, height, theme);
1974
+ }
1975
+
1571
1976
  private tasksBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
1572
1977
  const rows = this.taskRowList();
1573
1978
  this.tasksSelected = Math.min(this.tasksSelected, Math.max(0, rows.length - 1));
@@ -1595,9 +2000,9 @@ export class LobbyView implements Component, Focusable {
1595
2000
  const bodyHeight = Math.max(0, rows - top.length - search.length - prompt.length - 1);
1596
2001
  this.bodyTop = top.length;
1597
2002
  this.promptTop = top.length + bodyHeight + search.length;
1598
- let body = this.help ? this.helpBody(width, bodyHeight, theme) : this.body(width, bodyHeight, theme);
2003
+ let body = this.help ? this.helpBody(width, bodyHeight, theme) : this.picking ? this.pickerBody(width, bodyHeight, theme) : this.body(width, bodyHeight, theme);
1599
2004
  // A pane scrolled back that gained lines moves with them, so it keeps showing what was being read.
1600
- if (!this.help && this.clampScroll()) body = this.body(width, bodyHeight, theme);
2005
+ if (!this.help && !this.picking && this.clampScroll()) body = this.body(width, bodyHeight, theme);
1601
2006
  const query = this.query();
1602
2007
  if (query && !this.help) body = body.map((line) => highlight(line, query));
1603
2008
  return [...top, ...body, ...search, ...prompt, hint].slice(0, rows).map((line) => fit(line, width));