@a-t-h-i/bot-lobby 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -51
- package/package.json +5 -2
- package/prompts/panel.md +8 -3
- package/prompts/planner.md +9 -4
- package/src/lobby/ask.ts +226 -0
- package/src/lobby/keys.ts +51 -0
- package/src/lobby/layout.ts +192 -3
- package/src/lobby/markdown.ts +48 -0
- package/src/lobby/planner.ts +121 -14
- package/src/lobby/runtime.ts +158 -7
- package/src/lobby/tabs/home.ts +121 -43
- package/src/lobby/tabs/metrics.ts +155 -42
- package/src/lobby/tabs/plan.ts +117 -35
- package/src/lobby/tabs/quickfix.ts +40 -12
- package/src/lobby/tabs/tasks.ts +46 -12
- package/src/lobby/view.ts +868 -113
- package/src/pi/commands.ts +1 -1
- package/src/pi/settings-ui.ts +65 -1
- package/src/schemas/configuration.ts +38 -4
package/src/lobby/layout.ts
CHANGED
|
@@ -26,7 +26,9 @@ export interface LobbyTheme {
|
|
|
26
26
|
fg(color: LobbyColor, text: string): string;
|
|
27
27
|
bold(text: string): string;
|
|
28
28
|
italic?(text: string): string;
|
|
29
|
-
bg?(color: "selectedBg", text: string): string;
|
|
29
|
+
bg?(color: "selectedBg" | "searchMatchBg", text: string): string;
|
|
30
|
+
/** Render Markdown to styled lines; plain wrapping without it (tests). */
|
|
31
|
+
markdown?(text: string, width: number): string[];
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/** Paint only when a theme is present; tests without one get plain text. */
|
|
@@ -45,7 +47,7 @@ export function italic(theme: LobbyTheme | undefined, text: string): string {
|
|
|
45
47
|
/** Exactly `width` columns: truncated with an ellipsis, or padded with spaces. */
|
|
46
48
|
export function fit(text: string, width: number): string {
|
|
47
49
|
if (width <= 0) return "";
|
|
48
|
-
const cut = visibleWidth(text) > width ? truncateToWidth(text, width) : text;
|
|
50
|
+
const cut = visibleWidth(text) > width ? truncateToWidth(text, width, "…") : text;
|
|
49
51
|
const pad = width - visibleWidth(cut);
|
|
50
52
|
return pad > 0 ? `${cut}${" ".repeat(pad)}` : cut;
|
|
51
53
|
}
|
|
@@ -165,10 +167,197 @@ export function spinner(tick: number): string {
|
|
|
165
167
|
return SPINNER[((tick % SPINNER.length) + SPINNER.length) % SPINNER.length]!;
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
/**
|
|
170
|
+
/** Markdown for plans, reports and replies: the theme's renderer, or headings bold without their `#` and the rest wrapped. */
|
|
169
171
|
export function markdownLines(text: string, width: number, theme?: LobbyTheme): string[] {
|
|
172
|
+
if (theme?.markdown) return theme.markdown(text, width);
|
|
170
173
|
return text.split("\n").flatMap((line) => {
|
|
171
174
|
const heading = /^#{1,6}\s+(.*)$/.exec(line);
|
|
172
175
|
return wrap(heading ? bold(theme, paint(theme, "mdHeading", heading[1]!)) : line, width);
|
|
173
176
|
});
|
|
174
177
|
}
|
|
178
|
+
|
|
179
|
+
/** Markdown behind a hanging lead (`oracle ▸ `): the first line carries the lead, the rest align under it. */
|
|
180
|
+
export function markdownHanging(lead: string, text: string, width: number, theme?: LobbyTheme): string[] {
|
|
181
|
+
const indent = visibleWidth(lead);
|
|
182
|
+
const body = markdownLines(text, Math.max(1, width - indent), theme);
|
|
183
|
+
if (body.length === 0) return [lead];
|
|
184
|
+
return body.map((line, index) => (index === 0 ? `${lead}${line}` : line ? `${" ".repeat(indent)}${line}` : ""));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface BoxOptions {
|
|
188
|
+
title?: string;
|
|
189
|
+
/** Muted text at the right end of the top border. */
|
|
190
|
+
right?: string;
|
|
191
|
+
/** The box that has the keyboard: its border takes the accent colour. */
|
|
192
|
+
focused?: boolean;
|
|
193
|
+
theme?: LobbyTheme;
|
|
194
|
+
/** Content longer than the box: `total` lines, the first shown at `start`; drawn as a thumb on the right border. */
|
|
195
|
+
scroll?: { total: number; start: number };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A scrollable pane as a render laid it out: its box in body cells, and its content against its rows. */
|
|
199
|
+
export interface PaneBox {
|
|
200
|
+
top: number;
|
|
201
|
+
left: number;
|
|
202
|
+
width: number;
|
|
203
|
+
height: number;
|
|
204
|
+
/** Lines of content, and rows showing them at once. */
|
|
205
|
+
total: number;
|
|
206
|
+
rows: number;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Scrollable panes by name, filled in by a tab's render so the lobby can clamp offsets and route the wheel. */
|
|
210
|
+
export type PaneLayout = Map<string, PaneBox>;
|
|
211
|
+
|
|
212
|
+
/** Record a pane that sits at `top`/`left` in the body, `width` × `height`, showing `total` lines. */
|
|
213
|
+
export function notePane(panes: PaneLayout | undefined, name: string, top: number, left: number, width: number, height: number, total: number): void {
|
|
214
|
+
panes?.set(name, { top, left, width, height, total, rows: Math.max(0, height - 2) });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** First line of a top-anchored pane scrolled `offset` lines down, stopping when its last line shows. */
|
|
218
|
+
export function detailWindow(total: number, rows: number, offset: number): number {
|
|
219
|
+
return Math.max(0, Math.min(offset, total - Math.max(1, rows)));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** `12–40/96`: the lines a pane shows out of all it holds. */
|
|
223
|
+
export function position(start: number, rows: number, total: number): string {
|
|
224
|
+
return `${start + 1}–${Math.min(total, start + rows)}/${total}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The rows of a `rows`-tall track that the thumb covers, or undefined when everything fits. */
|
|
228
|
+
export function scrollThumb(total: number, rows: number, start: number): { from: number; to: number } | undefined {
|
|
229
|
+
if (rows <= 0 || total <= rows) return undefined;
|
|
230
|
+
const size = Math.max(1, Math.round((rows * rows) / total));
|
|
231
|
+
const max = total - rows;
|
|
232
|
+
const from = Math.round((Math.min(max, Math.max(0, start)) / max) * (rows - size));
|
|
233
|
+
return { from, to: from + size };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A rounded panel exactly `width` × `height`: the title set into the top
|
|
238
|
+
* border, `content` inside with one column of padding, extra lines cut and
|
|
239
|
+
* missing ones blank. Below 4 columns or 2 rows it degrades to plain lines.
|
|
240
|
+
*/
|
|
241
|
+
export function box(width: number, height: number, content: readonly string[], options: BoxOptions = {}): string[] {
|
|
242
|
+
if (height <= 0) return [];
|
|
243
|
+
if (width < 4 || height < 2) return fill(content, height, Math.max(0, width));
|
|
244
|
+
const { theme, focused } = options;
|
|
245
|
+
const edge = (text: string) => paint(theme, focused ? "borderAccent" : "borderMuted", text);
|
|
246
|
+
const inner = width - 4;
|
|
247
|
+
const title = options.title ? ` ${options.title} ` : "";
|
|
248
|
+
const right = options.right ? ` ${options.right} ` : "";
|
|
249
|
+
let room = width - 2 - visibleWidth(title) - visibleWidth(right);
|
|
250
|
+
const shownRight = room >= 1 ? right : "";
|
|
251
|
+
room = width - 2 - visibleWidth(title) - visibleWidth(shownRight);
|
|
252
|
+
const titled = room >= 1 ? title : fit(title, Math.max(0, width - 3));
|
|
253
|
+
const fillWidth = Math.max(0, width - 2 - visibleWidth(titled) - visibleWidth(shownRight));
|
|
254
|
+
const paintedTitle = titled ? bold(theme, paint(theme, focused ? "accent" : "text", titled)) : "";
|
|
255
|
+
const top = `${edge("╭")}${paintedTitle}${edge("─".repeat(fillWidth))}${shownRight ? paint(theme, "dim", shownRight) : ""}${edge("╮")}`;
|
|
256
|
+
const thumb = options.scroll ? scrollThumb(options.scroll.total, height - 2, options.scroll.start) : undefined;
|
|
257
|
+
const rightEdge = (row: number) => (thumb && row >= thumb.from && row < thumb.to ? paint(theme, focused ? "accent" : "muted", "┃") : edge("│"));
|
|
258
|
+
const rows = fill(content, height - 2).map((line, row) => `${edge("│")} ${fit(line, inner)} ${rightEdge(row)}`);
|
|
259
|
+
return [top, ...rows, `${edge("╰")}${edge("─".repeat(width - 2))}${edge("╯")}`];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Lay boxes out side by side, each already exactly its width and the same height. */
|
|
263
|
+
export function beside(panes: ReadonlyArray<readonly string[]>, gap = " "): string[] {
|
|
264
|
+
const height = Math.max(0, ...panes.map((pane) => pane.length));
|
|
265
|
+
return Array.from({ length: height }, (_, row) => panes.map((pane) => pane[row] ?? "").join(gap));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Escape sequences (CSI, OSC, APC) that take no columns. */
|
|
269
|
+
const ESCAPE = /\x1b(?:\[[0-9;?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|_[^\x07\x1b]*(?:\x07|\x1b\\))/y;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Mark every case-insensitive occurrence of `query` in a styled line with
|
|
273
|
+
* reverse video. Escape sequences are skipped when matching and kept intact,
|
|
274
|
+
* and reverse video is switched off (not reset) so the line's own colours
|
|
275
|
+
* carry on after each match.
|
|
276
|
+
*/
|
|
277
|
+
export function highlight(line: string, query: string): string {
|
|
278
|
+
const needle = query.trim().toLowerCase();
|
|
279
|
+
if (!needle) return line;
|
|
280
|
+
// Visible characters with their raw offsets.
|
|
281
|
+
const chars: Array<{ ch: string; at: number }> = [];
|
|
282
|
+
for (let i = 0; i < line.length; ) {
|
|
283
|
+
ESCAPE.lastIndex = i;
|
|
284
|
+
const escape = ESCAPE.exec(line);
|
|
285
|
+
if (escape) {
|
|
286
|
+
i += escape[0].length;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const point = line.codePointAt(i)!;
|
|
290
|
+
const ch = String.fromCodePoint(point);
|
|
291
|
+
chars.push({ ch, at: i });
|
|
292
|
+
i += ch.length;
|
|
293
|
+
}
|
|
294
|
+
const visible = chars.map((entry) => entry.ch).join("").toLowerCase();
|
|
295
|
+
const starts: Array<[number, number]> = [];
|
|
296
|
+
for (let from = visible.indexOf(needle); from >= 0; from = visible.indexOf(needle, from + needle.length)) starts.push([from, from + needle.length]);
|
|
297
|
+
if (starts.length === 0) return line;
|
|
298
|
+
// Map visible string offsets back to char indexes (they differ only for astral characters).
|
|
299
|
+
const charAt: number[] = [];
|
|
300
|
+
chars.forEach((entry, index) => {
|
|
301
|
+
for (let k = 0; k < entry.ch.length; k++) charAt.push(index);
|
|
302
|
+
});
|
|
303
|
+
let out = "";
|
|
304
|
+
let cursor = 0;
|
|
305
|
+
for (const [start, end] of starts) {
|
|
306
|
+
const from = chars[charAt[start]!]!.at;
|
|
307
|
+
const lastChar = chars[charAt[end - 1]!]!;
|
|
308
|
+
const to = lastChar.at + lastChar.ch.length;
|
|
309
|
+
out += `${line.slice(cursor, from)}\x1b[7m${line.slice(from, to)}\x1b[27m`;
|
|
310
|
+
cursor = to;
|
|
311
|
+
}
|
|
312
|
+
return out + line.slice(cursor);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
|
316
|
+
|
|
317
|
+
/** A horizontal bar `value / max` of `width` cells, with eighth-cell precision at its end. */
|
|
318
|
+
export function bar(value: number, max: number, width: number): string {
|
|
319
|
+
if (width <= 0 || !Number.isFinite(value) || !Number.isFinite(max) || max <= 0 || value <= 0) return "";
|
|
320
|
+
const cells = Math.min(width, (value / max) * width);
|
|
321
|
+
const whole = Math.floor(cells);
|
|
322
|
+
const part = EIGHTHS[Math.round((cells - whole) * 8)] ?? "";
|
|
323
|
+
// A non-zero value always shows at least a sliver.
|
|
324
|
+
return `${"█".repeat(whole)}${part}` || "▏";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** A meter `fraction` full: filled cells over a dim track, exactly `width` wide. */
|
|
328
|
+
export function meter(fraction: number, width: number, fillPaint: (text: string) => string, trackPaint: (text: string) => string): string {
|
|
329
|
+
if (width <= 0) return "";
|
|
330
|
+
const safe = Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0;
|
|
331
|
+
const filled = Math.round(safe * width);
|
|
332
|
+
return `${fillPaint("━".repeat(filled))}${trackPaint("─".repeat(width - filled))}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const SPARKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
336
|
+
|
|
337
|
+
/** A sparkline of the last `width` values, scaled between their min and max. */
|
|
338
|
+
export function sparkline(values: readonly number[], width: number): string {
|
|
339
|
+
const shown = values.filter((value) => Number.isFinite(value)).slice(-Math.max(0, width));
|
|
340
|
+
if (shown.length === 0) return "";
|
|
341
|
+
const low = Math.min(...shown);
|
|
342
|
+
const high = Math.max(...shown);
|
|
343
|
+
const span = high - low;
|
|
344
|
+
return shown.map((value) => SPARKS[span === 0 ? 3 : Math.min(7, Math.floor(((value - low) / span) * 8))]).join("");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* A part-to-whole bar: each segment's share of `width` cells in its own
|
|
349
|
+
* colour, rounded so the segments always fill the bar exactly.
|
|
350
|
+
*/
|
|
351
|
+
export function stackedBar(segments: ReadonlyArray<{ value: number; paint: (text: string) => string }>, width: number): string {
|
|
352
|
+
const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0);
|
|
353
|
+
if (width <= 0 || total <= 0) return "";
|
|
354
|
+
let used = 0;
|
|
355
|
+
let acc = 0;
|
|
356
|
+
return segments.map((segment) => {
|
|
357
|
+
acc += Math.max(0, segment.value);
|
|
358
|
+
const end = Math.round((acc / total) * width);
|
|
359
|
+
const cells = end - used;
|
|
360
|
+
used = end;
|
|
361
|
+
return cells > 0 ? segment.paint("█".repeat(cells)) : "";
|
|
362
|
+
}).join("");
|
|
363
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown for the lobby: pi's own renderer (headings, emphasis, lists, code
|
|
3
|
+
* with syntax highlighting, quotes, tables) under the session's theme, with
|
|
4
|
+
* heading hashes dropped for a cleaner read. Renders are cached per theme,
|
|
5
|
+
* width and text, since the lobby repaints several times a second.
|
|
6
|
+
*/
|
|
7
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Markdown, stripTerminalSequences, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
export type MarkdownRenderer = (text: string, width: number) => string[];
|
|
11
|
+
|
|
12
|
+
const CACHE_LIMIT = 96;
|
|
13
|
+
|
|
14
|
+
/** A heading line as pi renders it: optional styling, then `#`…`######` and a space. */
|
|
15
|
+
const HEADING_HASHES = /^((?:\x1b\[[0-9;]*m)*)#{1,6} ((?:\x1b\[[0-9;]*m)*)/;
|
|
16
|
+
|
|
17
|
+
/** Drop the `### ` pi keeps in front of headings, and make them bold. */
|
|
18
|
+
export function tidyHeading(line: string, bold: (text: string) => string): string {
|
|
19
|
+
if (!/^#{1,6} /.test(stripTerminalSequences(line))) return line;
|
|
20
|
+
const body = line.replace(HEADING_HASHES, "$1$2");
|
|
21
|
+
return bold(body.trimEnd());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A renderer bound to a theme provider. `theme()` is read on every call, so a
|
|
26
|
+
* theme switch re-renders instead of serving stale colours from the cache.
|
|
27
|
+
*/
|
|
28
|
+
export function createMarkdownRenderer(theme: () => MarkdownTheme = getMarkdownTheme): MarkdownRenderer {
|
|
29
|
+
const caches = new WeakMap<object, Map<string, string[]>>();
|
|
30
|
+
return (text, width) => {
|
|
31
|
+
const current = theme();
|
|
32
|
+
let cache = caches.get(current);
|
|
33
|
+
if (!cache) {
|
|
34
|
+
cache = new Map();
|
|
35
|
+
caches.set(current, cache);
|
|
36
|
+
}
|
|
37
|
+
const key = `${width}\0${text}`;
|
|
38
|
+
const hit = cache.get(key);
|
|
39
|
+
if (hit) return hit;
|
|
40
|
+
const lines = new Markdown(text, 0, 0, current).render(Math.max(1, width)).map((line) => tidyHeading(line.trimEnd(), current.bold));
|
|
41
|
+
// Drop the blank lines pi pads the render with at either end.
|
|
42
|
+
while (lines.length > 0 && !lines[0]!.trim()) lines.shift();
|
|
43
|
+
while (lines.length > 0 && !lines.at(-1)!.trim()) lines.pop();
|
|
44
|
+
cache.set(key, lines);
|
|
45
|
+
if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!);
|
|
46
|
+
return lines;
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/lobby/planner.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { PANEL_MEMBERS, type PanelMember } from "../schemas/configuration.ts";
|
|
|
22
22
|
import { truncate } from "../text.ts";
|
|
23
23
|
import type { LobbyFeed } from "./feed.ts";
|
|
24
24
|
import type { QuickFixProfile } from "./quickfix.ts";
|
|
25
|
+
import type { AskResult } from "./ask.ts";
|
|
25
26
|
|
|
26
27
|
/** Seats and the oracle read the repository to ask informed questions; they never edit. */
|
|
27
28
|
export const PLANNER_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
|
|
@@ -41,10 +42,21 @@ const MEMBER_SEATS: Record<PanelMember, string> = {
|
|
|
41
42
|
researcher: "You are RESEARCH: facts outside the repository — libraries and versions, standards, APIs and documentation, known pitfalls and prior art. Use the web tools when you have them and cite a URL for every external claim; ask the user to choose where the options you found really differ.",
|
|
42
43
|
};
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
/** One answer the asker offers: a short label and what choosing it means. */
|
|
46
|
+
export interface PanelOption {
|
|
47
|
+
label: string;
|
|
48
|
+
description: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A question as a seat or the oracle wrote it, with the options it offers (recommended first). */
|
|
52
|
+
export interface AskedQuestion {
|
|
53
|
+
text: string;
|
|
54
|
+
options: PanelOption[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PanelQuestion extends AskedQuestion {
|
|
45
58
|
/** DEV, DESIGN, QA, RESEARCH or ORACLE. */
|
|
46
59
|
from: string;
|
|
47
|
-
text: string;
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
export interface PlannerMessage {
|
|
@@ -59,14 +71,14 @@ export interface PlannerMessage {
|
|
|
59
71
|
export interface PlannerReply {
|
|
60
72
|
status: "grilling" | "ready";
|
|
61
73
|
title?: string;
|
|
62
|
-
questions:
|
|
74
|
+
questions: AskedQuestion[];
|
|
63
75
|
plan?: string;
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
/** A seat's reply: whether its domain is settled, its questions and what the plan must respect. */
|
|
67
79
|
export interface MemberReply {
|
|
68
80
|
status: "open" | "ready";
|
|
69
|
-
questions:
|
|
81
|
+
questions: AskedQuestion[];
|
|
70
82
|
notes: string[];
|
|
71
83
|
}
|
|
72
84
|
|
|
@@ -104,6 +116,8 @@ export interface PlannerDeps {
|
|
|
104
116
|
feed?: LobbyFeed;
|
|
105
117
|
runProcess?: ProcessRunner;
|
|
106
118
|
onChange?: () => void;
|
|
119
|
+
/** Called when a round ends (answered, failed or stopped), so the lobby can put the questions to the user. */
|
|
120
|
+
onRound?: (session: PlanningSession) => void;
|
|
107
121
|
}
|
|
108
122
|
|
|
109
123
|
/** Split a reply into its top-level `## Section` bodies, keyed by lower-case title. */
|
|
@@ -137,6 +151,53 @@ function listItems(body: string | undefined): string[] {
|
|
|
137
151
|
return items;
|
|
138
152
|
}
|
|
139
153
|
|
|
154
|
+
/** `Label — what it means` (or `Label: …`, `Label - …`); bold markers dropped. */
|
|
155
|
+
export function parseOption(text: string): PanelOption {
|
|
156
|
+
const flat = text.replace(/\*\*/g, "").trim();
|
|
157
|
+
const split = /^(.*?)\s+(?:—|–|-)\s+(.+)$/.exec(flat) ?? /^([^:]{1,60}):\s+(.+)$/.exec(flat);
|
|
158
|
+
return split ? { label: split[1]!.trim(), description: split[2]!.trim() } : { label: flat, description: "" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Inline `a) … b) …` choices, when a question carries its options in its own text. */
|
|
162
|
+
function inlineOptions(question: AskedQuestion): AskedQuestion {
|
|
163
|
+
if (question.options.length > 0) return question;
|
|
164
|
+
const parts = question.text.split(/\s(?=[a-d]\)\s)/);
|
|
165
|
+
if (parts.length < 3) return question;
|
|
166
|
+
const options = parts.slice(1).map((part) => parseOption(part.replace(/^[a-d]\)\s+/, "").replace(/[;,.]\s*$/, "")));
|
|
167
|
+
return { text: parts[0]!.trim(), options };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Numbered or bulleted questions, each with the options indented beneath it
|
|
172
|
+
* (` - Users table (Recommended) — follows the user`); continuation lines
|
|
173
|
+
* join the question or the option they follow.
|
|
174
|
+
*/
|
|
175
|
+
export function questionItems(body: string | undefined): AskedQuestion[] {
|
|
176
|
+
if (!body) return [];
|
|
177
|
+
const items: AskedQuestion[] = [];
|
|
178
|
+
let current: AskedQuestion | undefined;
|
|
179
|
+
for (const line of body.split("\n")) {
|
|
180
|
+
const match = /^(\s*)(?:\d+[.)]|[-*])\s+(.*\S)\s*$/.exec(line);
|
|
181
|
+
if (match && (match[1]!.length === 0 || !current)) {
|
|
182
|
+
current = { text: match[2]!.replace(/\*\*/g, ""), options: [] };
|
|
183
|
+
items.push(current);
|
|
184
|
+
} else if (match && current) {
|
|
185
|
+
current.options.push(parseOption(match[2]!));
|
|
186
|
+
} else if (line.trim() && current) {
|
|
187
|
+
const last = current.options.at(-1);
|
|
188
|
+
if (last) last.description = `${last.description} ${line.trim()}`.trim();
|
|
189
|
+
else current.text = `${current.text} ${line.trim()}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return items.map(inlineOptions);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A leading `[SEAT]` tag names who asked; the oracle uses it when it relays a seat. */
|
|
196
|
+
function tagged(question: AskedQuestion, fallback: string): PanelQuestion {
|
|
197
|
+
const tag = /^\[(DEV|DESIGN|QA|RESEARCH|ORACLE)\]\s*/i.exec(question.text);
|
|
198
|
+
return tag ? { ...question, from: tag[1]!.toUpperCase(), text: question.text.slice(tag[0].length) } : { ...question, from: fallback };
|
|
199
|
+
}
|
|
200
|
+
|
|
140
201
|
/**
|
|
141
202
|
* Read the oracle's reply. Missing sections degrade gracefully: no status
|
|
142
203
|
* reads as grilling, and a reply with no sections at all becomes one question
|
|
@@ -147,8 +208,8 @@ export function parsePlannerReply(text: string): PlannerReply {
|
|
|
147
208
|
const status = /\bready\b/i.test(parts.get("status") ?? "") ? "ready" : "grilling";
|
|
148
209
|
const title = parts.get("title")?.split("\n").find((line) => line.trim())?.replace(/^[#*\s]+|[*\s]+$/g, "");
|
|
149
210
|
const plan = parts.get("plan") ?? parts.get("draft plan");
|
|
150
|
-
let questions =
|
|
151
|
-
if (parts.size === 0 && text.trim()) questions = [text.trim()];
|
|
211
|
+
let questions = questionItems(parts.get("questions"));
|
|
212
|
+
if (parts.size === 0 && text.trim()) questions = [{ text: text.trim(), options: [] }];
|
|
152
213
|
return { status, questions, ...(title ? { title } : {}), ...(plan ? { plan } : {}) };
|
|
153
214
|
}
|
|
154
215
|
|
|
@@ -156,13 +217,17 @@ export function parsePlannerReply(text: string): PlannerReply {
|
|
|
156
217
|
export function parseMemberReply(text: string): MemberReply {
|
|
157
218
|
const parts = sections(text);
|
|
158
219
|
const ready = /\bready\b/i.test(parts.get("status") ?? "");
|
|
159
|
-
let questions = ready ? [] :
|
|
160
|
-
if (parts.size === 0 && text.trim()) questions = [text.trim()];
|
|
220
|
+
let questions = ready ? [] : questionItems(parts.get("questions"));
|
|
221
|
+
if (parts.size === 0 && text.trim()) questions = [{ text: text.trim(), options: [] }];
|
|
161
222
|
return { status: ready ? "ready" : "open", questions, notes: listItems(parts.get("notes")) };
|
|
162
223
|
}
|
|
163
224
|
|
|
225
|
+
function optionLines(options: readonly PanelOption[]): string[] {
|
|
226
|
+
return options.map((option) => ` - ${option.label}${option.description ? ` — ${option.description}` : ""}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
164
229
|
function questionLine(question: PanelQuestion, index: number): string {
|
|
165
|
-
return `${index + 1}. [${question.from}] ${question.text}
|
|
230
|
+
return [`${index + 1}. [${question.from}] ${question.text}`, ...optionLines(question.options)].join("\n");
|
|
166
231
|
}
|
|
167
232
|
|
|
168
233
|
/** The conversation, the source issue and the current draft: what every seat and the oracle read. */
|
|
@@ -196,13 +261,26 @@ export function panelSection(outcomes: readonly MemberOutcome[]): string {
|
|
|
196
261
|
const { status, questions, notes } = outcome.reply;
|
|
197
262
|
return [
|
|
198
263
|
`### ${label} — ${status === "ready" ? "READY" : "OPEN"}`,
|
|
199
|
-
questions.length > 0 ? `Questions
|
|
264
|
+
questions.length > 0 ? `Questions for the user:\n${questions.map((question) => [`- ${question.text}`, ...optionLines(question.options)].join("\n")).join("\n")}` : "",
|
|
200
265
|
notes.length > 0 ? `Notes:\n${notes.map((note) => `- ${note}`).join("\n")}` : "",
|
|
201
266
|
].filter(Boolean).join("\n");
|
|
202
267
|
});
|
|
203
268
|
return ["## Panel this round", "", ...blocks].join("\n\n");
|
|
204
269
|
}
|
|
205
270
|
|
|
271
|
+
/** A comment the user left on one line of the draft plan. */
|
|
272
|
+
export interface LineComment {
|
|
273
|
+
/** The line as shown, without styling. */
|
|
274
|
+
line: string;
|
|
275
|
+
text: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Line comments as part of the user's next turn. */
|
|
279
|
+
export function commentBlock(comments: readonly LineComment[]): string {
|
|
280
|
+
if (comments.length === 0) return "";
|
|
281
|
+
return ["Comments on the draft plan:", ...comments.map((comment) => `- On "${comment.line.replace(/\s+/g, " ").trim()}": ${comment.text.trim()}`)].join("\n");
|
|
282
|
+
}
|
|
283
|
+
|
|
206
284
|
/** What the panel said in a round, as the conversation shows it. */
|
|
207
285
|
export function plannerSays(ready: boolean, questions: readonly PanelQuestion[]): string {
|
|
208
286
|
if (ready) return "The panel agrees the plan is clear. Press s to save it as a pending task, or keep refining.";
|
|
@@ -250,6 +328,10 @@ export class PlanningSession {
|
|
|
250
328
|
error?: string;
|
|
251
329
|
turns = 0;
|
|
252
330
|
saved?: PlannedTask;
|
|
331
|
+
/** Questionnaires answered so far for this round's questions, so stopping one resumes where it left off. */
|
|
332
|
+
answered: AskResult[] = [];
|
|
333
|
+
/** Comments on draft lines, sent with the user's next turn. */
|
|
334
|
+
lineComments: LineComment[] = [];
|
|
253
335
|
private controller?: AbortController;
|
|
254
336
|
private readonly deps: PlannerDeps;
|
|
255
337
|
private readonly memberNotes = new Map<PanelMember, string[]>();
|
|
@@ -276,15 +358,36 @@ export class PlanningSession {
|
|
|
276
358
|
return this.seats.has(member);
|
|
277
359
|
}
|
|
278
360
|
|
|
279
|
-
/** Add the user's message (the idea, or answers) and run a round. */
|
|
361
|
+
/** Add the user's message (the idea, or answers), with any line comments, and run a round. */
|
|
280
362
|
async send(text: string): Promise<void> {
|
|
281
|
-
const body = text.trim();
|
|
363
|
+
const body = [text.trim(), commentBlock(this.lineComments)].filter(Boolean).join("\n\n");
|
|
282
364
|
if (!body) return;
|
|
283
365
|
if (this.busy) throw new Error("the panel is still thinking");
|
|
366
|
+
this.lineComments = [];
|
|
284
367
|
this.messages = [...this.messages, { role: "you", text: body, at: Date.now() }];
|
|
285
368
|
await this.turn();
|
|
286
369
|
}
|
|
287
370
|
|
|
371
|
+
/** The round's questions still wait for answers. */
|
|
372
|
+
get awaitingAnswers(): boolean {
|
|
373
|
+
return !this.busy && this.questions.length > 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Comment on one line of the draft. While questions wait for answers the
|
|
378
|
+
* comment rides along with them; otherwise it goes to the panel now.
|
|
379
|
+
* Returns whether a round started.
|
|
380
|
+
*/
|
|
381
|
+
commentOnLine(line: string, text: string): boolean {
|
|
382
|
+
const body = text.trim();
|
|
383
|
+
if (!body || !line.trim()) return false;
|
|
384
|
+
this.lineComments = [...this.lineComments, { line: line.trim(), text: body }];
|
|
385
|
+
this.deps.onChange?.();
|
|
386
|
+
if (this.busy || this.awaitingAnswers) return false;
|
|
387
|
+
void this.send("");
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
288
391
|
/** Start from the seed alone (an issue) without a user message. */
|
|
289
392
|
async open(): Promise<void> {
|
|
290
393
|
if (this.busy || this.messages.length > 0) return;
|
|
@@ -396,6 +499,7 @@ export class PlanningSession {
|
|
|
396
499
|
this.status = "thinking";
|
|
397
500
|
this.error = undefined;
|
|
398
501
|
this.turns += 1;
|
|
502
|
+
this.answered = [];
|
|
399
503
|
this.members = PANEL_MEMBERS.filter((member) => this.seats.has(member)).map((member) => ({ member, status: "thinking", step: "reading the conversation" }));
|
|
400
504
|
this.step = this.members.length > 0 ? "waiting for the panel" : "reading the conversation";
|
|
401
505
|
this.deps.onChange?.();
|
|
@@ -422,14 +526,17 @@ export class PlanningSession {
|
|
|
422
526
|
this.controller = undefined;
|
|
423
527
|
if (this.error) this.deps.feed?.log(ORACLE_LABEL, `planning round failed — ${this.error.split("\n")[0]}`, "error");
|
|
424
528
|
this.deps.onChange?.();
|
|
529
|
+
this.deps.onRound?.(this);
|
|
425
530
|
}
|
|
531
|
+
// Comments left on draft lines during the round go to the panel now, unless questions wait (they ride with the answers).
|
|
532
|
+
if (!this.error && !this.busy && this.questions.length === 0 && this.lineComments.length > 0) await this.send("");
|
|
426
533
|
}
|
|
427
534
|
|
|
428
535
|
/** Merge the seats' and the oracle's answers into the round's questions, verdict and draft. */
|
|
429
536
|
private finishRound(outcomes: readonly MemberOutcome[], lead: RunOutcome): void {
|
|
430
537
|
const reply = lead.status === "success" ? parsePlannerReply(lead.output) : undefined;
|
|
431
|
-
const seatQuestions = outcomes.flatMap((outcome) => (outcome.reply?.questions ?? []).map((
|
|
432
|
-
const questions = [...(reply?.questions ?? []).map((
|
|
538
|
+
const seatQuestions = outcomes.flatMap((outcome) => (outcome.reply?.questions ?? []).map((question) => ({ ...question, from: MEMBER_LABELS[outcome.member] })));
|
|
539
|
+
const questions = [...(reply?.questions ?? []).map((question) => tagged(question, ORACLE_LABEL)), ...seatQuestions];
|
|
433
540
|
const seatsReady = outcomes.every((outcome) => outcome.reply?.status === "ready");
|
|
434
541
|
const ready = Boolean(reply && reply.status === "ready" && seatsReady);
|
|
435
542
|
if (reply) {
|