@a-t-h-i/bot-lobby 0.6.2 → 0.6.3
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 +10 -0
- package/package.json +1 -1
- package/src/index.ts +3 -0
- package/src/lobby/feed.ts +76 -8
- package/src/lobby/layout.ts +14 -13
- package/src/lobby/markdown.ts +61 -18
- package/src/lobby/runtime.ts +45 -9
- package/src/lobby/session-files.ts +162 -25
- package/src/lobby/sessions.ts +21 -7
- package/src/lobby/tabs/home.ts +219 -61
- package/src/lobby/view.ts +114 -20
- package/src/pi/events.ts +7 -2
- package/src/pi/fresh-context.ts +134 -0
- package/src/pi/owner.ts +19 -10
- package/src/pi/start-task.ts +3 -0
- package/src/pi/ui.ts +3 -2
- package/src/pi/zen-large.ts +10 -10
- package/src/pi/zen.ts +6 -6
- package/src/schemas/configuration.ts +4 -0
- package/src/state/archive.ts +12 -3
- package/src/state/backlog.ts +13 -3
- package/src/state/file-cache.ts +62 -0
- package/src/state/metrics.ts +63 -13
- package/src/state/persistence.ts +36 -2
- package/src/width.ts +102 -0
package/src/lobby/tabs/home.ts
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* activity log of plain-words steps from every agent, and the one place where
|
|
6
6
|
* thoughts show up. Pure: the scene arrives as a callback, the clock as `now`.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { textWidth } from "../../width.ts";
|
|
9
9
|
import type { Task } from "../../schemas/task.ts";
|
|
10
10
|
import type { ActivityEntry, ChatEntry, ThoughtEntry } from "../feed.ts";
|
|
11
11
|
import type { LobbyPanel } from "../../schemas/configuration.ts";
|
|
12
|
+
import { markdownBlocks } from "../markdown.ts";
|
|
12
13
|
import { beside, bold, box, clock, fill, fit, italic, markdownLines, notePane, paint, since, spinner, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
|
|
13
14
|
|
|
14
15
|
/** The Lobby tab's scrollable panes. */
|
|
@@ -51,6 +52,8 @@ export interface HomeInput {
|
|
|
51
52
|
emptyNote?: string;
|
|
52
53
|
/** What the empty activity log says instead of "No activity yet.". */
|
|
53
54
|
activityNote?: string;
|
|
55
|
+
/** Tops the conversation when earlier messages are not loaded (scrolling up loads them). */
|
|
56
|
+
olderNote?: string;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/** Wide terminals put the conversation and the activity log side by side. */
|
|
@@ -132,10 +135,10 @@ export function speakerLine(speaker: Speaker, width: number, theme?: LobbyTheme,
|
|
|
132
135
|
const dimmed = note ? paint(theme, "dim", note) : "";
|
|
133
136
|
if (side === "right") {
|
|
134
137
|
const head = dimmed ? `${dimmed} ${label} ${who}` : `${label} ${who}`;
|
|
135
|
-
return `${" ".repeat(Math.max(0, width -
|
|
138
|
+
return `${" ".repeat(Math.max(0, width - textWidth(head)))}${head}`;
|
|
136
139
|
}
|
|
137
140
|
const left = `${who} ${label}`;
|
|
138
|
-
const gap = width -
|
|
141
|
+
const gap = width - textWidth(left) - textWidth(dimmed);
|
|
139
142
|
return gap >= 1 && dimmed ? `${left}${" ".repeat(gap)}${dimmed}` : left;
|
|
140
143
|
}
|
|
141
144
|
|
|
@@ -148,7 +151,7 @@ export function speakerLine(speaker: Speaker, width: number, theme?: LobbyTheme,
|
|
|
148
151
|
export function youLines(text: string, width: number, theme?: LobbyTheme): string[] {
|
|
149
152
|
const most = Math.max(1, messageWidth(width, YOU_SHARE, 4) - 2);
|
|
150
153
|
const lines = wrap(text, most);
|
|
151
|
-
const inner = Math.max(1, ...lines.map((line) =>
|
|
154
|
+
const inner = Math.max(1, ...lines.map((line) => textWidth(line)));
|
|
152
155
|
const pad = " ".repeat(Math.max(0, width - inner - 2));
|
|
153
156
|
const bubble = theme?.bg
|
|
154
157
|
? (line: string) => theme.bg!("userMessageBg", ` ${fit(paint(theme, "accent", line), inner)} `)
|
|
@@ -157,55 +160,145 @@ export function youLines(text: string, width: number, theme?: LobbyTheme): strin
|
|
|
157
160
|
}
|
|
158
161
|
|
|
159
162
|
/** The oracle's reply as Markdown on the left, under its name. */
|
|
160
|
-
function oracleLines(text: string, width: number, theme?: LobbyTheme): string[] {
|
|
163
|
+
function oracleLines(text: string, width: number, theme?: LobbyTheme, keep = true): string[] {
|
|
161
164
|
const indent = " ".repeat(BODY_INDENT);
|
|
162
|
-
return markdownLines(text, Math.max(1, messageWidth(width, ORACLE_SHARE, 0) - BODY_INDENT), theme).map((line) => (line ? `${indent}${line}` : ""));
|
|
165
|
+
return markdownLines(text, Math.max(1, messageWidth(width, ORACLE_SHARE, 0) - BODY_INDENT), theme, keep).map((line) => (line ? `${indent}${line}` : ""));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** An open block this long is drawn again at most every `BIG_BLOCK_MS` while it grows (a long code block, say). */
|
|
169
|
+
const BIG_BLOCK = 1500;
|
|
170
|
+
const BIG_BLOCK_MS = 100;
|
|
171
|
+
let lastOpen: { text: string; width: number; theme: object; lines: string[]; at: number } | undefined;
|
|
172
|
+
|
|
173
|
+
/** The block the oracle is writing: drawn afresh (and not cached), except that a big one is redrawn only a few times a second. */
|
|
174
|
+
function openBlockLines(text: string, width: number, theme: LobbyTheme | undefined): string[] {
|
|
175
|
+
const now = performance.now();
|
|
176
|
+
const key = theme ?? PLAIN;
|
|
177
|
+
if (text.length > BIG_BLOCK && lastOpen && lastOpen.width === width && lastOpen.theme === key && now - lastOpen.at < BIG_BLOCK_MS && text.startsWith(lastOpen.text)) return lastOpen.lines;
|
|
178
|
+
const lines = oracleLines(text, width, theme, false);
|
|
179
|
+
lastOpen = text.length > BIG_BLOCK ? { text, width, theme: key, lines, at: now } : undefined;
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The newest `need` lines (or a few more) of the reply the oracle is still
|
|
185
|
+
* writing. It is drawn block by block (see `markdownBlocks`): finished blocks
|
|
186
|
+
* come from the Markdown cache, only the one being written is rendered on
|
|
187
|
+
* each frame, and blocks above what the pane shows are not drawn at all.
|
|
188
|
+
*/
|
|
189
|
+
function liveLines(text: string, width: number, theme: LobbyTheme | undefined, need: number): string[] {
|
|
190
|
+
const blocks = markdownBlocks(text);
|
|
191
|
+
const parts: string[][] = [];
|
|
192
|
+
let count = 0;
|
|
193
|
+
for (let index = blocks.length - 1; index >= 0 && count < need; index -= 1) {
|
|
194
|
+
const lines = index === blocks.length - 1 ? openBlockLines(blocks[index]!, width, theme) : oracleLines(blocks[index]!, width, theme);
|
|
195
|
+
if (lines.length === 0) continue;
|
|
196
|
+
parts.push(lines);
|
|
197
|
+
count += lines.length + 1;
|
|
198
|
+
}
|
|
199
|
+
const lines: string[] = [];
|
|
200
|
+
for (let part = parts.length - 1; part >= 0; part -= 1) {
|
|
201
|
+
if (lines.length > 0) lines.push("");
|
|
202
|
+
for (const line of parts[part]!) lines.push(line);
|
|
203
|
+
}
|
|
204
|
+
return lines;
|
|
163
205
|
}
|
|
164
206
|
|
|
165
207
|
/** An event in the conversation (a task starting, a comment sent) as a centred rule; failures stand out instead. */
|
|
166
208
|
export function eventLines(text: string, at: number, width: number, theme?: LobbyTheme): string[] {
|
|
167
209
|
if (text.startsWith("✗")) return wrap(paint(theme, "error", text), width);
|
|
168
210
|
const label = ` ${text}${at > 0 ? ` · ${clock(at)}` : ""} `;
|
|
169
|
-
const room = width -
|
|
211
|
+
const room = width - textWidth(label);
|
|
170
212
|
if (room < 6) return wrap(paint(theme, "dim", text), width);
|
|
171
213
|
const left = Math.floor(room / 2);
|
|
172
214
|
return [paint(theme, "dim", `${"─".repeat(left)}${label}${"─".repeat(room - left)}`)];
|
|
173
215
|
}
|
|
174
216
|
|
|
217
|
+
/** The newest lines of a pane, and how many it holds in all (estimated when only the newest were drawn). */
|
|
218
|
+
export interface PaneLines {
|
|
219
|
+
lines: string[];
|
|
220
|
+
total: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A message's drawn lines, kept until the pane's width, the theme or its grouping changes. */
|
|
224
|
+
interface Block {
|
|
225
|
+
width: number;
|
|
226
|
+
grouped: boolean;
|
|
227
|
+
lines: string[];
|
|
228
|
+
}
|
|
229
|
+
|
|
175
230
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* oracle works its header says so (streaming the reply under it).
|
|
231
|
+
* Drawn messages, per theme and per message: a message is drawn once (its
|
|
232
|
+
* Markdown is the costly part) and reused on every later frame. Messages
|
|
233
|
+
* that leave the feed are forgotten with it.
|
|
180
234
|
*/
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (!grouped) lines.push(speakerLine(entry.role, width, theme, entry.at > 0 ? clock(entry.at) : ""));
|
|
196
|
-
lines.push(...(entry.role === "you" ? youLines(entry.text, width, theme) : oracleLines(entry.text, width, theme)));
|
|
197
|
-
last = entry;
|
|
198
|
-
}
|
|
199
|
-
if (live?.trim()) {
|
|
200
|
-
gap();
|
|
201
|
-
lines.push(speakerLine("oracle", width, theme, `${spinner(tick)} writing`), ...oracleLines(live.trim(), width, theme));
|
|
202
|
-
} else if (busy) {
|
|
203
|
-
gap();
|
|
204
|
-
lines.push(speakerLine("oracle", width, theme, `${spinner(tick)} working…`));
|
|
235
|
+
const blocks = new WeakMap<object, WeakMap<ChatEntry, Block>>();
|
|
236
|
+
const PLAIN = {};
|
|
237
|
+
|
|
238
|
+
/** Whether `entry` shares the speaker line of the message before it (same speaker, within minutes). */
|
|
239
|
+
function isGrouped(entry: ChatEntry, before: ChatEntry | undefined): boolean {
|
|
240
|
+
return entry.role !== "note" && before !== undefined && before.role === entry.role && entry.at >= before.at && entry.at - before.at < GROUP_MS;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function entryBlock(entry: ChatEntry, before: ChatEntry | undefined, width: number, theme?: LobbyTheme): string[] {
|
|
244
|
+
const grouped = isGrouped(entry, before);
|
|
245
|
+
let cache = blocks.get(theme ?? PLAIN);
|
|
246
|
+
if (!cache) {
|
|
247
|
+
cache = new WeakMap();
|
|
248
|
+
blocks.set(theme ?? PLAIN, cache);
|
|
205
249
|
}
|
|
250
|
+
const hit = cache.get(entry);
|
|
251
|
+
if (hit && hit.width === width && hit.grouped === grouped) return hit.lines;
|
|
252
|
+
const lines = entry.role === "note"
|
|
253
|
+
? eventLines(entry.text, entry.at, width, theme)
|
|
254
|
+
: [
|
|
255
|
+
...(grouped ? [] : [speakerLine(entry.role, width, theme, entry.at > 0 ? clock(entry.at) : "")]),
|
|
256
|
+
...(entry.role === "you" ? youLines(entry.text, width, theme) : oracleLines(entry.text, width, theme)),
|
|
257
|
+
];
|
|
258
|
+
cache.set(entry, { width, grouped, lines });
|
|
206
259
|
return lines;
|
|
207
260
|
}
|
|
208
261
|
|
|
262
|
+
/**
|
|
263
|
+
* The newest `need` lines of the conversation, like a chat: your messages as
|
|
264
|
+
* bubbles on the right, the oracle's replies in Markdown on the left, each
|
|
265
|
+
* turn under a line naming its speaker and time, events as centred rules,
|
|
266
|
+
* and while the oracle works its header says so (streaming the reply under
|
|
267
|
+
* it). Only the messages those lines reach are drawn — scrolling back draws
|
|
268
|
+
* more — and each is drawn once; the total is estimated from them until the
|
|
269
|
+
* whole conversation has been drawn. `older` tops a conversation that has
|
|
270
|
+
* earlier messages still to load.
|
|
271
|
+
*/
|
|
272
|
+
export function chatTail(chat: readonly ChatEntry[], width: number, theme: LobbyTheme | undefined, need: number, live?: string, busy = false, tick = 0, older?: string): PaneLines {
|
|
273
|
+
const parts: string[][] = [];
|
|
274
|
+
let count = 0;
|
|
275
|
+
const add = (lines: string[]) => {
|
|
276
|
+
if (lines.length === 0) return;
|
|
277
|
+
count += lines.length + (parts.length > 0 ? 1 : 0);
|
|
278
|
+
parts.push(lines);
|
|
279
|
+
};
|
|
280
|
+
if (live?.trim()) add([speakerLine("oracle", width, theme, `${spinner(tick)} writing`), ...liveLines(live.trim(), width, theme, need)]);
|
|
281
|
+
else if (busy) add([speakerLine("oracle", width, theme, `${spinner(tick)} working…`)]);
|
|
282
|
+
let index = chat.length - 1;
|
|
283
|
+
for (; index >= 0 && count < need; index -= 1) add(entryBlock(chat[index]!, chat[index - 1], width, theme));
|
|
284
|
+
const drawn = chat.length - 1 - index;
|
|
285
|
+
if (index < 0 && older) add(wrap(paint(theme, "dim", older), width));
|
|
286
|
+
const lines: string[] = [];
|
|
287
|
+
for (let part = parts.length - 1; part >= 0; part -= 1) {
|
|
288
|
+
if (lines.length > 0) lines.push("");
|
|
289
|
+
for (const line of parts[part]!) lines.push(line);
|
|
290
|
+
}
|
|
291
|
+
// Messages not drawn yet count at the average height of those that were.
|
|
292
|
+
const rest = index + 1;
|
|
293
|
+
const total = rest > 0 && drawn > 0 ? lines.length + Math.ceil((rest * lines.length) / drawn) : lines.length;
|
|
294
|
+
return { lines, total };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The whole conversation drawn (the session browser's preview and tests; the Lobby tab draws only what shows). */
|
|
298
|
+
export function chatLines(chat: readonly ChatEntry[], width: number, theme?: LobbyTheme, live?: string, busy = false, tick = 0): string[] {
|
|
299
|
+
return chatTail(chat, width, theme, Number.POSITIVE_INFINITY, live, busy, tick).lines;
|
|
300
|
+
}
|
|
301
|
+
|
|
209
302
|
function emptyChat(input: HomeInput, width: number, theme?: LobbyTheme): string[] {
|
|
210
303
|
if (input.query) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${input.query}".`), width);
|
|
211
304
|
if (input.emptyNote) return wrap(paint(theme, "dim", input.emptyNote), width);
|
|
@@ -237,24 +330,43 @@ export function filterFeed(input: Pick<HomeInput, "chat" | "activity" | "thought
|
|
|
237
330
|
};
|
|
238
331
|
}
|
|
239
332
|
|
|
240
|
-
function
|
|
333
|
+
function whole(lines: string[]): PaneLines {
|
|
334
|
+
return { lines, total: lines.length };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function conversation(input: HomeInput, chat: readonly ChatEntry[], width: number, need: number, theme?: LobbyTheme): PaneLines {
|
|
241
338
|
const searching = Boolean(input.query);
|
|
242
339
|
const live = searching ? undefined : input.liveReply;
|
|
243
340
|
const busy = !searching && input.busy;
|
|
244
|
-
|
|
341
|
+
if (chat.length === 0 && !live && !busy) return whole(emptyChat(input, width, theme));
|
|
342
|
+
return chatTail(chat, width, theme, need, live, busy, input.tick, searching ? undefined : input.olderNote);
|
|
245
343
|
}
|
|
246
344
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
345
|
+
/** The newest `need` activity lines (one per entry), drawn only as far back as the pane shows. */
|
|
346
|
+
function activity(input: HomeInput, entries: readonly ActivityEntry[], width: number, need: number, theme?: LobbyTheme): PaneLines {
|
|
347
|
+
if (entries.length === 0) return whole(wrap(paint(theme, "dim", input.query ? `No activity matches "${input.query}".` : input.activityNote ?? "No activity yet."), width));
|
|
348
|
+
const shown = Number.isFinite(need) ? entries.slice(-Math.max(0, need)) : entries;
|
|
349
|
+
return { lines: shown.map((entry) => activityLine(entry, width, input.tick, theme)), total: entries.length };
|
|
250
350
|
}
|
|
251
351
|
|
|
252
352
|
/** The last `rows` lines, `offset` lines back from the newest (clamped), and where they start. */
|
|
253
353
|
export function tailWindow(lines: readonly string[], rows: number, offset = 0): { shown: string[]; start: number; offset: number } {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
354
|
+
return paneWindow({ lines: [...lines], total: lines.length }, rows, offset);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* `rows` lines of a pane `offset` lines back from its newest (clamped), when
|
|
359
|
+
* only its newest lines were drawn: where they start in the whole pane, for
|
|
360
|
+
* the scrollbar.
|
|
361
|
+
*/
|
|
362
|
+
export function paneWindow(content: PaneLines, rows: number, offset = 0): { shown: string[]; start: number; offset: number } {
|
|
363
|
+
const { lines } = content;
|
|
364
|
+
const total = Math.max(content.total, lines.length);
|
|
365
|
+
const height = Math.max(0, rows);
|
|
366
|
+
const back = Math.min(Math.max(0, offset), Math.max(0, total - height));
|
|
367
|
+
const end = Math.max(0, lines.length - back);
|
|
368
|
+
const begin = Math.max(0, end - height);
|
|
369
|
+
return { shown: lines.slice(begin, end), start: Math.max(0, total - back - height), offset: back };
|
|
258
370
|
}
|
|
259
371
|
|
|
260
372
|
/** The thought to show: a live one first, otherwise the newest. */
|
|
@@ -262,17 +374,57 @@ export function currentThought(thoughts: readonly ThoughtEntry[]): ThoughtEntry
|
|
|
262
374
|
return [...thoughts].reverse().find((entry) => entry.live) ?? thoughts.at(-1);
|
|
263
375
|
}
|
|
264
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Drawn thoughts, per theme and per thought, kept while the thought's text
|
|
379
|
+
* and the width stay the same (a live thought grows, so it is drawn again as
|
|
380
|
+
* it streams; settled ones are wrapped once).
|
|
381
|
+
*/
|
|
382
|
+
const thoughtBlocks = new WeakMap<object, WeakMap<ThoughtEntry, { width: number; text: string; lines: string[]; at: number }>>();
|
|
383
|
+
|
|
384
|
+
/** A live thought this long is wrapped again at most every `LONG_THOUGHT_MS` while it streams. */
|
|
385
|
+
const LONG_THOUGHT = 1000;
|
|
386
|
+
const LONG_THOUGHT_MS = 150;
|
|
387
|
+
|
|
388
|
+
function thoughtBlock(thought: ThoughtEntry, width: number, theme?: LobbyTheme): string[] {
|
|
389
|
+
let cache = thoughtBlocks.get(theme ?? PLAIN);
|
|
390
|
+
if (!cache) {
|
|
391
|
+
cache = new WeakMap();
|
|
392
|
+
thoughtBlocks.set(theme ?? PLAIN, cache);
|
|
393
|
+
}
|
|
394
|
+
const hit = cache.get(thought);
|
|
395
|
+
const now = performance.now();
|
|
396
|
+
if (hit && hit.width === width && (hit.text === thought.text || (thought.live && thought.text.length > LONG_THOUGHT && now - hit.at < LONG_THOUGHT_MS))) return hit.lines;
|
|
397
|
+
const lead = `${paint(theme, sourceColor(thought.source), thought.source.padEnd(SOURCE_WIDTH).slice(0, SOURCE_WIDTH))} `;
|
|
398
|
+
const lines = wrapHanging(lead, italic(theme, paint(theme, "dim", thought.text.replace(/\s*\n\s*/g, " "))), width);
|
|
399
|
+
cache.set(thought, { width, text: thought.text, lines, at: now });
|
|
400
|
+
return lines;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The newest `need` lines of the thoughts, oldest first: only the thoughts they reach are drawn, and the rest estimated. */
|
|
404
|
+
export function thoughtTail(thoughts: readonly ThoughtEntry[], width: number, theme: LobbyTheme | undefined, need: number): PaneLines {
|
|
405
|
+
const parts: string[][] = [];
|
|
406
|
+
let count = 0;
|
|
407
|
+
let index = thoughts.length - 1;
|
|
408
|
+
for (; index >= 0 && count < need; index -= 1) {
|
|
409
|
+
const lines = thoughtBlock(thoughts[index]!, width, theme);
|
|
410
|
+
parts.push(lines);
|
|
411
|
+
count += lines.length;
|
|
412
|
+
}
|
|
413
|
+
const lines: string[] = [];
|
|
414
|
+
for (let part = parts.length - 1; part >= 0; part -= 1) for (const line of parts[part]!) lines.push(line);
|
|
415
|
+
const rest = index + 1;
|
|
416
|
+
const drawn = parts.length;
|
|
417
|
+
return { lines, total: rest > 0 && drawn > 0 ? lines.length + Math.ceil((rest * lines.length) / drawn) : lines.length };
|
|
418
|
+
}
|
|
419
|
+
|
|
265
420
|
/** Every thought, oldest first: who thought it, then the thought, dimmed. */
|
|
266
421
|
export function thoughtLines(thoughts: readonly ThoughtEntry[], width: number, theme?: LobbyTheme): string[] {
|
|
267
|
-
return thoughts.
|
|
268
|
-
const lead = `${paint(theme, sourceColor(thought.source), thought.source.padEnd(SOURCE_WIDTH).slice(0, SOURCE_WIDTH))} `;
|
|
269
|
-
return wrapHanging(lead, italic(theme, paint(theme, "dim", thought.text.replace(/\s*\n\s*/g, " "))), width);
|
|
270
|
-
});
|
|
422
|
+
return thoughtTail(thoughts, width, theme, Number.POSITIVE_INFINITY).lines;
|
|
271
423
|
}
|
|
272
424
|
|
|
273
|
-
function thinkingContent(input: HomeInput, thoughts: readonly ThoughtEntry[], width: number, theme?: LobbyTheme):
|
|
274
|
-
if (thoughts.length > 0) return
|
|
275
|
-
return [paint(theme, "dim", input.query ? `No thought matches "${input.query}".` : "Thoughts from the oracle and every agent appear here, and only here.")];
|
|
425
|
+
function thinkingContent(input: HomeInput, thoughts: readonly ThoughtEntry[], width: number, need: number, theme?: LobbyTheme): PaneLines {
|
|
426
|
+
if (thoughts.length > 0) return thoughtTail(thoughts, width, theme, need);
|
|
427
|
+
return whole([paint(theme, "dim", input.query ? `No thought matches "${input.query}".` : "Thoughts from the oracle and every agent appear here, and only here.")]);
|
|
276
428
|
}
|
|
277
429
|
|
|
278
430
|
/**
|
|
@@ -295,7 +447,7 @@ function sceneLines(input: HomeInput, width: number, height: number, theme?: Lob
|
|
|
295
447
|
function keyNote(lines: readonly string[], width: number, note: string | undefined, theme?: LobbyTheme): string[] {
|
|
296
448
|
const first = lines[0];
|
|
297
449
|
if (!note || first === undefined) return [...lines];
|
|
298
|
-
const gap = width -
|
|
450
|
+
const gap = width - textWidth(first) - textWidth(note) - 1;
|
|
299
451
|
if (gap < 2) return [...lines];
|
|
300
452
|
return [`${first}${" ".repeat(gap)}${paint(theme, "dim", note)}`, ...lines.slice(1)];
|
|
301
453
|
}
|
|
@@ -328,19 +480,25 @@ export function renderHome(input: HomeInput, width: number, height: number, them
|
|
|
328
480
|
const main = rest - thinkHeight;
|
|
329
481
|
if (!showMain && !panels.thinking) return fill([...scene, ...hiddenHint(input, width, rest, theme)], height, width);
|
|
330
482
|
const matches = (count: number) => `${count} match${count === 1 ? "" : "es"}`;
|
|
331
|
-
/**
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
483
|
+
/**
|
|
484
|
+
* One scrollable pane: only the lines it shows are drawn — its newest, or
|
|
485
|
+
* as far back as it is scrolled — in a box that records where it sits.
|
|
486
|
+
*/
|
|
487
|
+
const pane = (name: HomePane, title: string, note: string | undefined, content: (inner: number, need: number) => PaneLines) => (top: number, left: number, w: number, h: number): string[] => {
|
|
488
|
+
const rows = Math.max(0, h - 2);
|
|
489
|
+
const offset = input.offsets?.[name] ?? 0;
|
|
490
|
+
const drawn = content(Math.max(1, w - 4), rows + offset);
|
|
491
|
+
const view = paneWindow(drawn, rows, offset);
|
|
492
|
+
const total = Math.max(drawn.total, drawn.lines.length);
|
|
493
|
+
notePane(input.panes, name, top, left, w, h, total);
|
|
336
494
|
const right = rightNote(view.offset, note);
|
|
337
|
-
return box(w, h, view.shown, { title, ...(right ? { right } : {}), focused: input.focus === name, scroll: { total
|
|
495
|
+
return box(w, h, view.shown, { title, ...(right ? { right } : {}), focused: input.focus === name, scroll: { total, start: view.start }, theme });
|
|
338
496
|
};
|
|
339
|
-
const chatBox = pane("conversation", input.title ? `Conversation · ${input.title}` : "Conversation", input.query ? matches(feed.chat.length) : input.keys?.conversation, (inner) => conversation(input, feed.chat, inner, theme));
|
|
340
|
-
const activityBox = pane("activity", "Activity", input.query ? matches(feed.activity.length) : input.keys?.activity, (inner) => activity(input, feed.activity, inner, theme));
|
|
497
|
+
const chatBox = pane("conversation", input.title ? `Conversation · ${input.title}` : "Conversation", input.query ? matches(feed.chat.length) : input.keys?.conversation, (inner, need) => conversation(input, feed.chat, inner, need, theme));
|
|
498
|
+
const activityBox = pane("activity", "Activity", input.query ? matches(feed.activity.length) : input.keys?.activity, (inner, need) => activity(input, feed.activity, inner, need, theme));
|
|
341
499
|
const thought = currentThought(feed.thoughts);
|
|
342
500
|
const thinkingNote = thought ? `${thought.source} · ${thought.live ? "thinking" : since(input.now - thought.at)}` : input.keys?.thinking;
|
|
343
|
-
const thinkingBox = pane("thinking", "Thinking", thinkingNote, (inner) => thinkingContent(input, feed.thoughts, inner, theme));
|
|
501
|
+
const thinkingBox = pane("thinking", "Thinking", thinkingNote, (inner, need) => thinkingContent(input, feed.thoughts, inner, need, theme));
|
|
344
502
|
const top = scene.length;
|
|
345
503
|
let body: string[] = [];
|
|
346
504
|
if (main > 0 && panels.conversation && panels.activity) {
|