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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,12 +6,12 @@
6
6
  * turn into the feed, passes plan comments to the Master that owns a task and
7
7
  * records each Master turn in the metrics log.
8
8
  */
9
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
10
10
  import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
11
11
  import { Key, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
12
12
  import { TERMINAL_STATES } from "../schemas/task.ts";
13
13
  import { listTasks, loadTask } from "../state/persistence.ts";
14
- import { detectProjectRoot, loadConfig } from "../state/project.ts";
14
+ import { detectProjectRoot, loadConfig, saveConfig } from "../state/project.ts";
15
15
  import { addPlanComment, commentMessage, markCommentsDelivered, readPlanComments, undeliveredComments } from "../state/comments.ts";
16
16
  import { discardPlannedTask, listPlannedTasks, markPlannedTaskStarted, plannedTaskRequest, type PlannedTask } from "../state/backlog.ts";
17
17
  import { appendMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
@@ -21,13 +21,16 @@ import { isSubagentProcess } from "../pi/quiet.ts";
21
21
  import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-support.ts";
22
22
  import { modelLookup } from "../pi/tools.ts";
23
23
  import { startTask } from "../pi/start-task.ts";
24
- import type { LobbyAgentKind, PanelMember } from "../schemas/configuration.ts";
24
+ import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
25
25
  import { chatFromEntries, chatText, lobbyFeed, textOf } from "./feed.ts";
26
+ import { answerMessage, dialogAsker, loadAskTool, questionnaires, toolAsker, type Asker } from "./ask.ts";
26
27
  import { QuickFixQueue } from "./quickfix.ts";
27
28
  import { PlanningSession, type PlannerSeed } from "./planner.ts";
28
29
  import { execCommand, IssuesState } from "./issues.ts";
29
30
  import { LobbyView, type LobbyHost, type TabId } from "./view.ts";
30
31
  import type { LobbyTheme } from "./layout.ts";
32
+ import { createMarkdownRenderer } from "./markdown.ts";
33
+ import { openEntrySettings, openSettings } from "../pi/settings-ui.ts";
31
34
 
32
35
  export const ANCHOR_KEY = "bot-lobby-anchor";
33
36
  /** How often the owning session looks for new plan comments. */
@@ -50,6 +53,14 @@ interface Runtime {
50
53
  issues: IssuesState;
51
54
  poll?: ReturnType<typeof setInterval>;
52
55
  unsubscribeFeed?: () => void;
56
+ /** The ask-user-question tool (or pi's dialogs), loaded once on first use. */
57
+ asker?: Promise<Asker>;
58
+ /** A questionnaire is on screen. */
59
+ asking: boolean;
60
+ /** The lobby turned the terminal's mouse reporting on (pi's regular screen only). */
61
+ mouse: boolean;
62
+ /** Settings opened from the lobby are on screen; the lobby stays aside until they close. */
63
+ inSettings: boolean;
53
64
  }
54
65
 
55
66
  let runtime: Runtime | undefined;
@@ -164,14 +175,82 @@ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMe
164
175
  toolStallTimeoutMs: workflow.toolStallTimeoutMs,
165
176
  feed: lobbyFeed,
166
177
  onChange: rerender,
178
+ onRound: (session) => {
179
+ // Put the questions to the user at once when they are looking at the Plan tab.
180
+ if (loadConfig().lobby.autoAsk && state.visible && state.view?.tab === "plan" && session.awaitingAnswers) void answerPanel(state);
181
+ },
167
182
  }, seed);
168
183
  return state.planner;
169
184
  }
170
185
 
186
+ /** The library's questionnaire when it loads, pi's own dialogs otherwise. */
187
+ function panelAsker(state: Runtime): Promise<Asker> {
188
+ state.asker ??= loadAskTool(state.pi).then((tool) => (tool ? toolAsker(tool) : dialogAsker()));
189
+ return state.asker;
190
+ }
191
+
192
+ /**
193
+ * The oracle puts the round's questions to the user, one at a time; answered
194
+ * questionnaires are kept if the user stops, so the next call resumes there.
195
+ * Once every questionnaire is done, the answers (and any line comments) start
196
+ * the next round. Returns a notice for the lobby.
197
+ */
198
+ export async function answerPanel(state: Runtime | undefined = runtime): Promise<string> {
199
+ const session = state?.planner;
200
+ if (!state || !session) return "no planning session";
201
+ if (session.busy) return "the panel is still thinking";
202
+ if (session.questions.length === 0) return "no open questions";
203
+ if (state.asking) return "the questions are already open";
204
+ state.asking = true;
205
+ try {
206
+ const asker = await panelAsker(state);
207
+ const chunks = questionnaires(session.questions);
208
+ for (let index = session.answered.length; index < chunks.length; index++) {
209
+ const result = await asker(chunks[index]!, state.ctx);
210
+ if (result.cancelled) {
211
+ rerender();
212
+ return session.answered.length > 0 ? `answers kept — ${chunks.length - session.answered.length} questionnaire${chunks.length - session.answered.length === 1 ? "" : "s"} left; enter resumes` : "questions put away — enter brings them back";
213
+ }
214
+ session.answered = [...session.answered, result];
215
+ }
216
+ const message = answerMessage(session.questions, session.answered);
217
+ if (!message) {
218
+ session.answered = [];
219
+ return "nothing was answered — the questions stay open";
220
+ }
221
+ void session.send(message);
222
+ return "answers sent — the panel is on the next round";
223
+ } catch (error) {
224
+ return `could not put the questions: ${(error as Error).message}`;
225
+ } finally {
226
+ state.asking = false;
227
+ rerender();
228
+ }
229
+ }
230
+
231
+ const renderMarkdown = createMarkdownRenderer();
232
+ const lobbyThemes = new WeakMap<Theme, LobbyTheme>();
233
+
234
+ /** pi's theme as the lobby draws with it, plus Markdown rendering; one wrapper per theme so caches stay warm. */
235
+ function lobbyTheme(theme: Theme): LobbyTheme {
236
+ let wrapped = lobbyThemes.get(theme);
237
+ if (!wrapped) {
238
+ wrapped = {
239
+ fg: (color, text) => theme.fg(color, text),
240
+ bold: (text) => theme.bold(text),
241
+ italic: (text) => theme.italic(text),
242
+ bg: (color, text) => theme.bg(color, text),
243
+ markdown: renderMarkdown,
244
+ };
245
+ lobbyThemes.set(theme, wrapped);
246
+ }
247
+ return wrapped;
248
+ }
249
+
171
250
  function host(state: Runtime, tui: TUI): LobbyHost {
172
251
  return {
173
252
  rows: () => tui.terminal.rows,
174
- theme: () => state.ctx.ui.theme as LobbyTheme,
253
+ theme: () => lobbyTheme(state.ctx.ui.theme),
175
254
  sessionId: () => state.ctx.sessionManager.getSessionId(),
176
255
  zen: () => {
177
256
  const snapshot = zenSnapshot();
@@ -198,7 +277,13 @@ function host(state: Runtime, tui: TUI): LobbyHost {
198
277
  quickfix: state.quickfix,
199
278
  planner: () => state.planner,
200
279
  newPlanner: (seed, seats) => newPlanner(state, seed, seats),
280
+ answerPanel: () => answerPanel(state),
201
281
  defaultPanel: () => loadConfig().lobby.planningPanel,
282
+ issuesEnabled: () => loadConfig().lobby.issues,
283
+ panels: () => loadConfig().lobby.panels,
284
+ savePanels: (panels) => savePanels(panels),
285
+ keys: () => loadConfig().lobby.keys,
286
+ openSettings: (entry) => lobbySettings(state, entry),
202
287
  seatLabel: (member) => {
203
288
  const profile = seatProfile(state, member);
204
289
  return `${profile.model ?? "session model"} · ${profile.thinking}`;
@@ -212,6 +297,63 @@ function host(state: Runtime, tui: TUI): LobbyHost {
212
297
  };
213
298
  }
214
299
 
300
+ /**
301
+ * bot-lobby's settings (or one agent's entry) from inside the lobby. The
302
+ * lobby stays aside for the whole visit, not only for each menu, so it does
303
+ * not flash between them, and rereads the config when it comes back.
304
+ */
305
+ async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Promise<void> {
306
+ if (state.inSettings) return;
307
+ state.inSettings = true;
308
+ setMouse(state, false);
309
+ state.handle?.setHidden(true);
310
+ try {
311
+ if (entry) await openEntrySettings(state.pi, state.ctx, entry);
312
+ else await openSettings(state.pi, state.ctx);
313
+ } catch (error) {
314
+ state.ctx.ui.notify(`bot-lobby: settings failed — ${(error as Error).message}`, "warning");
315
+ } finally {
316
+ state.inSettings = false;
317
+ state.asideForPrompt = false;
318
+ if (state.visible && state.handle) {
319
+ state.handle.setHidden(false);
320
+ state.handle.focus();
321
+ setMouse(state, true);
322
+ }
323
+ state.view?.reloadConfig();
324
+ rerender();
325
+ }
326
+ }
327
+
328
+ /** Remember which panes show, keeping every other setting as the file has it now. */
329
+ function savePanels(panels: Record<LobbyPanel, boolean>): void {
330
+ try {
331
+ const config = loadConfig();
332
+ saveConfig({ ...config, lobby: { ...config.lobby, panels: { ...panels } } });
333
+ } catch {
334
+ // A read-only config only means the choice lasts for this session.
335
+ }
336
+ }
337
+
338
+ /** SGR mouse reporting: presses, releases and the wheel, in cell coordinates. */
339
+ const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
340
+ const MOUSE_OFF = "\x1b[?1006l\x1b[?1000l";
341
+
342
+ /**
343
+ * In pi's regular screen nothing reports the mouse, so the lobby turns
344
+ * reporting on while it is up (clicks pick tabs and draft lines, the wheel
345
+ * scrolls) and off whenever it steps aside. Full-screen pi reports the mouse
346
+ * itself and hands the events to the lobby.
347
+ */
348
+ function setMouse(state: Runtime, on: boolean): void {
349
+ const tui = state.tui;
350
+ if (!tui || tui.mode === "fullscreen") return;
351
+ const want = on && loadConfig().lobby.mouse;
352
+ if (want === state.mouse) return;
353
+ state.mouse = want;
354
+ tui.terminal.write(want ? MOUSE_ON : MOUSE_OFF);
355
+ }
356
+
215
357
  /** Show the lobby (optionally on `tab`); false when there is no interactive TUI. */
216
358
  export function showLobby(tab?: TabId): boolean {
217
359
  const state = runtime;
@@ -229,6 +371,7 @@ export function showLobby(tab?: TabId): boolean {
229
371
  state.asideForPrompt = false;
230
372
  if (tab) state.view.setTab(tab);
231
373
  state.view.start();
374
+ setMouse(state, true);
232
375
  applyStatus(state.ctx, state.root, state.configDir);
233
376
  state.tui.requestRender();
234
377
  return true;
@@ -239,6 +382,7 @@ export function hideLobby(): void {
239
382
  if (!state?.view || !state.visible) return;
240
383
  state.visible = false;
241
384
  state.asideForPrompt = false;
385
+ setMouse(state, false);
242
386
  state.view.stop();
243
387
  state.handle?.setHidden(true);
244
388
  applyStatus(state.ctx, state.root, state.configDir);
@@ -261,16 +405,19 @@ export function autoOpenLobby(): void {
261
405
  /** Step aside while pi shows a dialog (an approval, a question), and come back after. */
262
406
  function promptStarted(): void {
263
407
  const state = runtime;
264
- if (!state?.visible || !state.handle) return;
408
+ if (!state?.visible || !state.handle || state.inSettings) return;
265
409
  state.asideForPrompt = true;
410
+ setMouse(state, false);
266
411
  state.handle.setHidden(true);
267
412
  }
268
413
 
269
414
  function promptEnded(): void {
270
415
  const state = runtime;
271
- if (!state?.asideForPrompt || !state.handle) return;
416
+ if (!state?.asideForPrompt || !state.handle || state.inSettings) return;
272
417
  state.asideForPrompt = false;
273
- if (state.visible) state.handle.setHidden(false);
418
+ if (!state.visible) return;
419
+ state.handle.setHidden(false);
420
+ setMouse(state, true);
274
421
  }
275
422
 
276
423
  function shutdown(): void {
@@ -278,6 +425,7 @@ function shutdown(): void {
278
425
  if (!state) return;
279
426
  runtime = undefined;
280
427
  if (state.poll) clearInterval(state.poll);
428
+ setMouse(state, false);
281
429
  state.unsubscribeFeed?.();
282
430
  state.quickfix.cancelAll();
283
431
  state.planner?.cancel();
@@ -301,6 +449,9 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
301
449
  root,
302
450
  visible: false,
303
451
  asideForPrompt: false,
452
+ asking: false,
453
+ mouse: false,
454
+ inSettings: false,
304
455
  scene: new ZenScene(),
305
456
  quickfix: undefined as unknown as QuickFixQueue,
306
457
  issues: new IssuesState(execCommand, ctx.cwd, rerender),
@@ -6,7 +6,12 @@
6
6
  */
7
7
  import type { Task } from "../../schemas/task.ts";
8
8
  import type { ActivityEntry, ChatEntry, ThoughtEntry } from "../feed.ts";
9
- import { bold, since, clock, columns, fill, italic, paint, rule, spinner, split, tail, wrap, wrapHanging, type LobbyColor, type LobbyTheme } from "../layout.ts";
9
+ import type { LobbyPanel } from "../../schemas/configuration.ts";
10
+ import { beside, bold, box, clock, fill, italic, markdownHanging, notePane, paint, since, spinner, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
11
+
12
+ /** The Lobby tab's scrollable panes. */
13
+ export const HOME_PANES = ["conversation", "activity", "thinking"] as const;
14
+ export type HomePane = (typeof HOME_PANES)[number];
10
15
 
11
16
  export interface HomeInput {
12
17
  task?: Task;
@@ -22,10 +27,20 @@ export interface HomeInput {
22
27
  /** Active tasks owned by other sessions, and saved plans waiting to start. */
23
28
  others: number;
24
29
  pending: number;
25
- /** Lines of conversation scrollback. */
26
- chatOffset: number;
30
+ /** How far each pane is scrolled back, in lines from its newest; 0 follows the newest. */
31
+ offsets?: Partial<Record<HomePane, number>>;
32
+ /** The pane the arrow keys scroll; its border lights up. */
33
+ focus?: HomePane;
34
+ /** Filled with where each pane landed and how much it holds. */
35
+ panes?: PaneLayout;
27
36
  tick: number;
28
37
  now: number;
38
+ /** Which panes show. */
39
+ panels: Record<LobbyPanel, boolean>;
40
+ /** The search in force: panes show only matching entries. */
41
+ query?: string;
42
+ /** Key labels for the pane toggles, shown on the panes. */
43
+ keys?: Record<LobbyPanel, string>;
29
44
  }
30
45
 
31
46
  /** Wide terminals put the conversation and the activity log side by side. */
@@ -79,16 +94,23 @@ export function chatLines(chat: readonly ChatEntry[], width: number, theme?: Lob
79
94
  const lines: string[] = [];
80
95
  const push = (role: ChatEntry["role"], text: string) => {
81
96
  if (lines.length > 0) lines.push("");
82
- const body = role === "note" ? paint(theme, "dim", text) : text;
83
- lines.push(...wrapHanging(chatLead(role, theme), body, width));
97
+ // The oracle writes Markdown; what you typed and notes are shown as written.
98
+ if (role === "oracle") lines.push(...markdownHanging(chatLead(role, theme), text, width, theme));
99
+ else lines.push(...wrapHanging(chatLead(role, theme), role === "note" ? paint(theme, "dim", text) : text, width));
84
100
  };
85
101
  for (const entry of chat) push(entry.role, entry.text);
86
- if (live?.trim()) push("oracle", `${live.trim()} ${paint(theme, "accent", spinner(tick))}`);
87
- else if (busy) push("oracle", paint(theme, "dim", `${spinner(tick)} working…`));
102
+ if (live?.trim()) {
103
+ push("oracle", live.trim());
104
+ lines[lines.length - 1] = `${lines.at(-1)!.trimEnd()} ${paint(theme, "accent", spinner(tick))}`;
105
+ } else if (busy) {
106
+ if (lines.length > 0) lines.push("");
107
+ lines.push(...wrapHanging(chatLead("oracle", theme), paint(theme, "dim", `${spinner(tick)} working…`), width));
108
+ }
88
109
  return lines;
89
110
  }
90
111
 
91
112
  function emptyChat(input: HomeInput, width: number, theme?: LobbyTheme): string[] {
113
+ if (input.query) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${input.query}".`), width);
92
114
  const lines = input.task
93
115
  ? [paint(theme, "dim", "Nothing said yet. Type below to talk to the oracle about this task.")]
94
116
  : [
@@ -103,16 +125,38 @@ function emptyChat(input: HomeInput, width: number, theme?: LobbyTheme): string[
103
125
  return [...lines.flatMap((line) => wrap(line, width)), ...(extra.length > 0 ? ["", ...extra.flatMap((line) => wrap(paint(theme, "muted", line), width))] : [])];
104
126
  }
105
127
 
106
- function conversation(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
107
- const all = input.chat.length > 0 || input.liveReply || input.busy ? chatLines(input.chat, width, theme, input.liveReply, input.busy, input.tick) : emptyChat(input, width, theme);
108
- const shown = tail(all, height, input.chatOffset);
109
- return fill(shown, height);
128
+ function matches(text: string, query: string | undefined): boolean {
129
+ return !query || text.toLowerCase().includes(query.toLowerCase());
130
+ }
131
+
132
+ /** The feed as the search narrows it: only entries whose text (or source) contains the query. */
133
+ export function filterFeed(input: Pick<HomeInput, "chat" | "activity" | "thoughts" | "query">): { chat: ChatEntry[]; activity: ActivityEntry[]; thoughts: ThoughtEntry[] } {
134
+ const query = input.query?.trim();
135
+ return {
136
+ chat: input.chat.filter((entry) => matches(entry.text, query)),
137
+ activity: input.activity.filter((entry) => matches(`${entry.source} ${entry.text}`, query)),
138
+ thoughts: input.thoughts.filter((entry) => matches(`${entry.source} ${entry.text}`, query)),
139
+ };
140
+ }
141
+
142
+ function conversation(input: HomeInput, chat: readonly ChatEntry[], width: number, theme?: LobbyTheme): string[] {
143
+ const searching = Boolean(input.query);
144
+ const live = searching ? undefined : input.liveReply;
145
+ const busy = !searching && input.busy;
146
+ return chat.length > 0 || live || busy ? chatLines(chat, width, theme, live, busy, input.tick) : emptyChat(input, width, theme);
147
+ }
148
+
149
+ function activity(input: HomeInput, entries: readonly ActivityEntry[], width: number, theme?: LobbyTheme): string[] {
150
+ if (entries.length === 0) return [paint(theme, "dim", input.query ? `No activity matches "${input.query}".` : "No activity yet.")];
151
+ return entries.map((entry) => activityLine(entry, width, input.tick, theme));
110
152
  }
111
153
 
112
- function activity(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
113
- if (input.activity.length === 0) return fill([paint(theme, "dim", "No activity yet.")], height);
114
- const lines = input.activity.slice(-Math.max(height, 1)).map((entry) => activityLine(entry, width, input.tick, theme));
115
- return fill(tail(lines, height), height);
154
+ /** The last `rows` lines, `offset` lines back from the newest (clamped), and where they start. */
155
+ export function tailWindow(lines: readonly string[], rows: number, offset = 0): { shown: string[]; start: number; offset: number } {
156
+ const max = Math.max(0, lines.length - rows);
157
+ const back = Math.min(Math.max(0, offset), max);
158
+ const start = max - back;
159
+ return { shown: lines.slice(start, start + Math.max(0, rows)), start, offset: back };
116
160
  }
117
161
 
118
162
  /** The thought to show: a live one first, otherwise the newest. */
@@ -120,45 +164,79 @@ export function currentThought(thoughts: readonly ThoughtEntry[]): ThoughtEntry
120
164
  return [...thoughts].reverse().find((entry) => entry.live) ?? thoughts.at(-1);
121
165
  }
122
166
 
123
- function thinking(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
124
- const thought = currentThought(input.thoughts);
125
- const right = thought ? `${thought.source} · ${thought.live ? "thinking" : since(input.now - thought.at)}` : "";
126
- const header = rule(width, "Thinking", theme, right);
127
- if (height <= 1) return [header].slice(0, height);
128
- if (!thought) return fill([header, paint(theme, "dim", "Thoughts from the oracle and every agent appear here, and only here.")], height);
129
- const body = wrap(thought.text, width).map((line) => italic(theme, paint(theme, "dim", line)));
130
- return fill([header, ...tail(body, height - 1)], height);
167
+ /** Every thought, oldest first: who thought it, then the thought, dimmed. */
168
+ export function thoughtLines(thoughts: readonly ThoughtEntry[], width: number, theme?: LobbyTheme): string[] {
169
+ return thoughts.flatMap((thought) => {
170
+ const lead = `${paint(theme, sourceColor(thought.source), thought.source.padEnd(SOURCE_WIDTH).slice(0, SOURCE_WIDTH))} `;
171
+ return wrapHanging(lead, italic(theme, paint(theme, "dim", thought.text.replace(/\s*\n\s*/g, " "))), width);
172
+ });
173
+ }
174
+
175
+ function thinkingContent(input: HomeInput, thoughts: readonly ThoughtEntry[], width: number, theme?: LobbyTheme): string[] {
176
+ if (thoughts.length > 0) return thoughtLines(thoughts, width, theme);
177
+ return [paint(theme, "dim", input.query ? `No thought matches "${input.query}".` : "Thoughts from the oracle and every agent appear here, and only here.")];
131
178
  }
132
179
 
133
180
  function sceneLines(input: HomeInput, width: number, height: number): string[] {
134
- if (!input.task || !input.scene) return [];
181
+ if (!input.task || !input.scene || !input.panels.scene) return [];
135
182
  const budget = Math.min(MAX_SCENE, Math.floor(height * SCENE_SHARE));
136
183
  if (budget < 6) return [];
137
184
  return input.scene(width, budget).slice(0, budget);
138
185
  }
139
186
 
140
- /** The whole tab, exactly `height` lines of `width` columns. */
187
+ function hiddenHint(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
188
+ const keys = input.keys;
189
+ const text = keys
190
+ ? `Every pane is hidden — ${keys.conversation} conversation · ${keys.activity} activity · ${keys.thinking} thinking${input.task ? ` · ${keys.scene} scene` : ""}`
191
+ : "Every pane is hidden.";
192
+ const lines = wrap(paint(theme, "dim", text), width);
193
+ const top = Math.max(0, Math.floor((height - lines.length) / 2));
194
+ return fill([...Array.from({ length: top }, () => ""), ...lines], height, width);
195
+ }
196
+
197
+ /** `↓12` when a pane is scrolled back from its newest line, before the pane's usual note. */
198
+ function rightNote(offset: number, note: string | undefined): string | undefined {
199
+ const parts = [offset > 0 ? `↓${offset}` : "", note ?? ""].filter(Boolean);
200
+ return parts.length > 0 ? parts.join(" · ") : undefined;
201
+ }
202
+
203
+ /** The whole tab, exactly `height` lines of `width` columns; hidden panes give their room to the rest. */
141
204
  export function renderHome(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
142
205
  if (height <= 0) return [];
206
+ const { panels } = input;
207
+ const feed = filterFeed(input);
143
208
  const scene = sceneLines(input, width, height);
144
209
  const rest = height - scene.length;
145
- const thinkHeight = rest >= 16 ? Math.max(4, Math.floor(rest * 0.25)) : rest >= 9 ? 3 : 0;
210
+ const showMain = panels.conversation || panels.activity;
211
+ const thinkHeight = !panels.thinking ? 0 : !showMain ? rest : rest >= 18 ? Math.max(5, Math.floor(rest * 0.25)) : rest >= 10 ? 4 : 0;
146
212
  const main = rest - thinkHeight;
147
- const chatTitle = input.task ? `Conversation · ${input.task.id}` : "Conversation";
148
- let body: string[];
149
- if (width >= HOME_COLUMNS_MIN) {
150
- const [left, right] = split(width, 0.56);
151
- const headers = columns([rule(left, chatTitle, theme)], [rule(right, "Activity", theme)], left, right, " ┬ ", theme);
152
- const rows = columns(conversation(input, left, main - 1, theme), activity(input, right, main - 1, theme), left, right, " │ ", theme);
153
- body = [...headers, ...rows];
154
- } else {
155
- const chatHeight = Math.max(1, Math.ceil(main * 0.6));
156
- const activityHeight = main - chatHeight;
157
- body = [
158
- rule(width, chatTitle, theme),
159
- ...conversation(input, width, chatHeight - 1, theme),
160
- ...(activityHeight > 0 ? [rule(width, "Activity", theme), ...activity(input, width, activityHeight - 1, theme)] : []),
161
- ];
162
- }
163
- return fill([...scene, ...fill(body, main), ...thinking(input, width, thinkHeight, theme)], height, width);
213
+ if (!showMain && !panels.thinking) return fill([...scene, ...hiddenHint(input, width, rest, theme)], height, width);
214
+ const matches = (count: number) => `${count} match${count === 1 ? "" : "es"}`;
215
+ /** One scrollable pane: its lines, tailed to its rows and scrolled back by its offset, in a box that records where it sits. */
216
+ const pane = (name: HomePane, title: string, note: string | undefined, lines: (inner: number) => string[]) => (top: number, left: number, w: number, h: number): string[] => {
217
+ const all = lines(Math.max(1, w - 4));
218
+ const view = tailWindow(all, h - 2, input.offsets?.[name] ?? 0);
219
+ notePane(input.panes, name, top, left, w, h, all.length);
220
+ const right = rightNote(view.offset, note);
221
+ return box(w, h, view.shown, { title, ...(right ? { right } : {}), focused: input.focus === name, scroll: { total: all.length, start: view.start }, theme });
222
+ };
223
+ const chatBox = pane("conversation", input.task ? `Conversation · ${input.task.id}` : "Conversation", input.query ? matches(feed.chat.length) : input.keys?.conversation, (inner) => conversation(input, feed.chat, inner, theme));
224
+ const activityBox = pane("activity", "Activity", input.query ? matches(feed.activity.length) : input.keys?.activity, (inner) => activity(input, feed.activity, inner, theme));
225
+ const thought = currentThought(feed.thoughts);
226
+ const thinkingNote = thought ? `${thought.source} · ${thought.live ? "thinking" : since(input.now - thought.at)}` : input.keys?.thinking;
227
+ const thinkingBox = pane("thinking", "Thinking", thinkingNote, (inner) => thinkingContent(input, feed.thoughts, inner, theme));
228
+ const top = scene.length;
229
+ let body: string[] = [];
230
+ if (main > 0 && panels.conversation && panels.activity) {
231
+ if (width >= HOME_COLUMNS_MIN) {
232
+ const left = Math.round((width - 1) * 0.56);
233
+ body = beside([chatBox(top, 0, left, main), activityBox(top, left + 1, width - 1 - left, main)]);
234
+ } else {
235
+ const chatHeight = Math.max(3, Math.ceil(main * 0.6));
236
+ body = [...chatBox(top, 0, width, chatHeight), ...(main - chatHeight >= 3 ? activityBox(top + chatHeight, 0, width, main - chatHeight) : [])];
237
+ }
238
+ } else if (main > 0 && panels.conversation) body = chatBox(top, 0, width, main);
239
+ else if (main > 0 && panels.activity) body = activityBox(top, 0, width, main);
240
+ const think = thinkHeight >= 3 ? thinkingBox(top + main, 0, width, thinkHeight) : [];
241
+ return fill([...scene, ...fill(body, main), ...think], height, width);
164
242
  }