@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.
@@ -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
- /** Light Markdown for plans and reports: headings bold without their `#`, everything else wrapped as written. */
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
+ }
@@ -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
- export interface PanelQuestion {
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: string[];
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: string[];
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 = listItems(parts.get("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 ? [] : listItems(parts.get("questions"));
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 asked of the user:\n${questions.map((question) => `- ${question}`).join("\n")}` : "",
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((text) => ({ from: MEMBER_LABELS[outcome.member], text })));
432
- const questions = [...(reply?.questions ?? []).map((text) => ({ from: ORACLE_LABEL, text })), ...seatQuestions];
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) {