@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.
- package/README.md +146 -51
- package/package.json +5 -2
- package/prompts/panel.md +8 -3
- package/prompts/planner.md +9 -4
- package/src/lobby/ask.ts +226 -0
- package/src/lobby/keys.ts +51 -0
- package/src/lobby/layout.ts +192 -3
- package/src/lobby/markdown.ts +48 -0
- package/src/lobby/planner.ts +121 -14
- package/src/lobby/runtime.ts +158 -7
- package/src/lobby/tabs/home.ts +121 -43
- package/src/lobby/tabs/metrics.ts +155 -42
- package/src/lobby/tabs/plan.ts +117 -35
- package/src/lobby/tabs/quickfix.ts +40 -12
- package/src/lobby/tabs/tasks.ts +46 -12
- package/src/lobby/view.ts +868 -113
- package/src/pi/commands.ts +1 -1
- package/src/pi/settings-ui.ts +65 -1
- package/src/schemas/configuration.ts +38 -4
package/src/lobby/runtime.ts
CHANGED
|
@@ -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
|
|
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)
|
|
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),
|
package/src/lobby/tabs/home.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
26
|
-
|
|
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
|
-
|
|
83
|
-
lines.push(...
|
|
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())
|
|
87
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
}
|