@a-t-h-i/bot-lobby 0.6.2 → 0.6.4
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 +112 -11
- package/package.json +1 -4
- package/prompts/master.md +47 -1
- package/prompts/researcher.md +8 -2
- package/prompts/reviewer.md +42 -0
- package/prompts/worker.md +7 -0
- package/src/ask/dialog.ts +167 -0
- package/src/ask/image.ts +202 -0
- package/src/ask/png.ts +179 -0
- package/src/ask/relay.ts +89 -0
- package/src/ask/state.ts +160 -0
- package/src/ask/tool.ts +126 -0
- package/src/ask/types.ts +55 -0
- package/src/ask/view.ts +159 -0
- package/src/execution/agent-runner.ts +103 -11
- package/src/execution/git.ts +111 -14
- package/src/execution/pi-runner.ts +164 -11
- package/src/index.ts +9 -0
- package/src/lobby/ask.ts +10 -120
- package/src/lobby/feed.ts +76 -8
- package/src/lobby/layout.ts +38 -18
- package/src/lobby/markdown.ts +92 -19
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/quickfix.ts +21 -0
- package/src/lobby/runtime.ts +51 -37
- package/src/lobby/session-files.ts +162 -25
- package/src/lobby/sessions.ts +21 -7
- package/src/lobby/tabs/home.ts +289 -67
- package/src/lobby/tabs/issues.ts +4 -3
- package/src/lobby/tabs/plan.ts +4 -4
- package/src/lobby/tabs/quickfix.ts +7 -1
- package/src/lobby/tabs/tasks.ts +14 -4
- package/src/lobby/theme.ts +30 -0
- package/src/lobby/view.ts +141 -25
- package/src/master/decisions.ts +1 -1
- package/src/master/master.ts +41 -4
- package/src/master/research.ts +5 -2
- package/src/pi/commands.ts +94 -10
- package/src/pi/events.ts +54 -12
- package/src/pi/fresh-context.ts +134 -0
- package/src/pi/owner.ts +19 -10
- package/src/pi/quiet.ts +22 -4
- package/src/pi/start-task.ts +11 -2
- package/src/pi/tools.ts +26 -8
- package/src/pi/ui.ts +9 -3
- package/src/pi/zen-large.ts +10 -10
- package/src/pi/zen-metrics.ts +13 -3
- package/src/pi/zen.ts +22 -15
- package/src/roles/reviewer.ts +23 -4
- package/src/roles/worker.ts +18 -0
- package/src/schemas/configuration.ts +8 -0
- package/src/schemas/findings.ts +14 -0
- package/src/schemas/task.ts +21 -0
- package/src/state/archive.ts +12 -3
- package/src/state/backlog.ts +13 -3
- package/src/state/budget.ts +274 -0
- package/src/state/changes.ts +231 -0
- package/src/state/file-cache.ts +62 -0
- package/src/state/metrics.ts +63 -13
- package/src/state/persistence.ts +36 -2
- package/src/text.ts +28 -2
- package/src/web/extract.ts +332 -0
- package/src/web/fetch.ts +232 -0
- package/src/web/html.ts +183 -0
- package/src/web/read.ts +113 -0
- package/src/web/search.ts +202 -0
- package/src/web/tools.ts +279 -0
- package/src/width.ts +102 -0
- package/src/workflow/workflow.ts +592 -42
package/src/lobby/tabs/home.ts
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
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 {
|
|
12
|
+
import { markdownBlocks } from "../markdown.ts";
|
|
13
|
+
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
|
|
|
14
15
|
/** The Lobby tab's scrollable panes. */
|
|
15
16
|
export const HOME_PANES = ["conversation", "activity", "thinking"] as const;
|
|
@@ -35,6 +36,8 @@ export interface HomeInput {
|
|
|
35
36
|
focus?: HomePane;
|
|
36
37
|
/** Filled with where each pane landed and how much it holds. */
|
|
37
38
|
panes?: PaneLayout;
|
|
39
|
+
/** Each pane's newest entry as the frame before marked it, so the pane can measure what arrived below since. */
|
|
40
|
+
anchors?: ReadonlyMap<string, PaneMark>;
|
|
38
41
|
tick: number;
|
|
39
42
|
now: number;
|
|
40
43
|
/** Which panes show. */
|
|
@@ -51,6 +54,8 @@ export interface HomeInput {
|
|
|
51
54
|
emptyNote?: string;
|
|
52
55
|
/** What the empty activity log says instead of "No activity yet.". */
|
|
53
56
|
activityNote?: string;
|
|
57
|
+
/** Tops the conversation when earlier messages are not loaded (scrolling up loads them). */
|
|
58
|
+
olderNote?: string;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
/** Wide terminals put the conversation and the activity log side by side. */
|
|
@@ -132,78 +137,199 @@ export function speakerLine(speaker: Speaker, width: number, theme?: LobbyTheme,
|
|
|
132
137
|
const dimmed = note ? paint(theme, "dim", note) : "";
|
|
133
138
|
if (side === "right") {
|
|
134
139
|
const head = dimmed ? `${dimmed} ${label} ${who}` : `${label} ${who}`;
|
|
135
|
-
return `${" ".repeat(Math.max(0, width -
|
|
140
|
+
return `${" ".repeat(Math.max(0, width - textWidth(head)))}${head}`;
|
|
136
141
|
}
|
|
137
142
|
const left = `${who} ${label}`;
|
|
138
|
-
const gap = width -
|
|
143
|
+
const gap = width - textWidth(left) - textWidth(dimmed);
|
|
139
144
|
return gap >= 1 && dimmed ? `${left}${" ".repeat(gap)}${dimmed}` : left;
|
|
140
145
|
}
|
|
141
146
|
|
|
142
147
|
/**
|
|
143
148
|
* What you wrote: a bubble on the right, only as wide as its longest line (up
|
|
144
|
-
* to `YOU_SHARE` of the pane), your words in
|
|
145
|
-
* user-message background — or, where the theme
|
|
146
|
-
* by a bar in that colour.
|
|
149
|
+
* to `YOU_SHARE` of the pane), your words in Markdown as pi renders them, in
|
|
150
|
+
* the accent colour on pi's user-message background — or, where the theme
|
|
151
|
+
* has no background, closed by a bar in that colour.
|
|
147
152
|
*/
|
|
148
153
|
export function youLines(text: string, width: number, theme?: LobbyTheme): string[] {
|
|
149
154
|
const most = Math.max(1, messageWidth(width, YOU_SHARE, 4) - 2);
|
|
150
|
-
|
|
151
|
-
const
|
|
155
|
+
// The renderer colours your words itself; without one (tests) they are wrapped and painted here.
|
|
156
|
+
const rendered = theme?.markdown ? markdownLines(text, most, theme, true, "you") : wrap(text, most).map((line) => paint(theme, "accent", line));
|
|
157
|
+
const lines = rendered.length > 0 ? rendered : [""];
|
|
158
|
+
const inner = Math.max(1, ...lines.map((line) => textWidth(line)));
|
|
152
159
|
const pad = " ".repeat(Math.max(0, width - inner - 2));
|
|
153
160
|
const bubble = theme?.bg
|
|
154
|
-
? (line: string) => theme.bg!("userMessageBg", ` ${fit(
|
|
155
|
-
: (line: string) => `${fit(
|
|
161
|
+
? (line: string) => theme.bg!("userMessageBg", ` ${fit(line, inner)} `)
|
|
162
|
+
: (line: string) => `${fit(line, inner)} ${paint(theme, "accent", "▐")}`;
|
|
156
163
|
return lines.map((line) => `${pad}${bubble(line)}`);
|
|
157
164
|
}
|
|
158
165
|
|
|
159
166
|
/** The oracle's reply as Markdown on the left, under its name. */
|
|
160
|
-
function oracleLines(text: string, width: number, theme?: LobbyTheme): string[] {
|
|
167
|
+
function oracleLines(text: string, width: number, theme?: LobbyTheme, keep = true): string[] {
|
|
161
168
|
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}` : ""));
|
|
169
|
+
return markdownLines(text, Math.max(1, messageWidth(width, ORACLE_SHARE, 0) - BODY_INDENT), theme, keep).map((line) => (line ? `${indent}${line}` : ""));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** An open block this long is drawn again at most every `BIG_BLOCK_MS` while it grows (a long code block, say). */
|
|
173
|
+
const BIG_BLOCK = 1500;
|
|
174
|
+
const BIG_BLOCK_MS = 100;
|
|
175
|
+
let lastOpen: { text: string; width: number; theme: object; lines: string[]; at: number } | undefined;
|
|
176
|
+
|
|
177
|
+
/** The block the oracle is writing: drawn afresh (and not cached), except that a big one is redrawn only a few times a second. */
|
|
178
|
+
function openBlockLines(text: string, width: number, theme: LobbyTheme | undefined): string[] {
|
|
179
|
+
const now = performance.now();
|
|
180
|
+
const key = theme ?? PLAIN;
|
|
181
|
+
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;
|
|
182
|
+
const lines = oracleLines(text, width, theme, false);
|
|
183
|
+
lastOpen = text.length > BIG_BLOCK ? { text, width, theme: key, lines, at: now } : undefined;
|
|
184
|
+
return lines;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The reply the oracle is still writing. It is drawn block by block (see
|
|
189
|
+
* `markdownBlocks`): finished blocks come from the Markdown cache and only the
|
|
190
|
+
* one being written is rendered on each frame. It is drawn whole (it is
|
|
191
|
+
* capped at `MAX_REPLY_TEXT`), so the pane knows exactly how far it reaches.
|
|
192
|
+
*/
|
|
193
|
+
function liveLines(text: string, width: number, theme: LobbyTheme | undefined): string[] {
|
|
194
|
+
const blocks = markdownBlocks(text);
|
|
195
|
+
const lines: string[] = [];
|
|
196
|
+
for (let index = 0; index < blocks.length; index += 1) {
|
|
197
|
+
const block = index === blocks.length - 1 ? openBlockLines(blocks[index]!, width, theme) : oracleLines(blocks[index]!, width, theme);
|
|
198
|
+
if (block.length === 0) continue;
|
|
199
|
+
if (lines.length > 0) lines.push("");
|
|
200
|
+
for (const line of block) lines.push(line);
|
|
201
|
+
}
|
|
202
|
+
return lines;
|
|
163
203
|
}
|
|
164
204
|
|
|
165
205
|
/** An event in the conversation (a task starting, a comment sent) as a centred rule; failures stand out instead. */
|
|
166
206
|
export function eventLines(text: string, at: number, width: number, theme?: LobbyTheme): string[] {
|
|
167
207
|
if (text.startsWith("✗")) return wrap(paint(theme, "error", text), width);
|
|
168
208
|
const label = ` ${text}${at > 0 ? ` · ${clock(at)}` : ""} `;
|
|
169
|
-
const room = width -
|
|
209
|
+
const room = width - textWidth(label);
|
|
170
210
|
if (room < 6) return wrap(paint(theme, "dim", text), width);
|
|
171
211
|
const left = Math.floor(room / 2);
|
|
172
212
|
return [paint(theme, "dim", `${"─".repeat(left)}${label}${"─".repeat(room - left)}`)];
|
|
173
213
|
}
|
|
174
214
|
|
|
215
|
+
/** The newest lines of a pane, and how many it holds in all (estimated when only the newest were drawn). */
|
|
216
|
+
export interface PaneLines {
|
|
217
|
+
lines: string[];
|
|
218
|
+
total: number;
|
|
219
|
+
/** The newest entry and how many lines run from its first line to the end, for the next frame to measure against. */
|
|
220
|
+
mark?: PaneMark;
|
|
221
|
+
/** Lines that arrived below the anchor passed in since it was marked; absent when it is no longer there. */
|
|
222
|
+
grew?: number;
|
|
223
|
+
}
|
|
224
|
+
|
|
175
225
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* oracle works its header says so (streaming the reply under it).
|
|
226
|
+
* Where the anchor (an entry the frame before marked) sits in `entries`, so
|
|
227
|
+
* drawing goes on at least that far back; `entries.length` when it is gone.
|
|
228
|
+
* It is nearly always the last entry, so the search from the end is short.
|
|
180
229
|
*/
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
230
|
+
function reachOf<T extends object>(entries: readonly T[], anchor: PaneMark | undefined): { found: number; reach: number } {
|
|
231
|
+
const found = anchor ? entries.lastIndexOf(anchor.entry as T) : -1;
|
|
232
|
+
return { found, reach: found < 0 ? entries.length : found };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A message's drawn lines, kept until the pane's width, the theme or its grouping changes. */
|
|
236
|
+
interface Block {
|
|
237
|
+
width: number;
|
|
238
|
+
grouped: boolean;
|
|
239
|
+
lines: string[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Drawn messages, per theme and per message: a message is drawn once (its
|
|
244
|
+
* Markdown is the costly part) and reused on every later frame. Messages
|
|
245
|
+
* that leave the feed are forgotten with it.
|
|
246
|
+
*/
|
|
247
|
+
const blocks = new WeakMap<object, WeakMap<ChatEntry, Block>>();
|
|
248
|
+
const PLAIN = {};
|
|
249
|
+
|
|
250
|
+
/** Whether `entry` shares the speaker line of the message before it (same speaker, within minutes). */
|
|
251
|
+
function isGrouped(entry: ChatEntry, before: ChatEntry | undefined): boolean {
|
|
252
|
+
return entry.role !== "note" && before !== undefined && before.role === entry.role && entry.at >= before.at && entry.at - before.at < GROUP_MS;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function entryBlock(entry: ChatEntry, before: ChatEntry | undefined, width: number, theme?: LobbyTheme): string[] {
|
|
256
|
+
const grouped = isGrouped(entry, before);
|
|
257
|
+
let cache = blocks.get(theme ?? PLAIN);
|
|
258
|
+
if (!cache) {
|
|
259
|
+
cache = new WeakMap();
|
|
260
|
+
blocks.set(theme ?? PLAIN, cache);
|
|
261
|
+
}
|
|
262
|
+
const hit = cache.get(entry);
|
|
263
|
+
if (hit && hit.width === width && hit.grouped === grouped) return hit.lines;
|
|
264
|
+
const lines = entry.role === "note"
|
|
265
|
+
? eventLines(entry.text, entry.at, width, theme)
|
|
266
|
+
: [
|
|
267
|
+
...(grouped ? [] : [speakerLine(entry.role, width, theme, entry.at > 0 ? clock(entry.at) : "")]),
|
|
268
|
+
...(entry.role === "you" ? youLines(entry.text, width, theme) : oracleLines(entry.text, width, theme)),
|
|
269
|
+
];
|
|
270
|
+
cache.set(entry, { width, grouped, lines });
|
|
271
|
+
return lines;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface ChatTailOptions {
|
|
275
|
+
/** The reply the oracle is still writing. */
|
|
276
|
+
live?: string;
|
|
277
|
+
/** The oracle's turn is running. */
|
|
278
|
+
busy?: boolean;
|
|
279
|
+
tick?: number;
|
|
280
|
+
/** Tops a conversation that has earlier messages still to load. */
|
|
281
|
+
older?: string;
|
|
282
|
+
/** The newest message as the frame before marked it: drawing reaches at least back to it, to measure what arrived below. */
|
|
283
|
+
anchor?: PaneMark;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The newest `need` lines of the conversation, like a chat: your messages as
|
|
288
|
+
* bubbles on the right, the oracle's replies in Markdown on the left, each
|
|
289
|
+
* turn under a line naming its speaker and time, events as centred rules,
|
|
290
|
+
* and while the oracle works its header says so (streaming the reply under
|
|
291
|
+
* it). Only the messages those lines reach are drawn — scrolling back draws
|
|
292
|
+
* more — and each is drawn once; the total is estimated from them until the
|
|
293
|
+
* whole conversation has been drawn. `older` tops a conversation that has
|
|
294
|
+
* earlier messages still to load.
|
|
295
|
+
*/
|
|
296
|
+
export function chatTail(chat: readonly ChatEntry[], width: number, theme: LobbyTheme | undefined, need: number, options: ChatTailOptions = {}): PaneLines {
|
|
297
|
+
const { live, busy = false, tick = 0, older, anchor } = options;
|
|
298
|
+
const parts: string[][] = [];
|
|
299
|
+
let count = 0;
|
|
300
|
+
const add = (lines: string[]) => {
|
|
301
|
+
if (lines.length === 0) return;
|
|
302
|
+
count += lines.length + (parts.length > 0 ? 1 : 0);
|
|
303
|
+
parts.push(lines);
|
|
185
304
|
};
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
last = entry;
|
|
305
|
+
if (live?.trim()) add([speakerLine("oracle", width, theme, `${spinner(tick)} writing`), ...liveLines(live.trim(), width, theme)]);
|
|
306
|
+
else if (busy) add([speakerLine("oracle", width, theme, `${spinner(tick)} working…`)]);
|
|
307
|
+
const { found, reach } = reachOf(chat, anchor);
|
|
308
|
+
let mark: PaneMark | undefined;
|
|
309
|
+
let grew: number | undefined;
|
|
310
|
+
let index = chat.length - 1;
|
|
311
|
+
for (; index >= 0 && (count < need || index >= reach); index -= 1) {
|
|
312
|
+
const entry = chat[index]!;
|
|
313
|
+
add(entryBlock(entry, chat[index - 1], width, theme));
|
|
314
|
+
if (index === chat.length - 1) mark = { entry, below: count };
|
|
315
|
+
if (index === found) grew = count - anchor!.below;
|
|
198
316
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
317
|
+
const drawn = chat.length - 1 - index;
|
|
318
|
+
if (index < 0 && older) add(wrap(paint(theme, "dim", older), width));
|
|
319
|
+
const lines: string[] = [];
|
|
320
|
+
for (let part = parts.length - 1; part >= 0; part -= 1) {
|
|
321
|
+
if (lines.length > 0) lines.push("");
|
|
322
|
+
for (const line of parts[part]!) lines.push(line);
|
|
205
323
|
}
|
|
206
|
-
|
|
324
|
+
// Messages not drawn yet count at the average height of those that were.
|
|
325
|
+
const rest = index + 1;
|
|
326
|
+
const total = rest > 0 && drawn > 0 ? lines.length + Math.ceil((rest * lines.length) / drawn) : lines.length;
|
|
327
|
+
return { lines, total, ...(mark ? { mark } : {}), ...(grew !== undefined ? { grew } : {}) };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The whole conversation drawn (the session browser's preview and tests; the Lobby tab draws only what shows). */
|
|
331
|
+
export function chatLines(chat: readonly ChatEntry[], width: number, theme?: LobbyTheme, live?: string, busy = false, tick = 0): string[] {
|
|
332
|
+
return chatTail(chat, width, theme, Number.POSITIVE_INFINITY, { ...(live ? { live } : {}), busy, tick }).lines;
|
|
207
333
|
}
|
|
208
334
|
|
|
209
335
|
function emptyChat(input: HomeInput, width: number, theme?: LobbyTheme): string[] {
|
|
@@ -237,24 +363,51 @@ export function filterFeed(input: Pick<HomeInput, "chat" | "activity" | "thought
|
|
|
237
363
|
};
|
|
238
364
|
}
|
|
239
365
|
|
|
240
|
-
function
|
|
366
|
+
function whole(lines: string[]): PaneLines {
|
|
367
|
+
return { lines, total: lines.length };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function conversation(input: HomeInput, chat: readonly ChatEntry[], width: number, need: number, anchor: PaneMark | undefined, theme?: LobbyTheme): PaneLines {
|
|
241
371
|
const searching = Boolean(input.query);
|
|
242
372
|
const live = searching ? undefined : input.liveReply;
|
|
243
373
|
const busy = !searching && input.busy;
|
|
244
|
-
|
|
374
|
+
if (chat.length === 0 && !live && !busy) return whole(emptyChat(input, width, theme));
|
|
375
|
+
const older = searching ? undefined : input.olderNote;
|
|
376
|
+
return chatTail(chat, width, theme, need, { ...(live ? { live } : {}), busy, tick: input.tick, ...(older ? { older } : {}), ...(anchor ? { anchor } : {}) });
|
|
245
377
|
}
|
|
246
378
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
379
|
+
/** The newest `need` activity lines (one per entry), drawn only as far back as the pane shows. */
|
|
380
|
+
function activity(input: HomeInput, entries: readonly ActivityEntry[], width: number, need: number, anchor: PaneMark | undefined, theme?: LobbyTheme): PaneLines {
|
|
381
|
+
if (entries.length === 0) return whole(wrap(paint(theme, "dim", input.query ? `No activity matches "${input.query}".` : input.activityNote ?? "No activity yet."), width));
|
|
382
|
+
const shown = Number.isFinite(need) ? entries.slice(-Math.max(0, need)) : entries;
|
|
383
|
+
// One line per entry: where the anchor sits is a count, nothing needs drawing to find it.
|
|
384
|
+
const { found } = reachOf(entries, anchor);
|
|
385
|
+
return {
|
|
386
|
+
lines: shown.map((entry) => activityLine(entry, width, input.tick, theme)),
|
|
387
|
+
total: entries.length,
|
|
388
|
+
mark: { entry: entries.at(-1)!, below: 1 },
|
|
389
|
+
...(found >= 0 ? { grew: entries.length - found - anchor!.below } : {}),
|
|
390
|
+
};
|
|
250
391
|
}
|
|
251
392
|
|
|
252
393
|
/** The last `rows` lines, `offset` lines back from the newest (clamped), and where they start. */
|
|
253
394
|
export function tailWindow(lines: readonly string[], rows: number, offset = 0): { shown: string[]; start: number; offset: number } {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
395
|
+
return paneWindow({ lines: [...lines], total: lines.length }, rows, offset);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* `rows` lines of a pane `offset` lines back from its newest (clamped), when
|
|
400
|
+
* only its newest lines were drawn: where they start in the whole pane, for
|
|
401
|
+
* the scrollbar.
|
|
402
|
+
*/
|
|
403
|
+
export function paneWindow(content: PaneLines, rows: number, offset = 0): { shown: string[]; start: number; offset: number } {
|
|
404
|
+
const { lines } = content;
|
|
405
|
+
const total = Math.max(content.total, lines.length);
|
|
406
|
+
const height = Math.max(0, rows);
|
|
407
|
+
const back = Math.min(Math.max(0, offset), Math.max(0, total - height));
|
|
408
|
+
const end = Math.max(0, lines.length - back);
|
|
409
|
+
const begin = Math.max(0, end - height);
|
|
410
|
+
return { shown: lines.slice(begin, end), start: Math.max(0, total - back - height), offset: back };
|
|
258
411
|
}
|
|
259
412
|
|
|
260
413
|
/** The thought to show: a live one first, otherwise the newest. */
|
|
@@ -262,17 +415,79 @@ export function currentThought(thoughts: readonly ThoughtEntry[]): ThoughtEntry
|
|
|
262
415
|
return [...thoughts].reverse().find((entry) => entry.live) ?? thoughts.at(-1);
|
|
263
416
|
}
|
|
264
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Drawn thoughts, per theme and per thought, kept while the thought's text
|
|
420
|
+
* and the width stay the same (a live thought grows, so it is drawn again as
|
|
421
|
+
* it streams; settled ones are wrapped once).
|
|
422
|
+
*/
|
|
423
|
+
const thoughtBlocks = new WeakMap<object, WeakMap<ThoughtEntry, { width: number; text: string; lines: string[]; at: number }>>();
|
|
424
|
+
|
|
425
|
+
/** 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`. */
|
|
426
|
+
const LONG_THOUGHT = 1000;
|
|
427
|
+
const LONG_THOUGHT_MARKDOWN = 300;
|
|
428
|
+
const LONG_THOUGHT_MS = 150;
|
|
429
|
+
|
|
430
|
+
function thoughtBlock(thought: ThoughtEntry, width: number, theme?: LobbyTheme): string[] {
|
|
431
|
+
let cache = thoughtBlocks.get(theme ?? PLAIN);
|
|
432
|
+
if (!cache) {
|
|
433
|
+
cache = new WeakMap();
|
|
434
|
+
thoughtBlocks.set(theme ?? PLAIN, cache);
|
|
435
|
+
}
|
|
436
|
+
const hit = cache.get(thought);
|
|
437
|
+
const now = performance.now();
|
|
438
|
+
const long = theme?.markdown ? LONG_THOUGHT_MARKDOWN : LONG_THOUGHT;
|
|
439
|
+
if (hit && hit.width === width && (hit.text === thought.text || (thought.live && thought.text.length > long && now - hit.at < LONG_THOUGHT_MS))) return hit.lines;
|
|
440
|
+
const lead = `${paint(theme, sourceColor(thought.source), thought.source.padEnd(SOURCE_WIDTH).slice(0, SOURCE_WIDTH))} `;
|
|
441
|
+
const lines = theme?.markdown ? thoughtMarkdown(lead, thought, width, theme) : wrapHanging(lead, italic(theme, paint(theme, "dim", thought.text.replace(/\s*\n\s*/g, " "))), width);
|
|
442
|
+
cache.set(thought, { width, text: thought.text, lines, at: now });
|
|
443
|
+
return lines;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* A thought in Markdown as pi shows thinking (dimmed, in italics), after who
|
|
448
|
+
* thought it: a model's `**Checking the plan**` reads bold, not starred. The
|
|
449
|
+
* pane is small, so the blank lines between paragraphs are left out. A live
|
|
450
|
+
* thought is not kept in the renderer's cache; it changes as it streams.
|
|
451
|
+
*/
|
|
452
|
+
function thoughtMarkdown(lead: string, thought: ThoughtEntry, width: number, theme: LobbyTheme): string[] {
|
|
453
|
+
const indent = " ".repeat(textWidth(lead));
|
|
454
|
+
const body = markdownLines(thought.text, Math.max(1, width - textWidth(lead)), theme, !thought.live, "thought").filter((line) => line.trim());
|
|
455
|
+
if (body.length === 0) return [lead];
|
|
456
|
+
return body.map((line, index) => `${index === 0 ? lead : indent}${line}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The newest `need` lines of the thoughts, oldest first: only the thoughts they reach are drawn (and back to `anchor`), and the rest estimated. */
|
|
460
|
+
export function thoughtTail(thoughts: readonly ThoughtEntry[], width: number, theme: LobbyTheme | undefined, need: number, anchor?: PaneMark): PaneLines {
|
|
461
|
+
const parts: string[][] = [];
|
|
462
|
+
let count = 0;
|
|
463
|
+
const { found, reach } = reachOf(thoughts, anchor);
|
|
464
|
+
let mark: PaneMark | undefined;
|
|
465
|
+
let grew: number | undefined;
|
|
466
|
+
let index = thoughts.length - 1;
|
|
467
|
+
for (; index >= 0 && (count < need || index >= reach); index -= 1) {
|
|
468
|
+
const thought = thoughts[index]!;
|
|
469
|
+
const lines = thoughtBlock(thought, width, theme);
|
|
470
|
+
parts.push(lines);
|
|
471
|
+
count += lines.length;
|
|
472
|
+
if (index === thoughts.length - 1) mark = { entry: thought, below: count };
|
|
473
|
+
if (index === found) grew = count - anchor!.below;
|
|
474
|
+
}
|
|
475
|
+
const lines: string[] = [];
|
|
476
|
+
for (let part = parts.length - 1; part >= 0; part -= 1) for (const line of parts[part]!) lines.push(line);
|
|
477
|
+
const rest = index + 1;
|
|
478
|
+
const drawn = parts.length;
|
|
479
|
+
const total = rest > 0 && drawn > 0 ? lines.length + Math.ceil((rest * lines.length) / drawn) : lines.length;
|
|
480
|
+
return { lines, total, ...(mark ? { mark } : {}), ...(grew !== undefined ? { grew } : {}) };
|
|
481
|
+
}
|
|
482
|
+
|
|
265
483
|
/** Every thought, oldest first: who thought it, then the thought, dimmed. */
|
|
266
484
|
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
|
-
});
|
|
485
|
+
return thoughtTail(thoughts, width, theme, Number.POSITIVE_INFINITY).lines;
|
|
271
486
|
}
|
|
272
487
|
|
|
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.")];
|
|
488
|
+
function thinkingContent(input: HomeInput, thoughts: readonly ThoughtEntry[], width: number, need: number, anchor: PaneMark | undefined, theme?: LobbyTheme): PaneLines {
|
|
489
|
+
if (thoughts.length > 0) return thoughtTail(thoughts, width, theme, need, anchor);
|
|
490
|
+
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
491
|
}
|
|
277
492
|
|
|
278
493
|
/**
|
|
@@ -295,7 +510,7 @@ function sceneLines(input: HomeInput, width: number, height: number, theme?: Lob
|
|
|
295
510
|
function keyNote(lines: readonly string[], width: number, note: string | undefined, theme?: LobbyTheme): string[] {
|
|
296
511
|
const first = lines[0];
|
|
297
512
|
if (!note || first === undefined) return [...lines];
|
|
298
|
-
const gap = width -
|
|
513
|
+
const gap = width - textWidth(first) - textWidth(note) - 1;
|
|
299
514
|
if (gap < 2) return [...lines];
|
|
300
515
|
return [`${first}${" ".repeat(gap)}${paint(theme, "dim", note)}`, ...lines.slice(1)];
|
|
301
516
|
}
|
|
@@ -328,19 +543,26 @@ export function renderHome(input: HomeInput, width: number, height: number, them
|
|
|
328
543
|
const main = rest - thinkHeight;
|
|
329
544
|
if (!showMain && !panels.thinking) return fill([...scene, ...hiddenHint(input, width, rest, theme)], height, width);
|
|
330
545
|
const matches = (count: number) => `${count} match${count === 1 ? "" : "es"}`;
|
|
331
|
-
/**
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
546
|
+
/**
|
|
547
|
+
* One scrollable pane: only the lines it shows are drawn — its newest, or
|
|
548
|
+
* as far back as it is scrolled — in a box that records where it sits, and
|
|
549
|
+
* how many lines arrived below since the frame before.
|
|
550
|
+
*/
|
|
551
|
+
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[] => {
|
|
552
|
+
const rows = Math.max(0, h - 2);
|
|
553
|
+
const offset = input.offsets?.[name] ?? 0;
|
|
554
|
+
const drawn = content(Math.max(1, w - 4), rows + offset, input.anchors?.get(name));
|
|
555
|
+
const view = paneWindow(drawn, rows, offset);
|
|
556
|
+
const total = Math.max(drawn.total, drawn.lines.length);
|
|
557
|
+
notePane(input.panes, name, top, left, w, h, total, { ...(drawn.mark ? { mark: drawn.mark } : {}), grew: drawn.grew ?? 0 });
|
|
336
558
|
const right = rightNote(view.offset, note);
|
|
337
|
-
return box(w, h, view.shown, { title, ...(right ? { right } : {}), focused: input.focus === name, scroll: { total
|
|
559
|
+
return box(w, h, view.shown, { title, ...(right ? { right } : {}), focused: input.focus === name, scroll: { total, start: view.start }, theme });
|
|
338
560
|
};
|
|
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));
|
|
561
|
+
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));
|
|
562
|
+
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));
|
|
341
563
|
const thought = currentThought(feed.thoughts);
|
|
342
564
|
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));
|
|
565
|
+
const thinkingBox = pane("thinking", "Thinking", thinkingNote, (inner, need, anchor) => thinkingContent(input, feed.thoughts, inner, need, anchor, theme));
|
|
344
566
|
const top = scene.length;
|
|
345
567
|
let body: string[] = [];
|
|
346
568
|
if (main > 0 && panels.conversation && panels.activity) {
|
package/src/lobby/tabs/issues.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* becomes a task.
|
|
5
5
|
*/
|
|
6
6
|
import type { IssueDetail, IssueSummary } from "../issues.ts";
|
|
7
|
-
import { bold, since, columns, fill, paint, rule, selectRow, spinner, split, windowStart, wrap, type LobbyTheme } from "../layout.ts";
|
|
7
|
+
import { bold, since, columns, fill, markdownLines, paint, rule, selectRow, spinner, split, windowStart, wrap, type LobbyTheme } from "../layout.ts";
|
|
8
8
|
|
|
9
9
|
export interface IssuesTabInput {
|
|
10
10
|
issues: readonly IssueSummary[];
|
|
@@ -31,9 +31,10 @@ export function issueDetailLines(issue: IssueDetail, width: number, now: number,
|
|
|
31
31
|
].filter(Boolean);
|
|
32
32
|
const lines = [...wrap(bold(theme, `#${issue.number} ${issue.title}`), width), facts.join(paint(theme, "dim", " · "))];
|
|
33
33
|
if (issue.url) lines.push(paint(theme, "dim", issue.url));
|
|
34
|
-
|
|
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)")]));
|
|
35
36
|
for (const comment of issue.comments) {
|
|
36
|
-
lines.push("", rule(width, comment.author ?? "comment", theme, comment.createdAt ? since(now - Date.parse(comment.createdAt)) : ""), ...
|
|
37
|
+
lines.push("", rule(width, comment.author ?? "comment", theme, comment.createdAt ? since(now - Date.parse(comment.createdAt)) : ""), ...markdownLines(comment.body.trim(), width, theme));
|
|
37
38
|
}
|
|
38
39
|
return lines;
|
|
39
40
|
}
|
package/src/lobby/tabs/plan.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
10
10
|
import { ORACLE_LABEL, type LineComment, type PanelNote, type PanelQuestion, type PlannerMessage, type PlannerReply, type PlannerSeed, type RoundMode } from "../planner.ts";
|
|
11
11
|
import type { PlannedTask } from "../../state/backlog.ts";
|
|
12
|
-
import { beside, bold, box, clock, fill, fit, italic, markdownLines, notePane, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
|
|
12
|
+
import { beside, bold, box, clock, fill, fit, italic, markdownHanging, markdownLines, notePane, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
|
|
13
13
|
import { sourceColor, speakerLine, tailWindow, youLines } from "./home.ts";
|
|
14
14
|
|
|
15
15
|
export interface SeatView {
|
|
@@ -152,7 +152,7 @@ function questionLines(questions: ReadonlyArray<{ question: PanelQuestion; numbe
|
|
|
152
152
|
const lead = `${paint(theme, "dim", `${String(number).padStart(2)}.`)} ${bold(theme, paint(theme, sourceColor(question.from), question.from.padEnd(LABEL_WIDTH)))} `;
|
|
153
153
|
const indent = " ".repeat(4 + LABEL_WIDTH);
|
|
154
154
|
const options = question.options.map((option) => wrapHanging(`${indent}${paint(theme, "dim", "○")} `, `${option.label}${option.description ? paint(theme, "dim", ` — ${option.description}`) : ""}`, width)).flat();
|
|
155
|
-
return [...
|
|
155
|
+
return [...markdownHanging(lead, question.text, width, theme), ...options];
|
|
156
156
|
});
|
|
157
157
|
}
|
|
158
158
|
|
|
@@ -178,7 +178,7 @@ export function conversationLines(view: PlanView, width: number, theme?: LobbyTh
|
|
|
178
178
|
continue;
|
|
179
179
|
}
|
|
180
180
|
lines.push(speakerLine("panel", width, theme, time));
|
|
181
|
-
lines.push(...(questions.length > 0 ? questionLines(questions, width, theme) :
|
|
181
|
+
lines.push(...(questions.length > 0 ? questionLines(questions, width, theme) : markdownLines(message.text, Math.max(1, width - 2), theme).map((line) => (line ? ` ${line}` : ""))));
|
|
182
182
|
for (const entry of message.decided ?? []) {
|
|
183
183
|
lines.push(...wrapHanging(` ${paint(theme, "success", "✓")} `, `${paint(theme, sourceColor(entry.from), `[${entry.from}]`)} ${entry.question} → ${bold(theme, entry.answer)} ${paint(theme, "dim", `· decided by the classifier (${entry.probability.toFixed(2)}); comment on the plan to overrule`)}`, width));
|
|
184
184
|
}
|
|
@@ -210,7 +210,7 @@ export function draftLines(view: PlanView, width: number, theme?: LobbyTheme): D
|
|
|
210
210
|
}
|
|
211
211
|
if (view.notes.length > 0) {
|
|
212
212
|
lines.push(line(""), line(bold(theme, paint(theme, "mdHeading", "What each seat needs"))));
|
|
213
|
-
for (const note of view.notes) for (const wrapped of
|
|
213
|
+
for (const note of view.notes) for (const wrapped of markdownHanging(`${paint(theme, sourceColor(note.from), note.from.padEnd(LABEL_WIDTH))} `, note.text, width, theme)) lines.push(line(wrapped));
|
|
214
214
|
}
|
|
215
215
|
return lines;
|
|
216
216
|
}
|
|
@@ -61,7 +61,9 @@ export function jobDetailLines(job: QuickFixJob, width: number, tick: number, no
|
|
|
61
61
|
job.tools ? `${job.tools} tool${job.tools === 1 ? "" : "s"}` : "",
|
|
62
62
|
job.usage?.cost ? `$${job.usage.cost.toFixed(2)}` : "",
|
|
63
63
|
].filter(Boolean);
|
|
64
|
-
|
|
64
|
+
// What you asked, in Markdown as your messages read in the conversation (a pasted snippet, a list of steps).
|
|
65
|
+
const asked = theme?.markdown ? markdownLines(job.prompt, width, theme, true, "you") : wrap(bold(theme, job.prompt), width);
|
|
66
|
+
const lines = [paint(theme, "dim", job.id), facts.join(paint(theme, "dim", " · ")), "", ...asked];
|
|
65
67
|
if (job.route) lines.push("", ...wrap(paint(theme, "dim", `↓ routed ${job.route}`), width));
|
|
66
68
|
if (job.note) lines.push("", ...wrap(paint(theme, "warning", job.status === "held" ? `‖ Held: ${job.note}. r runs it anyway · t starts it as a task in a new session.` : job.note), width));
|
|
67
69
|
lines.push("", rule(width, "Steps", theme));
|
|
@@ -70,6 +72,10 @@ export function jobDetailLines(job: QuickFixJob, width: number, tick: number, no
|
|
|
70
72
|
const lead = `${paint(theme, "dim", clock(step.at))} ${step.pending && job.status === "running" ? paint(theme, "accent", spinner(tick)) : paint(theme, "dim", "·")} `;
|
|
71
73
|
lines.push(...wrapHanging(lead, step.pending && job.status === "running" ? `${step.text}…` : paint(theme, "muted", step.text), width));
|
|
72
74
|
}
|
|
75
|
+
if (job.files?.length) {
|
|
76
|
+
lines.push("", rule(width, "Edited", theme));
|
|
77
|
+
for (const file of job.files) lines.push(...wrapHanging(paint(theme, "dim", "· "), paint(theme, "muted", file), width));
|
|
78
|
+
}
|
|
73
79
|
if (job.error) lines.push("", ...wrap(paint(theme, "error", `✗ ${job.error}`), width));
|
|
74
80
|
if (job.report) {
|
|
75
81
|
lines.push("", rule(width, "Report", theme), ...markdownLines(job.report, width, theme));
|
package/src/lobby/tabs/tasks.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { planChecklist } from "../../pi/zen.ts";
|
|
|
11
11
|
import { persistedRuns } from "../../pi/ui.ts";
|
|
12
12
|
import { describeRun, runFromLog } from "../../pi/run-summary.ts";
|
|
13
13
|
import { pendingApprovals } from "../../workflow/approvals.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { textWidth } from "../../width.ts";
|
|
15
|
+
import { ago, beside, bold, box, detailWindow, fill, markdownHanging, markdownLines, notePane, paint, position, rule, selectRow, since, spread, strike, windowStart, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
|
|
15
16
|
|
|
16
17
|
export type TaskSection = "mine" | "others" | "pending" | "recent" | "archived";
|
|
17
18
|
|
|
@@ -240,6 +241,13 @@ export function listCount(rows: readonly TaskRow[], query?: string): string {
|
|
|
240
241
|
const COMMENT_MARKS: Record<PlanComment["status"], string> = { open: "○", delivered: "◐", addressed: "✓" };
|
|
241
242
|
const COMMENT_WORDS: Record<PlanComment["status"], string> = { open: "waiting for the owning session", delivered: "sent to the oracle", addressed: "plan amended" };
|
|
242
243
|
|
|
244
|
+
/** `note` at the end of the last line when it fits there, else on a line of its own under it. */
|
|
245
|
+
function trailing(lines: string[], note: string, width: number): string[] {
|
|
246
|
+
const last = lines.at(-1) ?? "";
|
|
247
|
+
if (textWidth(last) + 1 + textWidth(note) <= width) return [...lines.slice(0, -1), `${last} ${note}`];
|
|
248
|
+
return [...lines, ...wrapHanging(" ", note, width)];
|
|
249
|
+
}
|
|
250
|
+
|
|
243
251
|
function section(title: string, width: number, theme?: LobbyTheme, right = ""): string[] {
|
|
244
252
|
return ["", rule(width, title, theme, right)];
|
|
245
253
|
}
|
|
@@ -268,7 +276,8 @@ export function taskDetailLines(task: Task, comments: readonly PlanComment[], se
|
|
|
268
276
|
lines.push("", ` ${pips(done, steps.length, cells, theme)} ${paint(theme, "muted", `${done} of ${steps.length} steps`)}`);
|
|
269
277
|
}
|
|
270
278
|
const request = taskRequest(task);
|
|
271
|
-
|
|
279
|
+
// Requests are often Markdown: a plan agreed in the planning panel, an issue, a pasted spec.
|
|
280
|
+
if (request && request !== task.title) lines.push(...section("Request", width, theme), ...markdownLines(request, width, theme));
|
|
272
281
|
if (task.plan) {
|
|
273
282
|
if (steps.length > 0) {
|
|
274
283
|
lines.push(...section("Progress", width, theme, `${done}/${steps.length} steps`));
|
|
@@ -291,11 +300,12 @@ export function taskDetailLines(task: Task, comments: readonly PlanComment[], se
|
|
|
291
300
|
if (comments.length === 0) lines.push(paint(theme, "dim", TERMINAL_STATES.includes(task.state) ? "No comments." : "No comments yet — press c to comment on the plan; the oracle amends it."));
|
|
292
301
|
for (const comment of comments) {
|
|
293
302
|
const color = comment.status === "addressed" ? "success" : comment.status === "delivered" ? "accent" : "warning";
|
|
294
|
-
|
|
303
|
+
const said = markdownHanging(`${paint(theme, color, COMMENT_MARKS[comment.status])} `, comment.text, width, theme);
|
|
304
|
+
lines.push(...trailing(said, paint(theme, "dim", `— ${COMMENT_WORDS[comment.status]}, ${since(now - Date.parse(comment.createdAt))}`), width));
|
|
295
305
|
}
|
|
296
306
|
if (task.amendments.length > 0) {
|
|
297
307
|
lines.push(...section("Amendments", width, theme));
|
|
298
|
-
for (const amendment of task.amendments) lines.push(...
|
|
308
|
+
for (const amendment of task.amendments) lines.push(...markdownHanging("- ", amendment, width, theme));
|
|
299
309
|
}
|
|
300
310
|
const approvals = pendingApprovals(task);
|
|
301
311
|
if (approvals.length > 0 || task.blockers.length > 0) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi's theme as bot-lobby draws with it (the lobby, the questionnaire), plus
|
|
3
|
+
* Markdown rendering; one wrapper (and one Markdown renderer with its cache)
|
|
4
|
+
* per theme, so caches stay warm and a theme switch starts fresh.
|
|
5
|
+
*/
|
|
6
|
+
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { LobbyTheme } from "./layout.ts";
|
|
8
|
+
import { createMarkdownRenderer } from "./markdown.ts";
|
|
9
|
+
|
|
10
|
+
const lobbyThemes = new WeakMap<Theme, LobbyTheme>();
|
|
11
|
+
|
|
12
|
+
export function lobbyTheme(theme: Theme): LobbyTheme {
|
|
13
|
+
let wrapped = lobbyThemes.get(theme);
|
|
14
|
+
if (!wrapped) {
|
|
15
|
+
wrapped = {
|
|
16
|
+
fg: (color, text) => theme.fg(color, text),
|
|
17
|
+
bold: (text) => theme.bold(text),
|
|
18
|
+
italic: (text) => theme.italic(text),
|
|
19
|
+
bg: (color, text) => theme.bg(color, text),
|
|
20
|
+
// The same looks pi gives your messages and the thinking it shows.
|
|
21
|
+
markdown: createMarkdownRenderer(getMarkdownTheme(), {
|
|
22
|
+
you: { color: (text) => theme.fg("accent", text) },
|
|
23
|
+
thought: { color: (text) => theme.fg("thinkingText", text), italic: true },
|
|
24
|
+
}),
|
|
25
|
+
strike: (text) => theme.strikethrough(text),
|
|
26
|
+
};
|
|
27
|
+
lobbyThemes.set(theme, wrapped);
|
|
28
|
+
}
|
|
29
|
+
return wrapped;
|
|
30
|
+
}
|