@a-t-h-i/bot-lobby 0.6.13 → 0.6.15

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.
Files changed (71) hide show
  1. package/README.md +70 -79
  2. package/package.json +4 -2
  3. package/prompts/master.md +3 -2
  4. package/prompts/qa.md +33 -11
  5. package/prompts/reviewer.md +5 -2
  6. package/prompts/worker.md +5 -2
  7. package/src/ask/relay.ts +1 -2
  8. package/src/ask/tool.ts +4 -6
  9. package/src/ask/types.ts +16 -0
  10. package/src/ask/web.ts +63 -0
  11. package/src/index.ts +7 -1
  12. package/src/lobby/ask.ts +4 -5
  13. package/src/lobby/blocks.ts +57 -0
  14. package/src/lobby/feed.ts +1 -1
  15. package/src/lobby/host.ts +178 -0
  16. package/src/lobby/keys.ts +10 -41
  17. package/src/lobby/prompt-hub.ts +30 -77
  18. package/src/lobby/prompts.ts +8 -12
  19. package/src/lobby/runtime.ts +49 -310
  20. package/src/lobby/service.ts +54 -134
  21. package/src/lobby/task-rows.ts +28 -0
  22. package/src/pi/commands.ts +14 -20
  23. package/src/pi/model-settings.ts +112 -0
  24. package/src/pi/start-task.ts +1 -1
  25. package/src/pi/tools.ts +6 -7
  26. package/src/pi/ui.ts +0 -9
  27. package/src/schemas/configuration.ts +9 -33
  28. package/src/state/attachments.ts +82 -0
  29. package/src/webui/api/index.ts +17 -15
  30. package/src/webui/api/lobby.ts +4 -3
  31. package/src/webui/api/planner.ts +6 -4
  32. package/src/webui/api/quickfix.ts +4 -3
  33. package/src/webui/api/sessions.ts +9 -7
  34. package/src/webui/api/settings.ts +20 -8
  35. package/src/webui/api/status.ts +4 -15
  36. package/src/webui/api/tasks.ts +11 -15
  37. package/src/webui/command.ts +35 -19
  38. package/src/webui/dev/fake-service.ts +1 -2
  39. package/src/webui/dev/fixtures.ts +2 -5
  40. package/src/webui/events.ts +1 -1
  41. package/src/webui/protocol.ts +22 -11
  42. package/src/webui/server.ts +42 -1
  43. package/src/webui/uploads.ts +68 -0
  44. package/webui/dist/assets/index-B1PnCyl2.css +2 -0
  45. package/webui/dist/assets/index-DI9Yq9IC.js +90 -0
  46. package/webui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  47. package/webui/dist/build.json +142 -110
  48. package/webui/dist/index.html +4 -3
  49. package/src/ask/dialog.ts +0 -193
  50. package/src/ask/image.ts +0 -202
  51. package/src/ask/png.ts +0 -179
  52. package/src/ask/state.ts +0 -175
  53. package/src/ask/view.ts +0 -163
  54. package/src/lobby/layout.ts +0 -496
  55. package/src/lobby/markdown.ts +0 -121
  56. package/src/lobby/mini.ts +0 -167
  57. package/src/lobby/tabs/excalidraw.ts +0 -95
  58. package/src/lobby/tabs/git.ts +0 -162
  59. package/src/lobby/tabs/home.ts +0 -544
  60. package/src/lobby/tabs/issues.ts +0 -73
  61. package/src/lobby/tabs/knowledge.ts +0 -135
  62. package/src/lobby/tabs/metrics.ts +0 -300
  63. package/src/lobby/tabs/plan.ts +0 -262
  64. package/src/lobby/tabs/quickfix.ts +0 -136
  65. package/src/lobby/tabs/tasks.ts +0 -412
  66. package/src/lobby/theme.ts +0 -30
  67. package/src/lobby/view.ts +0 -3018
  68. package/src/pi/settings-ui.ts +0 -708
  69. package/src/width.ts +0 -102
  70. package/webui/dist/assets/index-Bk-lpAGm.css +0 -2
  71. package/webui/dist/assets/index-E6MPcbdC.js +0 -80
@@ -1,544 +0,0 @@
1
- /**
2
- * The Lobby tab: the status of this session's task on top, then the
3
- * conversation with the oracle (text only, no tool rows, no thinking), the
4
- * activity log of plain-words steps from every agent, and the one place where
5
- * thoughts show up. Pure: the clock arrives as `now`.
6
- */
7
- import { textWidth } from "../../width.ts";
8
- import type { Task } from "../../schemas/task.ts";
9
- import type { ActivityEntry, ChatEntry, ThoughtEntry } from "../feed.ts";
10
- import type { LobbyPanel } from "../../schemas/configuration.ts";
11
- import { markdownBlocks } from "../markdown.ts";
12
- import { beside, bold, box, clock, fill, fit, italic, markdownLines, notePane, paint, since, spinner, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout, type PaneMark } from "../layout.ts";
13
-
14
- /** The Lobby tab's scrollable panes. */
15
- export const HOME_PANES = ["conversation", "activity", "thinking"] as const;
16
- export type HomePane = (typeof HOME_PANES)[number];
17
-
18
- export interface HomeInput {
19
- task?: Task;
20
- chat: readonly ChatEntry[];
21
- /** The oracle's reply while it streams. */
22
- liveReply?: string;
23
- activity: readonly ActivityEntry[];
24
- thoughts: readonly ThoughtEntry[];
25
- /** The Master's turn is running. */
26
- busy: boolean;
27
- /** Active tasks owned by other sessions, and saved plans waiting to start. */
28
- others: number;
29
- pending: 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;
36
- /** Each pane's newest entry as the frame before marked it, so the pane can measure what arrived below since. */
37
- anchors?: ReadonlyMap<string, PaneMark>;
38
- tick: number;
39
- now: number;
40
- /** Which panes show. */
41
- panels: Record<LobbyPanel, boolean>;
42
- /** The search in force: panes show only matching entries. */
43
- query?: string;
44
- /** Key labels for the pane toggles, shown on the panes. */
45
- keys?: Record<LobbyPanel, string>;
46
- /** Another session is shown: its name titles the conversation. */
47
- title?: string;
48
- /** What the empty conversation says instead of the default (a session starting, say). */
49
- emptyNote?: string;
50
- /** What the empty activity log says instead of "No activity yet.". */
51
- activityNote?: string;
52
- /** Tops the conversation when earlier messages are not loaded (scrolling up loads them). */
53
- olderNote?: string;
54
- }
55
-
56
- /** Wide terminals put the conversation and the activity log side by side. */
57
- export const HOME_COLUMNS_MIN = 100;
58
- const SOURCE_COLORS: Record<string, LobbyColor> = {
59
- MASTER: "accent",
60
- DEV: "success",
61
- DESIGN: "mdHeading",
62
- QA: "warning",
63
- RESEARCH: "toolTitle",
64
- "QUICK FIX": "mdCode",
65
- ORACLE: "accent",
66
- LOBBY: "muted",
67
- };
68
-
69
- export function sourceColor(source: string): LobbyColor {
70
- return SOURCE_COLORS[source] ?? "muted";
71
- }
72
-
73
- const SOURCE_WIDTH = 9;
74
-
75
- /** One activity line: time, who, what; a running step spins, a finished one is marked. */
76
- export function activityLine(entry: ActivityEntry, _width: number, tick: number, theme?: LobbyTheme): string {
77
- const who = paint(theme, sourceColor(entry.source), entry.source.padEnd(SOURCE_WIDTH).slice(0, SOURCE_WIDTH));
78
- const mark = entry.pending
79
- ? paint(theme, "accent", spinner(tick))
80
- : entry.kind === "error"
81
- ? paint(theme, "error", "✗")
82
- : entry.kind === "warning"
83
- ? paint(theme, "warning", "!")
84
- : entry.kind === "success"
85
- ? paint(theme, "success", "✓")
86
- : paint(theme, "dim", "·");
87
- const text = entry.pending ? `${entry.text}…` : entry.text;
88
- const body = entry.kind === "error" ? paint(theme, "error", text) : entry.pending ? text : paint(theme, "muted", text);
89
- return `${paint(theme, "dim", clock(entry.at))} ${who} ${mark} ${body}`;
90
- }
91
-
92
- /**
93
- * How each speaker is shown: a mark and a name in its colour, and the side of
94
- * the conversation it speaks from — you on the right, the oracle on the left.
95
- */
96
- export const SPEAKERS = {
97
- you: { mark: "●", name: "You", color: "accent", side: "right" },
98
- oracle: { mark: "◆", name: "Oracle", color: "toolTitle", side: "left" },
99
- panel: { mark: "◆", name: "Panel", color: "toolTitle", side: "left" },
100
- } as const satisfies Record<string, { mark: string; name: string; color: LobbyColor; side: "left" | "right" }>;
101
- export type Speaker = keyof typeof SPEAKERS;
102
-
103
- /** Messages from one speaker within this long of each other share a header. */
104
- const GROUP_MS = 5 * 60_000;
105
- /** The oracle's replies sit under its name, past its mark. */
106
- const BODY_INDENT = 2;
107
- /** Your messages take at most this share of the pane, the oracle's this much, so the two sides read apart. */
108
- const YOU_SHARE = 0.72;
109
- const ORACLE_SHARE = 0.86;
110
- /** Narrower panes give every message (nearly) the full width. */
111
- const NARROW = 48;
112
-
113
- /** The widest a message from one side may be in a pane `width` wide. */
114
- function messageWidth(width: number, share: number, margin: number): number {
115
- return Math.max(1, width < NARROW ? width - margin : Math.floor(width * share));
116
- }
117
-
118
- /**
119
- * Who speaks and when: `◆ Oracle ········ 12:04` on the left for the oracle,
120
- * `12:04 You ●` on the right for you. `note` is the time, or what the
121
- * speaker is doing (`⠋ writing`).
122
- */
123
- export function speakerLine(speaker: Speaker, width: number, theme?: LobbyTheme, note = ""): string {
124
- const { mark, name, color, side } = SPEAKERS[speaker];
125
- const who = paint(theme, color, mark);
126
- const label = bold(theme, paint(theme, color, name));
127
- const dimmed = note ? paint(theme, "dim", note) : "";
128
- if (side === "right") {
129
- const head = dimmed ? `${dimmed} ${label} ${who}` : `${label} ${who}`;
130
- return `${" ".repeat(Math.max(0, width - textWidth(head)))}${head}`;
131
- }
132
- const left = `${who} ${label}`;
133
- const gap = width - textWidth(left) - textWidth(dimmed);
134
- return gap >= 1 && dimmed ? `${left}${" ".repeat(gap)}${dimmed}` : left;
135
- }
136
-
137
- /**
138
- * What you wrote: a bubble on the right, only as wide as its longest line (up
139
- * to `YOU_SHARE` of the pane), your words in Markdown as pi renders them, in
140
- * the accent colour on pi's user-message background — or, where the theme
141
- * has no background, closed by a bar in that colour.
142
- */
143
- export function youLines(text: string, width: number, theme?: LobbyTheme): string[] {
144
- const most = Math.max(1, messageWidth(width, YOU_SHARE, 4) - 2);
145
- // The renderer colours your words itself; without one (tests) they are wrapped and painted here.
146
- const rendered = theme?.markdown ? markdownLines(text, most, theme, true, "you") : wrap(text, most).map((line) => paint(theme, "accent", line));
147
- const lines = rendered.length > 0 ? rendered : [""];
148
- const inner = Math.max(1, ...lines.map((line) => textWidth(line)));
149
- const pad = " ".repeat(Math.max(0, width - inner - 2));
150
- const bubble = theme?.bg
151
- ? (line: string) => theme.bg!("userMessageBg", ` ${fit(line, inner)} `)
152
- : (line: string) => `${fit(line, inner)} ${paint(theme, "accent", "▐")}`;
153
- return lines.map((line) => `${pad}${bubble(line)}`);
154
- }
155
-
156
- /** The oracle's reply as Markdown on the left, under its name. */
157
- function oracleLines(text: string, width: number, theme?: LobbyTheme, keep = true): string[] {
158
- const indent = " ".repeat(BODY_INDENT);
159
- return markdownLines(text, Math.max(1, messageWidth(width, ORACLE_SHARE, 0) - BODY_INDENT), theme, keep).map((line) => (line ? `${indent}${line}` : ""));
160
- }
161
-
162
- /** An open block this long is drawn again at most every `BIG_BLOCK_MS` while it grows (a long code block, say). */
163
- const BIG_BLOCK = 1500;
164
- const BIG_BLOCK_MS = 100;
165
- let lastOpen: { text: string; width: number; theme: object; lines: string[]; at: number } | undefined;
166
-
167
- /** The block the oracle is writing: drawn afresh (and not cached), except that a big one is redrawn only a few times a second. */
168
- function openBlockLines(text: string, width: number, theme: LobbyTheme | undefined): string[] {
169
- const now = performance.now();
170
- const key = theme ?? PLAIN;
171
- 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;
172
- const lines = oracleLines(text, width, theme, false);
173
- lastOpen = text.length > BIG_BLOCK ? { text, width, theme: key, lines, at: now } : undefined;
174
- return lines;
175
- }
176
-
177
- /**
178
- * The reply the oracle is still writing. It is drawn block by block (see
179
- * `markdownBlocks`): finished blocks come from the Markdown cache and only the
180
- * one being written is rendered on each frame. It is drawn whole (it is
181
- * capped at `MAX_REPLY_TEXT`), so the pane knows exactly how far it reaches.
182
- */
183
- function liveLines(text: string, width: number, theme: LobbyTheme | undefined): string[] {
184
- const blocks = markdownBlocks(text);
185
- const lines: string[] = [];
186
- for (let index = 0; index < blocks.length; index += 1) {
187
- const block = index === blocks.length - 1 ? openBlockLines(blocks[index]!, width, theme) : oracleLines(blocks[index]!, width, theme);
188
- if (block.length === 0) continue;
189
- if (lines.length > 0) lines.push("");
190
- for (const line of block) lines.push(line);
191
- }
192
- return lines;
193
- }
194
-
195
- /** An event in the conversation (a task starting, a comment sent) as a centred rule; failures stand out instead. */
196
- export function eventLines(text: string, at: number, width: number, theme?: LobbyTheme): string[] {
197
- if (text.startsWith("✗")) return wrap(paint(theme, "error", text), width);
198
- const label = ` ${text}${at > 0 ? ` · ${clock(at)}` : ""} `;
199
- const room = width - textWidth(label);
200
- if (room < 6) return wrap(paint(theme, "dim", text), width);
201
- const left = Math.floor(room / 2);
202
- return [paint(theme, "dim", `${"─".repeat(left)}${label}${"─".repeat(room - left)}`)];
203
- }
204
-
205
- /** The newest lines of a pane, and how many it holds in all (estimated when only the newest were drawn). */
206
- export interface PaneLines {
207
- lines: string[];
208
- total: number;
209
- /** The newest entry and how many lines run from its first line to the end, for the next frame to measure against. */
210
- mark?: PaneMark;
211
- /** Lines that arrived below the anchor passed in since it was marked; absent when it is no longer there. */
212
- grew?: number;
213
- }
214
-
215
- /**
216
- * Where the anchor (an entry the frame before marked) sits in `entries`, so
217
- * drawing goes on at least that far back; `entries.length` when it is gone.
218
- * It is nearly always the last entry, so the search from the end is short.
219
- */
220
- function reachOf<T extends object>(entries: readonly T[], anchor: PaneMark | undefined): { found: number; reach: number } {
221
- const found = anchor ? entries.lastIndexOf(anchor.entry as T) : -1;
222
- return { found, reach: found < 0 ? entries.length : found };
223
- }
224
-
225
- /** A message's drawn lines, kept until the pane's width, the theme or its grouping changes. */
226
- interface Block {
227
- width: number;
228
- grouped: boolean;
229
- lines: string[];
230
- }
231
-
232
- /**
233
- * Drawn messages, per theme and per message: a message is drawn once (its
234
- * Markdown is the costly part) and reused on every later frame. Messages
235
- * that leave the feed are forgotten with it.
236
- */
237
- const blocks = new WeakMap<object, WeakMap<ChatEntry, Block>>();
238
- const PLAIN = {};
239
-
240
- /** Whether `entry` shares the speaker line of the message before it (same speaker, within minutes). */
241
- function isGrouped(entry: ChatEntry, before: ChatEntry | undefined): boolean {
242
- return entry.role !== "note" && before !== undefined && before.role === entry.role && entry.at >= before.at && entry.at - before.at < GROUP_MS;
243
- }
244
-
245
- function entryBlock(entry: ChatEntry, before: ChatEntry | undefined, width: number, theme?: LobbyTheme): string[] {
246
- const grouped = isGrouped(entry, before);
247
- let cache = blocks.get(theme ?? PLAIN);
248
- if (!cache) {
249
- cache = new WeakMap();
250
- blocks.set(theme ?? PLAIN, cache);
251
- }
252
- const hit = cache.get(entry);
253
- if (hit && hit.width === width && hit.grouped === grouped) return hit.lines;
254
- const lines = entry.role === "note"
255
- ? eventLines(entry.text, entry.at, width, theme)
256
- : [
257
- ...(grouped ? [] : [speakerLine(entry.role, width, theme, entry.at > 0 ? clock(entry.at) : "")]),
258
- ...(entry.role === "you" ? youLines(entry.text, width, theme) : oracleLines(entry.text, width, theme)),
259
- ];
260
- cache.set(entry, { width, grouped, lines });
261
- return lines;
262
- }
263
-
264
- export interface ChatTailOptions {
265
- /** The reply the oracle is still writing. */
266
- live?: string;
267
- /** The oracle's turn is running. */
268
- busy?: boolean;
269
- tick?: number;
270
- /** Tops a conversation that has earlier messages still to load. */
271
- older?: string;
272
- /** The newest message as the frame before marked it: drawing reaches at least back to it, to measure what arrived below. */
273
- anchor?: PaneMark;
274
- }
275
-
276
- /**
277
- * The newest `need` lines of the conversation, like a chat: your messages as
278
- * bubbles on the right, the oracle's replies in Markdown on the left, each
279
- * turn under a line naming its speaker and time, events as centred rules,
280
- * and while the oracle works its header says so (streaming the reply under
281
- * it). Only the messages those lines reach are drawn — scrolling back draws
282
- * more — and each is drawn once; the total is estimated from them until the
283
- * whole conversation has been drawn. `older` tops a conversation that has
284
- * earlier messages still to load.
285
- */
286
- export function chatTail(chat: readonly ChatEntry[], width: number, theme: LobbyTheme | undefined, need: number, options: ChatTailOptions = {}): PaneLines {
287
- const { live, busy = false, tick = 0, older, anchor } = options;
288
- const parts: string[][] = [];
289
- let count = 0;
290
- const add = (lines: string[]) => {
291
- if (lines.length === 0) return;
292
- count += lines.length + (parts.length > 0 ? 1 : 0);
293
- parts.push(lines);
294
- };
295
- if (live?.trim()) add([speakerLine("oracle", width, theme, `${spinner(tick)} writing`), ...liveLines(live.trim(), width, theme)]);
296
- else if (busy) add([speakerLine("oracle", width, theme, `${spinner(tick)} working…`)]);
297
- const { found, reach } = reachOf(chat, anchor);
298
- let mark: PaneMark | undefined;
299
- let grew: number | undefined;
300
- let index = chat.length - 1;
301
- for (; index >= 0 && (count < need || index >= reach); index -= 1) {
302
- const entry = chat[index]!;
303
- add(entryBlock(entry, chat[index - 1], width, theme));
304
- if (index === chat.length - 1) mark = { entry, below: count };
305
- if (index === found) grew = count - anchor!.below;
306
- }
307
- const drawn = chat.length - 1 - index;
308
- if (index < 0 && older) add(wrap(paint(theme, "dim", older), width));
309
- const lines: string[] = [];
310
- for (let part = parts.length - 1; part >= 0; part -= 1) {
311
- if (lines.length > 0) lines.push("");
312
- for (const line of parts[part]!) lines.push(line);
313
- }
314
- // Messages not drawn yet count at the average height of those that were.
315
- const rest = index + 1;
316
- const total = rest > 0 && drawn > 0 ? lines.length + Math.ceil((rest * lines.length) / drawn) : lines.length;
317
- return { lines, total, ...(mark ? { mark } : {}), ...(grew !== undefined ? { grew } : {}) };
318
- }
319
-
320
- /** The whole conversation drawn (the session browser's preview and tests; the Lobby tab draws only what shows). */
321
- export function chatLines(chat: readonly ChatEntry[], width: number, theme?: LobbyTheme, live?: string, busy = false, tick = 0): string[] {
322
- return chatTail(chat, width, theme, Number.POSITIVE_INFINITY, { ...(live ? { live } : {}), busy, tick }).lines;
323
- }
324
-
325
- function emptyChat(input: HomeInput, width: number, theme?: LobbyTheme): string[] {
326
- if (input.query) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${input.query}".`), width);
327
- if (input.emptyNote) return wrap(paint(theme, "dim", input.emptyNote), width);
328
- const lines = input.task
329
- ? [paint(theme, "dim", "Nothing said yet. Type below to talk to the oracle about this task.")]
330
- : [
331
- bold(theme, "No task is running in this session."),
332
- "",
333
- "Type a request below and press enter to start one: the oracle scouts, proposes, plans and delegates.",
334
- `Or plan it first with the whole planning panel in ${bold(theme, "3 Plan")}, or make a direct change in ${bold(theme, "4 Quick fix")}.`,
335
- ];
336
- const extra: string[] = [];
337
- if (input.others > 0) extra.push(`${input.others} task${input.others === 1 ? " is" : "s are"} running in other sessions — see ${bold(theme, "2 Tasks")}.`);
338
- if (input.pending > 0) extra.push(`${input.pending} planned task${input.pending === 1 ? "" : "s"} waiting to start — see ${bold(theme, "2 Tasks")}.`);
339
- return [...lines.flatMap((line) => wrap(line, width)), ...(extra.length > 0 ? ["", ...extra.flatMap((line) => wrap(paint(theme, "muted", line), width))] : [])];
340
- }
341
-
342
- function matches(text: string, query: string | undefined): boolean {
343
- return !query || text.toLowerCase().includes(query.toLowerCase());
344
- }
345
-
346
- /** The feed as the search narrows it: only entries whose text (or source) contains the query. */
347
- export function filterFeed(input: Pick<HomeInput, "chat" | "activity" | "thoughts" | "query">): { chat: ChatEntry[]; activity: ActivityEntry[]; thoughts: ThoughtEntry[] } {
348
- const query = input.query?.trim();
349
- return {
350
- chat: input.chat.filter((entry) => matches(entry.text, query)),
351
- activity: input.activity.filter((entry) => matches(`${entry.source} ${entry.text}`, query)),
352
- thoughts: input.thoughts.filter((entry) => matches(`${entry.source} ${entry.text}`, query)),
353
- };
354
- }
355
-
356
- function whole(lines: string[]): PaneLines {
357
- return { lines, total: lines.length };
358
- }
359
-
360
- function conversation(input: HomeInput, chat: readonly ChatEntry[], width: number, need: number, anchor: PaneMark | undefined, theme?: LobbyTheme): PaneLines {
361
- const searching = Boolean(input.query);
362
- const live = searching ? undefined : input.liveReply;
363
- const busy = !searching && input.busy;
364
- if (chat.length === 0 && !live && !busy) return whole(emptyChat(input, width, theme));
365
- const older = searching ? undefined : input.olderNote;
366
- return chatTail(chat, width, theme, need, { ...(live ? { live } : {}), busy, tick: input.tick, ...(older ? { older } : {}), ...(anchor ? { anchor } : {}) });
367
- }
368
-
369
- /** The newest `need` activity lines (one per entry), drawn only as far back as the pane shows. */
370
- function activity(input: HomeInput, entries: readonly ActivityEntry[], width: number, need: number, anchor: PaneMark | undefined, theme?: LobbyTheme): PaneLines {
371
- if (entries.length === 0) return whole(wrap(paint(theme, "dim", input.query ? `No activity matches "${input.query}".` : input.activityNote ?? "No activity yet."), width));
372
- const shown = Number.isFinite(need) ? entries.slice(-Math.max(0, need)) : entries;
373
- // One line per entry: where the anchor sits is a count, nothing needs drawing to find it.
374
- const { found } = reachOf(entries, anchor);
375
- return {
376
- lines: shown.map((entry) => activityLine(entry, width, input.tick, theme)),
377
- total: entries.length,
378
- mark: { entry: entries.at(-1)!, below: 1 },
379
- ...(found >= 0 ? { grew: entries.length - found - anchor!.below } : {}),
380
- };
381
- }
382
-
383
- /** The last `rows` lines, `offset` lines back from the newest (clamped), and where they start. */
384
- export function tailWindow(lines: readonly string[], rows: number, offset = 0): { shown: string[]; start: number; offset: number } {
385
- return paneWindow({ lines: [...lines], total: lines.length }, rows, offset);
386
- }
387
-
388
- /**
389
- * `rows` lines of a pane `offset` lines back from its newest (clamped), when
390
- * only its newest lines were drawn: where they start in the whole pane, for
391
- * the scrollbar.
392
- */
393
- export function paneWindow(content: PaneLines, rows: number, offset = 0): { shown: string[]; start: number; offset: number } {
394
- const { lines } = content;
395
- const total = Math.max(content.total, lines.length);
396
- const height = Math.max(0, rows);
397
- const back = Math.min(Math.max(0, offset), Math.max(0, total - height));
398
- const end = Math.max(0, lines.length - back);
399
- const begin = Math.max(0, end - height);
400
- return { shown: lines.slice(begin, end), start: Math.max(0, total - back - height), offset: back };
401
- }
402
-
403
- /** The thought to show: a live one first, otherwise the newest. */
404
- export function currentThought(thoughts: readonly ThoughtEntry[]): ThoughtEntry | undefined {
405
- return [...thoughts].reverse().find((entry) => entry.live) ?? thoughts.at(-1);
406
- }
407
-
408
- /**
409
- * Drawn thoughts, per theme and per thought, kept while the thought's text
410
- * and the width stay the same (a live thought grows, so it is drawn again as
411
- * it streams; settled ones are wrapped once).
412
- */
413
- const thoughtBlocks = new WeakMap<object, WeakMap<ThoughtEntry, { width: number; text: string; lines: string[]; at: number }>>();
414
-
415
- /** A live thought this long is wrapped again at most every `LONG_THOUGHT_MS` while it streams; rendered as Markdown (costlier), from `LONG_THOUGHT_MARKDOWN`. */
416
- const LONG_THOUGHT = 1000;
417
- const LONG_THOUGHT_MARKDOWN = 300;
418
- const LONG_THOUGHT_MS = 150;
419
-
420
- function thoughtBlock(thought: ThoughtEntry, width: number, theme?: LobbyTheme): string[] {
421
- let cache = thoughtBlocks.get(theme ?? PLAIN);
422
- if (!cache) {
423
- cache = new WeakMap();
424
- thoughtBlocks.set(theme ?? PLAIN, cache);
425
- }
426
- const hit = cache.get(thought);
427
- const now = performance.now();
428
- const long = theme?.markdown ? LONG_THOUGHT_MARKDOWN : LONG_THOUGHT;
429
- if (hit && hit.width === width && (hit.text === thought.text || (thought.live && thought.text.length > long && now - hit.at < LONG_THOUGHT_MS))) return hit.lines;
430
- const lead = `${paint(theme, sourceColor(thought.source), thought.source.padEnd(SOURCE_WIDTH).slice(0, SOURCE_WIDTH))} `;
431
- const lines = theme?.markdown ? thoughtMarkdown(lead, thought, width, theme) : wrapHanging(lead, italic(theme, paint(theme, "dim", thought.text.replace(/\s*\n\s*/g, " "))), width);
432
- cache.set(thought, { width, text: thought.text, lines, at: now });
433
- return lines;
434
- }
435
-
436
- /**
437
- * A thought in Markdown as pi shows thinking (dimmed, in italics), after who
438
- * thought it: a model's `**Checking the plan**` reads bold, not starred. The
439
- * pane is small, so the blank lines between paragraphs are left out. A live
440
- * thought is not kept in the renderer's cache; it changes as it streams.
441
- */
442
- function thoughtMarkdown(lead: string, thought: ThoughtEntry, width: number, theme: LobbyTheme): string[] {
443
- const indent = " ".repeat(textWidth(lead));
444
- const body = markdownLines(thought.text, Math.max(1, width - textWidth(lead)), theme, !thought.live, "thought").filter((line) => line.trim());
445
- if (body.length === 0) return [lead];
446
- return body.map((line, index) => `${index === 0 ? lead : indent}${line}`);
447
- }
448
-
449
- /** The newest `need` lines of the thoughts, oldest first: only the thoughts they reach are drawn (and back to `anchor`), and the rest estimated. */
450
- export function thoughtTail(thoughts: readonly ThoughtEntry[], width: number, theme: LobbyTheme | undefined, need: number, anchor?: PaneMark): PaneLines {
451
- const parts: string[][] = [];
452
- let count = 0;
453
- const { found, reach } = reachOf(thoughts, anchor);
454
- let mark: PaneMark | undefined;
455
- let grew: number | undefined;
456
- let index = thoughts.length - 1;
457
- for (; index >= 0 && (count < need || index >= reach); index -= 1) {
458
- const thought = thoughts[index]!;
459
- const lines = thoughtBlock(thought, width, theme);
460
- parts.push(lines);
461
- count += lines.length;
462
- if (index === thoughts.length - 1) mark = { entry: thought, below: count };
463
- if (index === found) grew = count - anchor!.below;
464
- }
465
- const lines: string[] = [];
466
- for (let part = parts.length - 1; part >= 0; part -= 1) for (const line of parts[part]!) lines.push(line);
467
- const rest = index + 1;
468
- const drawn = parts.length;
469
- const total = rest > 0 && drawn > 0 ? lines.length + Math.ceil((rest * lines.length) / drawn) : lines.length;
470
- return { lines, total, ...(mark ? { mark } : {}), ...(grew !== undefined ? { grew } : {}) };
471
- }
472
-
473
- /** Every thought, oldest first: who thought it, then the thought, dimmed. */
474
- export function thoughtLines(thoughts: readonly ThoughtEntry[], width: number, theme?: LobbyTheme): string[] {
475
- return thoughtTail(thoughts, width, theme, Number.POSITIVE_INFINITY).lines;
476
- }
477
-
478
- function thinkingContent(input: HomeInput, thoughts: readonly ThoughtEntry[], width: number, need: number, anchor: PaneMark | undefined, theme?: LobbyTheme): PaneLines {
479
- if (thoughts.length > 0) return thoughtTail(thoughts, width, theme, need, anchor);
480
- return whole([paint(theme, "dim", input.query ? `No thought matches "${input.query}".` : "Thoughts from the oracle and every agent appear here, and only here.")]);
481
- }
482
-
483
- function hiddenHint(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
484
- const keys = input.keys;
485
- const text = keys
486
- ? `Every pane is hidden — ${keys.conversation} conversation · ${keys.activity} activity · ${keys.thinking} thinking`
487
- : "Every pane is hidden.";
488
- const lines = wrap(paint(theme, "dim", text), width);
489
- const top = Math.max(0, Math.floor((height - lines.length) / 2));
490
- return fill([...Array.from({ length: top }, () => ""), ...lines], height, width);
491
- }
492
-
493
- /** `↓12` when a pane is scrolled back from its newest line, before the pane's usual note. */
494
- function rightNote(offset: number, note: string | undefined): string | undefined {
495
- const parts = [offset > 0 ? `↓${offset}` : "", note ?? ""].filter(Boolean);
496
- return parts.length > 0 ? parts.join(" · ") : undefined;
497
- }
498
-
499
- /** The whole tab, exactly `height` lines of `width` columns; hidden panes give their room to the rest. */
500
- export function renderHome(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
501
- if (height <= 0) return [];
502
- const { panels } = input;
503
- const feed = filterFeed(input);
504
- const rest = height;
505
- const showMain = panels.conversation || panels.activity;
506
- const thinkHeight = !panels.thinking ? 0 : !showMain ? rest : rest >= 18 ? Math.max(5, Math.floor(rest * 0.25)) : rest >= 10 ? 4 : 0;
507
- const main = rest - thinkHeight;
508
- if (!showMain && !panels.thinking) return fill(hiddenHint(input, width, rest, theme), height, width);
509
- const matches = (count: number) => `${count} match${count === 1 ? "" : "es"}`;
510
- /**
511
- * One scrollable pane: only the lines it shows are drawn — its newest, or
512
- * as far back as it is scrolled — in a box that records where it sits, and
513
- * how many lines arrived below since the frame before.
514
- */
515
- const pane = (name: HomePane, title: string, note: string | undefined, content: (inner: number, need: number, anchor: PaneMark | undefined) => PaneLines) => (top: number, left: number, w: number, h: number): string[] => {
516
- const rows = Math.max(0, h - 2);
517
- const offset = input.offsets?.[name] ?? 0;
518
- const drawn = content(Math.max(1, w - 4), rows + offset, input.anchors?.get(name));
519
- const view = paneWindow(drawn, rows, offset);
520
- const total = Math.max(drawn.total, drawn.lines.length);
521
- notePane(input.panes, name, top, left, w, h, total, { ...(drawn.mark ? { mark: drawn.mark } : {}), grew: drawn.grew ?? 0 });
522
- const right = rightNote(view.offset, note);
523
- return box(w, h, view.shown, { title, ...(right ? { right } : {}), focused: input.focus === name, scroll: { total, start: view.start }, theme });
524
- };
525
- const chatBox = pane("conversation", input.title ? `Conversation · ${input.title}` : "Conversation", input.query ? matches(feed.chat.length) : input.keys?.conversation, (inner, need, anchor) => conversation(input, feed.chat, inner, need, anchor, theme));
526
- const activityBox = pane("activity", "Activity", input.query ? matches(feed.activity.length) : input.keys?.activity, (inner, need, anchor) => activity(input, feed.activity, inner, need, anchor, theme));
527
- const thought = currentThought(feed.thoughts);
528
- const thinkingNote = thought ? `${thought.source} · ${thought.live ? "thinking" : since(input.now - thought.at)}` : input.keys?.thinking;
529
- const thinkingBox = pane("thinking", "Thinking", thinkingNote, (inner, need, anchor) => thinkingContent(input, feed.thoughts, inner, need, anchor, theme));
530
- const top = 0;
531
- let body: string[] = [];
532
- if (main > 0 && panels.conversation && panels.activity) {
533
- if (width >= HOME_COLUMNS_MIN) {
534
- const left = Math.round((width - 1) * 0.56);
535
- body = beside([chatBox(top, 0, left, main), activityBox(top, left + 1, width - 1 - left, main)]);
536
- } else {
537
- const chatHeight = Math.max(3, Math.ceil(main * 0.6));
538
- body = [...chatBox(top, 0, width, chatHeight), ...(main - chatHeight >= 3 ? activityBox(top + chatHeight, 0, width, main - chatHeight) : [])];
539
- }
540
- } else if (main > 0 && panels.conversation) body = chatBox(top, 0, width, main);
541
- else if (main > 0 && panels.activity) body = activityBox(top, 0, width, main);
542
- const think = thinkHeight >= 3 ? thinkingBox(top + main, 0, width, thinkHeight) : [];
543
- return fill([...fill(body, main), ...think], height, width);
544
- }
@@ -1,73 +0,0 @@
1
- /**
2
- * The Issues tab: the repository's open GitHub issues through `gh`, the
3
- * selected issue with its comments, and the way into planning it (p) before it
4
- * becomes a task.
5
- */
6
- import type { IssueDetail, IssueSummary } from "../issues.ts";
7
- import { bold, since, columns, fill, markdownLines, paint, rule, selectRow, spinner, split, windowStart, wrap, type LobbyTheme } from "../layout.ts";
8
-
9
- export interface IssuesTabInput {
10
- issues: readonly IssueSummary[];
11
- selected: number;
12
- detail?: IssueDetail;
13
- focus: "list" | "detail";
14
- detailOffset: number;
15
- loading: boolean;
16
- loaded: boolean;
17
- error?: string;
18
- notice?: string;
19
- tick: number;
20
- now: number;
21
- }
22
-
23
- export const ISSUES_COLUMNS_MIN = 90;
24
-
25
- export function issueDetailLines(issue: IssueDetail, width: number, now: number, theme?: LobbyTheme): string[] {
26
- const facts = [
27
- issue.state ? paint(theme, issue.state === "OPEN" ? "success" : "dim", issue.state.toLowerCase()) : "",
28
- issue.author ? `by ${issue.author}` : "",
29
- issue.updatedAt ? `updated ${since(now - Date.parse(issue.updatedAt))}` : "",
30
- issue.labels.length > 0 ? paint(theme, "warning", issue.labels.join(", ")) : "",
31
- ].filter(Boolean);
32
- const lines = [...wrap(bold(theme, `#${issue.number} ${issue.title}`), width), facts.join(paint(theme, "dim", " · "))];
33
- if (issue.url) lines.push(paint(theme, "dim", issue.url));
34
- // Issues and their comments are GitHub Markdown.
35
- lines.push("", paint(theme, "dim", "p plans it with the planning panel, then save it as a task"), "", ...(issue.body.trim() ? markdownLines(issue.body.trim(), width, theme) : [paint(theme, "dim", "(no description)")]));
36
- for (const comment of issue.comments) {
37
- lines.push("", rule(width, comment.author ?? "comment", theme, comment.createdAt ? since(now - Date.parse(comment.createdAt)) : ""), ...markdownLines(comment.body.trim(), width, theme));
38
- }
39
- return lines;
40
- }
41
-
42
- function empty(input: IssuesTabInput, width: number, theme?: LobbyTheme): string[] {
43
- if (input.loading) return [`${paint(theme, "accent", spinner(input.tick))} loading issues from GitHub…`];
44
- if (input.error) return [];
45
- if (!input.loaded) return wrap(paint(theme, "dim", "Press r to load open issues with the GitHub CLI (gh)."), width);
46
- return wrap(paint(theme, "dim", "No open issues. Press n to file one."), width);
47
- }
48
-
49
- export function renderIssues(input: IssuesTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
50
- if (height <= 0) return [];
51
- const banner = [
52
- ...(input.error ? wrap(paint(theme, "error", `✗ ${input.error}`), width) : []),
53
- ...(input.notice ? wrap(paint(theme, "success", input.notice), width) : []),
54
- ];
55
- const bodyHeight = Math.max(0, height - banner.length);
56
- if (input.issues.length === 0) return fill([...banner, rule(width, "Issues", theme), ...empty(input, width, theme)], height, width);
57
- const selected = Math.min(Math.max(0, input.selected), input.issues.length - 1);
58
- const wide = width >= ISSUES_COLUMNS_MIN;
59
- const [listWidth, detailWidth] = wide ? split(width, 0.4, 3, 40) : [width, width];
60
- const rows = input.issues.map((issue, index) => {
61
- const labels = issue.labels.length > 0 ? ` ${paint(theme, "warning", `[${issue.labels.slice(0, 2).join(", ")}]`)}` : "";
62
- const title = index === selected ? bold(theme, issue.title) : issue.title;
63
- return selectRow(theme, `${paint(theme, "dim", `#${issue.number}`)} ${title}${labels}`, listWidth, index === selected, input.focus === "list");
64
- });
65
- const right = input.loading ? `${spinner(input.tick)} refreshing` : `${input.issues.length} open`;
66
- const listPane = fill([rule(listWidth, "Issues", theme, right), ...rows.slice(windowStart(selected, rows.length, bodyHeight - 1))], bodyHeight);
67
- const detail = input.detail && input.detail.number === input.issues[selected]!.number
68
- ? issueDetailLines(input.detail, detailWidth, input.now, theme)
69
- : [`${paint(theme, "accent", spinner(input.tick))} loading #${input.issues[selected]!.number}…`];
70
- const detailPane = fill([rule(detailWidth, input.focus === "detail" ? "Issue ◂" : "Issue", theme), ...detail.slice(Math.max(0, Math.min(input.detailOffset, detail.length - 1)))], bodyHeight);
71
- if (!wide) return fill([...banner, ...(input.focus === "detail" ? detailPane : listPane)], height, width);
72
- return fill([...banner, ...columns(listPane, detailPane, listWidth, detailWidth, " │ ", theme)], height, width);
73
- }