@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.
@@ -1,14 +1,16 @@
1
1
  /**
2
- * The Metrics tab: how each model performs at each thinking level — how long
3
- * its runs take (mean, median, p90), how often they succeed, how many turns,
4
- * tools and tokens they use, their throughput and cost — across the Master,
5
- * every subagent role, quick fixes and planner turns. Columns drop from the
6
- * right as the terminal narrows.
2
+ * The Metrics tab: how each model performs at each thinking level, as a
3
+ * dashboard — a row of stat tiles, average run time as bars (one hue),
4
+ * success rate as meters with a status icon, where the time goes by agent as
5
+ * a stacked bar with a legend, and the full table underneath (columns drop
6
+ * from the right as the terminal narrows). It covers the Master, every
7
+ * subagent role, quick fixes and planning rounds.
7
8
  */
8
9
  import { shortDuration } from "../../text.ts";
9
10
  import { tokens } from "../../pi/run-summary.ts";
10
11
  import type { GroupBy, MetricGroup, MetricRecord, SortKey, TaskStats, TaskTimeGroup } from "../../state/metrics.ts";
11
- import { bold, fill, fit, paint, rule, selectRow, windowStart, type LobbyTheme } from "../layout.ts";
12
+ import { bar, beside, bold, box, fill, fit, meter, notePane, paint, selectRow, sparkline, stackedBar, windowStart, wrap, type LobbyTheme, type PaneLayout } from "../layout.ts";
13
+ import { sourceColor } from "./home.ts";
12
14
 
13
15
  export interface MetricsTabInput {
14
16
  groups: readonly MetricGroup[];
@@ -19,6 +21,10 @@ export interface MetricsTabInput {
19
21
  by: GroupBy;
20
22
  sort: SortKey;
21
23
  selected: number;
24
+ /** The search in force; records and groups are already narrowed by it. */
25
+ query?: string;
26
+ /** Filled with where the table landed, for the wheel. */
27
+ panes?: PaneLayout;
22
28
  }
23
29
 
24
30
  interface Column {
@@ -111,52 +117,159 @@ export function tableLines(input: MetricsTabInput, width: number, theme?: LobbyT
111
117
  return { header: bold(theme, paint(theme, "muted", fit(head, width))), rows };
112
118
  }
113
119
 
114
- function summary(input: MetricsTabInput, theme?: LobbyTheme): string[] {
115
- const { stats, records } = input;
120
+ /** A stat tile: the label in its border, the value large, one line of context. */
121
+ function tile(width: number, label: string, value: string, sub: string, theme?: LobbyTheme): string[] {
122
+ return box(width, 4, [bold(theme, paint(theme, "accent", value)), paint(theme, "dim", sub)], { title: label, theme });
123
+ }
124
+
125
+ function timed(records: readonly MetricRecord[]): MetricRecord[] {
126
+ return records.filter((record) => record.status !== "cancelled" && record.durationMs > 0);
127
+ }
128
+
129
+ /** The KPI row: runs (with recent run times), success, average run, cost, tasks. */
130
+ export function tileRow(input: MetricsTabInput, width: number, theme?: LobbyTheme): string[] {
131
+ const { records, stats } = input;
132
+ const done = timed(records);
133
+ const ok = records.filter((record) => record.status === "success").length;
134
+ const stalls = records.filter((record) => record.stalled || record.status === "timeout").length;
116
135
  const cost = records.reduce((sum, record) => sum + (record.cost ?? 0), 0);
117
- const tasks = [
118
- `${stats.completed} completed${stats.completed > 0 && stats.avgCompleteMs > 0 ? ` (avg ${shortDuration(stats.avgCompleteMs)} from request to done)` : ""}`,
119
- `${stats.active} active`,
120
- `${stats.abandoned} abandoned`,
121
- ].join(" · ");
122
- return [
123
- `${bold(theme, "Tasks")} ${tasks}`,
124
- `${bold(theme, "Runs")} ${records.length} recorded · ${money(cost)} total · grouped by ${input.by === "model" ? "model + thinking" : "model + thinking + agent"} · sorted by ${input.sort}`,
125
- ];
126
- }
127
-
128
- /** Per-agent averages across every model, a quick read of where the time goes. */
129
- function agentLine(records: readonly MetricRecord[], theme?: LobbyTheme): string {
136
+ const avg = done.length > 0 ? done.reduce((sum, record) => sum + record.durationMs, 0) / done.length : 0;
137
+ const sorted = done.map((record) => record.durationMs).sort((a, b) => a - b);
138
+ const p90 = sorted.length > 0 ? sorted[Math.min(sorted.length - 1, Math.ceil(0.9 * sorted.length) - 1)]! : 0;
139
+ const count = width >= 100 ? 5 : width >= 60 ? 3 : 2;
140
+ const tileWidth = Math.floor((width - (count - 1)) / count);
141
+ const rate = records.length > 0 ? ok / records.length : 0;
142
+ const tiles = [
143
+ tile(tileWidth, "Runs", String(records.length), sparkline(done.map((record) => record.durationMs), tileWidth - 4) || "no runs yet", theme),
144
+ tile(tileWidth, "Success", records.length > 0 ? `${status(rate).icon} ${Math.round(rate * 100)}%` : "—", `${records.length - ok} failed${stalls > 0 ? ` · ${stalls} stalled` : ""}`, theme),
145
+ tile(tileWidth, "Avg run", duration(avg), `p90 ${duration(p90)}`, theme),
146
+ tile(tileWidth, "Cost", money(cost), records.length > 0 ? `${money(cost / records.length)} per run` : "—", theme),
147
+ tile(tileWidth, "Tasks", `${stats.completed} done`, stats.avgCompleteMs > 0 ? `avg ${shortDuration(stats.avgCompleteMs)} · ${stats.active} active` : `${stats.active} active`, theme),
148
+ ].slice(0, count);
149
+ return beside(tiles);
150
+ }
151
+
152
+ /** Success thresholds, each with an icon so the state never rests on colour alone. */
153
+ function status(rate: number): { icon: string; color: "success" | "warning" | "error"; word: string } {
154
+ if (rate >= 0.9) return { icon: "✓", color: "success", word: "healthy" };
155
+ if (rate >= 0.7) return { icon: "!", color: "warning", word: "shaky" };
156
+ return { icon: "✗", color: "error", word: "failing" };
157
+ }
158
+
159
+ /** Room for `model · thinking` beside a chart's marks: about a third of it, 10 to 26 columns. */
160
+ function chartLabelWidth(inner: number): number {
161
+ return Math.min(26, Math.max(10, Math.floor(inner * 0.34)));
162
+ }
163
+
164
+ function groupLabel(group: MetricGroup, by: GroupBy): string {
165
+ const agent = by === "model-kind" ? ` · ${agentsCell(group, by)}` : "";
166
+ return `${group.model} · ${group.thinking}${agent}`;
167
+ }
168
+
169
+ /** Average run time per model and thinking level: one hue, longest first, value and p90 beside each bar. */
170
+ export function timeBars(groups: readonly MetricGroup[], by: GroupBy, width: number, height: number, theme?: LobbyTheme): string[] {
171
+ const inner = width - 4;
172
+ const rows = [...groups].filter((group) => group.avgMs > 0).sort((a, b) => b.avgMs - a.avgMs).slice(0, Math.max(0, height - 2));
173
+ const labelWidth = chartLabelWidth(inner);
174
+ // The p90 goes first when room runs out.
175
+ const withP90 = inner - labelWidth >= 34;
176
+ const valueWidth = withP90 ? 19 : 7;
177
+ const barWidth = Math.max(4, inner - labelWidth - valueWidth - 2);
178
+ const max = Math.max(...rows.map((group) => group.avgMs), 1);
179
+ const lines = rows.map((group) => {
180
+ const value = `${duration(group.avgMs).padStart(7)}${withP90 ? ` ${paint(theme, "dim", `p90 ${duration(group.p90Ms)}`)}` : ""}`;
181
+ return `${paint(theme, "muted", fit(groupLabel(group, by), labelWidth))} ${fit(paint(theme, "accent", bar(group.avgMs, max, barWidth)), barWidth)} ${value}`;
182
+ });
183
+ return box(width, height, lines.length > 0 ? lines : [paint(theme, "dim", "No timed runs yet.")], { title: "Average run time", right: "per model · thinking", theme });
184
+ }
185
+
186
+ /** Success rate per model and thinking level: a meter, an icon and the number. */
187
+ export function successMeters(groups: readonly MetricGroup[], by: GroupBy, width: number, height: number, theme?: LobbyTheme): string[] {
188
+ const inner = width - 4;
189
+ const rows = [...groups].sort((a, b) => a.successes / a.runs - b.successes / b.runs).slice(0, Math.max(0, height - 2));
190
+ const labelWidth = chartLabelWidth(inner);
191
+ const meterWidth = Math.max(4, inner - labelWidth - 12);
192
+ const lines = rows.map((group) => {
193
+ const rate = group.runs > 0 ? group.successes / group.runs : 0;
194
+ const state = status(rate);
195
+ const paintFill = (text: string) => paint(theme, state.color, text);
196
+ const paintTrack = (text: string) => paint(theme, "borderMuted", text);
197
+ return `${paint(theme, "muted", fit(groupLabel(group, by), labelWidth))} ${meter(rate, meterWidth, paintFill, paintTrack)} ${paint(theme, state.color, state.icon)} ${`${Math.round(rate * 100)}%`.padStart(4)} ${paint(theme, "dim", `${group.runs}`)}`;
198
+ });
199
+ return box(width, height, lines.length > 0 ? lines : [paint(theme, "dim", "No runs yet.")], { title: "Success rate", right: "✓ ≥90% · ! ≥70% · ✗ below", theme });
200
+ }
201
+
202
+ /** Where the time goes: each agent's share of all run time, then how long a task takes per oracle model. */
203
+ export function timeShare(records: readonly MetricRecord[], taskTimes: readonly TaskTimeGroup[] | undefined, width: number, theme?: LobbyTheme): string[] {
204
+ const inner = width - 4;
130
205
  const byAgent = new Map<string, { n: number; ms: number }>();
131
- for (const record of records) {
132
- if (record.status === "cancelled" || record.durationMs <= 0) continue;
206
+ for (const record of timed(records)) {
133
207
  const entry = byAgent.get(record.agent) ?? { n: 0, ms: 0 };
134
208
  entry.n += 1;
135
209
  entry.ms += record.durationMs;
136
210
  byAgent.set(record.agent, entry);
137
211
  }
138
- const parts = [...byAgent.entries()].sort((a, b) => b[1].n - a[1].n).map(([agent, entry]) => `${agent} ${shortDuration(entry.ms / entry.n)} ${paint(theme, "dim", `×${entry.n}`)}`);
139
- return parts.length > 0 ? `${bold(theme, "Avg by agent")} ${parts.join(paint(theme, "dim", " · "))}` : "";
140
- }
141
-
142
- /** One line: how long a task takes on each oracle model, request to done. */
143
- function taskTimeLine(groups: readonly TaskTimeGroup[] | undefined, theme?: LobbyTheme): string {
144
- if (!groups || groups.length === 0) return "";
145
- const parts = groups.map((group) => `${group.model} ${paint(theme, "dim", group.thinking)} ${shortDuration(group.avgMs)} ${paint(theme, "dim", `×${group.tasks}`)}`);
146
- return `${bold(theme, "Task time by oracle")} ${parts.join(paint(theme, "dim", " · "))}`;
212
+ const agents = [...byAgent.entries()].sort((a, b) => b[1].ms - a[1].ms);
213
+ const total = agents.reduce((sum, [, entry]) => sum + entry.ms, 0);
214
+ const lines: string[] = [];
215
+ if (total > 0) {
216
+ lines.push(stackedBar(agents.map(([agent, entry]) => ({ value: entry.ms, paint: (text: string) => paint(theme, sourceColor(agent), text) })), inner));
217
+ const legend = agents.map(([agent, entry]) => `${paint(theme, sourceColor(agent), "■")} ${agent} ${Math.round((entry.ms / total) * 100)}% ${paint(theme, "dim", `avg ${shortDuration(entry.ms / entry.n)} ×${entry.n}`)}`);
218
+ lines.push(...wrap(legend.join(" "), inner));
219
+ } else lines.push(paint(theme, "dim", "No timed runs yet."));
220
+ if (taskTimes && taskTimes.length > 0) {
221
+ const max = Math.max(...taskTimes.map((group) => group.avgMs), 1);
222
+ const labelWidth = chartLabelWidth(inner);
223
+ const barWidth = Math.max(4, inner - labelWidth - 18);
224
+ lines.push("", bold(theme, "Request to done, by the oracle's model"));
225
+ for (const group of taskTimes) {
226
+ lines.push(`${paint(theme, "muted", fit(`${group.model} · ${group.thinking}`, labelWidth))} ${fit(paint(theme, "accent", bar(group.avgMs, max, barWidth)), barWidth)} ${shortDuration(group.avgMs).padStart(7)} ${paint(theme, "dim", `×${group.tasks}`)}`);
227
+ }
228
+ }
229
+ return box(width, lines.length + 2, lines, { title: "Where the time goes", right: "share of run time by agent", theme });
147
230
  }
148
231
 
232
+ /** The metrics dashboard: KPI tiles, the charts, and the full table filling what is left. */
149
233
  export function renderMetrics(input: MetricsTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
150
234
  if (height <= 0) return [];
151
- const head = [...summary(input, theme), taskTimeLine(input.taskTimes, theme), agentLine(input.records, theme)].filter(Boolean);
152
- if (input.groups.length === 0) {
153
- return fill([
154
- ...head, "", rule(width, "Model performance", theme),
155
- paint(theme, "dim", "No runs recorded yet. Every Master turn, subagent run, quick fix and planning round lands here with its model, thinking level, time, tokens and cost."),
156
- ], height, width);
235
+ if (input.records.length === 0) {
236
+ const empty = input.query ? `No run matches "${input.query}".` : "No runs recorded yet. Every Master turn, subagent run, quick fix and planning round lands here with its model, thinking level, time, tokens and cost.";
237
+ return fill([...(height >= 8 ? tileRow(input, width, theme) : []), ...box(width, Math.max(3, height - (height >= 8 ? 4 : 0)), [paint(theme, "dim", empty)], { title: "Model performance", theme })], height, width);
238
+ }
239
+ const sections: string[] = [];
240
+ let room = height;
241
+ if (room >= 16) {
242
+ sections.push(...tileRow(input, width, theme));
243
+ room -= 4;
244
+ }
245
+ const chartHeight = Math.min(input.groups.length + 2, 8);
246
+ if (room - chartHeight >= 9) {
247
+ if (width >= 100) {
248
+ const left = Math.floor((width - 1) / 2);
249
+ sections.push(...beside([timeBars(input.groups, input.by, left, chartHeight, theme), successMeters(input.groups, input.by, width - 1 - left, chartHeight, theme)]));
250
+ room -= chartHeight;
251
+ } else {
252
+ sections.push(...timeBars(input.groups, input.by, width, chartHeight, theme));
253
+ room -= chartHeight;
254
+ }
157
255
  }
158
- const table = tableLines(input, width, theme);
159
- const bodyHeight = Math.max(0, height - head.length - 3);
160
- const start = windowStart(input.selected, table.rows.length, bodyHeight);
161
- return fill([...head, "", rule(width, "Model performance", theme, "g group · s sort"), table.header, ...table.rows.slice(start, start + bodyHeight)], height, width);
256
+ const share = timeShare(input.records, input.taskTimes, width, theme);
257
+ if (room - share.length >= 6) {
258
+ sections.push(...share);
259
+ room -= share.length;
260
+ }
261
+ const table = tableLines(input, width - 4, theme);
262
+ const tableRows = Math.max(0, room - 3);
263
+ const start = windowStart(input.selected, table.rows.length, tableRows);
264
+ const grouping = input.by === "model" ? "by model · thinking" : "by model · thinking · agent";
265
+ notePane(input.panes, "table", sections.length, 0, width, room, table.rows.length);
266
+ sections.push(...box(width, room, [table.header, ...table.rows.slice(start, start + tableRows)], { title: "All models", right: `${grouping} · sorted by ${input.sort} · g s`, scroll: { total: table.rows.length + 1, start }, theme }));
267
+ return fill(sections, height, width);
268
+ }
269
+
270
+ /** Runs whose agent, model, thinking level, kind, status or task mention `query`. */
271
+ export function filterRecords(records: readonly MetricRecord[], query: string | undefined): MetricRecord[] {
272
+ const needle = query?.trim().toLowerCase();
273
+ if (!needle) return [...records];
274
+ return records.filter((record) => [record.agent, record.model ?? "", record.thinking ?? "", record.kind, record.status, record.taskId ?? ""].join(" ").toLowerCase().includes(needle));
162
275
  }
@@ -2,13 +2,15 @@
2
2
  * The Plan tab: task planning mode with a planning panel. The roster shows
3
3
  * each seat — the oracle chairing, DEV, DESIGN, QA and RESEARCH — and what it
4
4
  * is doing; the conversation shows every seat's questions, attributed, with
5
- * your answers; the other pane shows the draft plan converging and what each
6
- * seat said the plan must respect.
5
+ * their options and your answers; the draft pane shows the plan rendered as
6
+ * Markdown, with a line cursor for commenting on a line, and what each seat
7
+ * said the plan must respect.
7
8
  */
8
- import { ORACLE_LABEL, type PanelNote, type PanelQuestion, type PlannerMessage, type PlannerReply, type PlannerSeed } from "../planner.ts";
9
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
10
+ import { ORACLE_LABEL, type LineComment, type PanelNote, type PanelQuestion, type PlannerMessage, type PlannerReply, type PlannerSeed } from "../planner.ts";
9
11
  import type { PlannedTask } from "../../state/backlog.ts";
10
- import { bold, columns, fill, markdownLines, paint, rule, spinner, split, tail, wrap, wrapHanging, type LobbyTheme } from "../layout.ts";
11
- import { sourceColor } from "./home.ts";
12
+ import { beside, bold, box, fill, italic, markdownLines, notePane, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
13
+ import { sourceColor, tailWindow } from "./home.ts";
12
14
 
13
15
  export interface SeatView {
14
16
  label: string;
@@ -36,6 +38,25 @@ export interface PlanView {
36
38
  seed?: PlannerSeed;
37
39
  saved?: PlannedTask;
38
40
  title?: string;
41
+ /** The round's questions wait for answers. */
42
+ awaitingAnswers: boolean;
43
+ /** Questionnaires already answered for this round. */
44
+ answeredChunks: number;
45
+ lineComments: readonly LineComment[];
46
+ }
47
+
48
+ /** Where the draft pane landed, so the lobby can map a click or the cursor to a line. */
49
+ export interface PlanLayout {
50
+ /** First row of the draft's text inside the tab body, and its left column. */
51
+ draftTop: number;
52
+ draftLeft: number;
53
+ draftWidth: number;
54
+ /** Rows of draft text visible at once. */
55
+ draftRows: number;
56
+ /** Index of the first draft line shown. */
57
+ draftStart: number;
58
+ /** Every draft line, unstyled, in order. */
59
+ draftText: string[];
39
60
  }
40
61
 
41
62
  export interface PlanTabInput {
@@ -47,8 +68,15 @@ export interface PlanTabInput {
47
68
  offset: number;
48
69
  focus: "talk" | "draft";
49
70
  draftOffset: number;
71
+ /** The draft line the cursor is on, while the draft has the focus. */
72
+ cursor?: number;
50
73
  tick: number;
51
74
  notice?: string;
75
+ query?: string;
76
+ /** Filled with the draft pane's geometry during render. */
77
+ layout?: PlanLayout;
78
+ /** Filled with where the conversation (`talk`) and the draft landed, for scrolling. */
79
+ panes?: PaneLayout;
52
80
  }
53
81
 
54
82
  export const PLAN_COLUMNS_MIN = 100;
@@ -61,11 +89,13 @@ function intro(input: PlanTabInput, width: number, theme?: LobbyTheme): string[]
61
89
  const text = [
62
90
  bold(theme, "Plan a task with the whole team before anyone writes code."),
63
91
  "",
64
- `Describe what you want below. The planning panel — the oracle chairing${seats.length > 0 ? `, with ${seats.join(", ")}` : ""} — reads the codebase and grills you, each seat from its own domain: contracts and data, flows and states, acceptance criteria and tests, libraries and prior art. You answer everyone in one conversation, and the oracle folds every answer into the draft plan, so all the agents start from the same decisions.`,
92
+ `Describe what you want below. The planning panel — the oracle chairing${seats.length > 0 ? `, with ${seats.join(", ")}` : ""} — reads the codebase and questions you, each seat from its own domain: contracts and data, flows and states, acceptance criteria and tests, libraries and prior art.`,
93
+ "",
94
+ "After each round the oracle puts the panel's questions to you one at a time, each with options and room for your own answer, and folds every answer into the draft plan, so all the agents start from the same decisions.",
65
95
  "",
66
- "When the panel agrees the plan is clear, press s to save it to the pending tasks list; start it from the Tasks tab whenever you like. To plan a GitHub issue, pick it in the Issues tab and press p. While browsing (esc), 1-4 seat or unseat DEV, DESIGN, QA and RESEARCH.",
96
+ "Comment on any line of the draft: enter moves to the draft, ↑↓ pick a line (or click it), c comments. When the panel agrees, s saves the plan to the pending tasks list. While browsing (esc), 1-4 seat or unseat DEV, DESIGN, QA and RESEARCH.",
67
97
  "",
68
- paint(theme, "dim", `oracle: ${input.profile}`),
98
+ paint(theme, "dim", `oracle: ${input.profile} (m changes it; each seat uses its domain's settings, alt+s)`),
69
99
  ...members.map((seat) => paint(theme, "dim", `${seat.label.toLowerCase()}: ${seat.seated ? seat.profile ?? "" : "not seated"}`)),
70
100
  ];
71
101
  return text.flatMap((line) => wrap(line, width));
@@ -76,7 +106,11 @@ function statusLine(view: PlanView, input: PlanTabInput, theme?: LobbyTheme): st
76
106
  if (view.busy) parts.push(`${paint(theme, "accent", spinner(input.tick))} round ${view.turns}`);
77
107
  else if (view.error) parts.push(paint(theme, "error", `✗ ${view.error.split("\n")[0]} — esc, then r retries`));
78
108
  else if (view.reply?.status === "ready") parts.push(paint(theme, "success", "✓ READY — the panel agrees; s saves it as a pending task"));
79
- else if (view.messages.some((message) => message.role === "planner")) parts.push(paint(theme, "warning", `● GRILLING · ${view.questions.length} open question${view.questions.length === 1 ? "" : "s"}`));
109
+ else if (view.awaitingAnswers) {
110
+ const left = view.answeredChunks > 0 ? " (some answered — enter resumes)" : " — enter answers them one at a time";
111
+ parts.push(paint(theme, "warning", `● ${view.questions.length} question${view.questions.length === 1 ? "" : "s"} for you${left}`));
112
+ }
113
+ if (view.lineComments.length > 0) parts.push(paint(theme, "accent", `◆ ${view.lineComments.length} line comment${view.lineComments.length === 1 ? "" : "s"} to send`));
80
114
  if (view.saved) parts.push(paint(theme, "success", `saved as ${view.saved.id}`));
81
115
  if (!view.busy && view.turns > 0) parts.push(paint(theme, "dim", `round ${view.turns}`));
82
116
  return parts.join(paint(theme, "dim", " · ")) || paint(theme, "dim", "Answer below; enter sends.");
@@ -99,36 +133,67 @@ export function rosterLines(view: PlanView, width: number, tick: number, theme?:
99
133
  return wrap(`${paint(theme, "dim", "panel")} ${cells.join(paint(theme, "dim", " · "))}`, width);
100
134
  }
101
135
 
102
- function questionLines(questions: readonly PanelQuestion[], width: number, theme?: LobbyTheme): string[] {
103
- return questions.flatMap((question, index) => {
104
- const lead = `${paint(theme, "dim", `${String(index + 1).padStart(2)}.`)} ${bold(theme, paint(theme, sourceColor(question.from), question.from.padEnd(LABEL_WIDTH)))} `;
105
- return wrapHanging(lead, question.text, width);
136
+ /** Each question numbered as the panel asked it (a search may show only some), with its options beneath. */
137
+ function questionLines(questions: ReadonlyArray<{ question: PanelQuestion; number: number }>, width: number, theme?: LobbyTheme): string[] {
138
+ return questions.flatMap(({ question, number }) => {
139
+ const lead = `${paint(theme, "dim", `${String(number).padStart(2)}.`)} ${bold(theme, paint(theme, sourceColor(question.from), question.from.padEnd(LABEL_WIDTH)))} `;
140
+ const indent = " ".repeat(4 + LABEL_WIDTH);
141
+ const options = question.options.map((option) => wrapHanging(`${indent}${paint(theme, "dim", "○")} `, `${option.label}${option.description ? paint(theme, "dim", ` — ${option.description}`) : ""}`, width)).flat();
142
+ return [...wrapHanging(lead, question.text, width), ...options];
106
143
  });
107
144
  }
108
145
 
109
- export function conversationLines(view: PlanView, width: number, theme?: LobbyTheme): string[] {
146
+ function matches(text: string, query: string | undefined): boolean {
147
+ return !query || text.toLowerCase().includes(query.toLowerCase());
148
+ }
149
+
150
+ export function conversationLines(view: PlanView, width: number, theme?: LobbyTheme, query?: string): string[] {
110
151
  const lines: string[] = [];
111
152
  if (view.seed) {
112
153
  lines.push(...wrap(paint(theme, "dim", `from issue #${view.seed.issue.number} — ${view.seed.issue.title}`), width), "");
113
154
  }
114
155
  for (const message of view.messages) {
156
+ const questions = (message.questions ?? [])
157
+ .map((question, index) => ({ question, number: index + 1 }))
158
+ .filter(({ question }) => matches(`${question.from} ${question.text} ${question.options.map((option) => option.label).join(" ")}`, query));
159
+ if (message.role === "you" ? !matches(message.text, query) : message.questions?.length ? questions.length === 0 : !matches(message.text, query)) continue;
115
160
  if (lines.length > 0 && lines.at(-1) !== "") lines.push("");
116
161
  if (message.role === "you") {
117
162
  lines.push(...wrapHanging(`${bold(theme, paint(theme, "accent", "you"))} ${paint(theme, "dim", "▸")} `, message.text, width));
118
163
  continue;
119
164
  }
120
165
  lines.push(`${bold(theme, paint(theme, "toolTitle", "panel"))} ${paint(theme, "dim", "▸")}`);
121
- lines.push(...(message.questions && message.questions.length > 0 ? questionLines(message.questions, width, theme) : wrap(message.text, width)));
166
+ lines.push(...(questions.length > 0 ? questionLines(questions, width, theme) : wrap(message.text, width)));
122
167
  }
168
+ if (query && lines.length === 0) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${query}".`), width);
123
169
  return lines;
124
170
  }
125
171
 
126
- export function draftLines(view: PlanView, width: number, theme?: LobbyTheme): string[] {
172
+ interface DraftLine {
173
+ text: string;
174
+ plain: string;
175
+ }
176
+
177
+ /** The draft as lines: the plan in Markdown, commented lines marked with their comments beneath, then each seat's needs. */
178
+ export function draftLines(view: PlanView, width: number, theme?: LobbyTheme): DraftLine[] {
127
179
  const plan = view.reply?.plan;
128
- const lines = plan ? markdownLines(plan, width, theme) : wrap(paint(theme, "dim", view.busy ? "The draft appears after the panel's first round." : "No draft yet."), width);
129
- if (view.notes.length === 0) return lines;
130
- lines.push("", rule(width, "What each seat needs", theme));
131
- for (const note of view.notes) lines.push(...wrapHanging(`${paint(theme, sourceColor(note.from), note.from.padEnd(LABEL_WIDTH))} `, note.text, width));
180
+ const line = (text: string): DraftLine => ({ text, plain: stripTerminalSequences(text).trimEnd() });
181
+ if (!plan) return wrap(paint(theme, "dim", view.busy ? "The draft appears after the panel's first round." : "No draft yet."), width).map(line);
182
+ const comments = new Map<string, string[]>();
183
+ for (const comment of view.lineComments) comments.set(comment.line, [...(comments.get(comment.line) ?? []), comment.text]);
184
+ const lines: DraftLine[] = [];
185
+ for (const rendered of markdownLines(plan, Math.max(1, width - 2), theme)) {
186
+ const entry = line(rendered);
187
+ const notes = comments.get(entry.plain.trim());
188
+ lines.push({ text: `${notes ? paint(theme, "warning", "◆") : " "} ${rendered}`, plain: entry.plain });
189
+ for (const note of notes ?? []) {
190
+ for (const wrapped of wrap(italic(theme, paint(theme, "accent", `↳ ${note}`)), Math.max(1, width - 4))) lines.push({ text: ` ${wrapped}`, plain: entry.plain });
191
+ }
192
+ }
193
+ if (view.notes.length > 0) {
194
+ lines.push(line(""), line(bold(theme, paint(theme, "mdHeading", "What each seat needs"))));
195
+ for (const note of view.notes) for (const wrapped of wrapHanging(`${paint(theme, sourceColor(note.from), note.from.padEnd(LABEL_WIDTH))} `, note.text, width)) lines.push(line(wrapped));
196
+ }
132
197
  return lines;
133
198
  }
134
199
 
@@ -136,25 +201,42 @@ export function renderPlan(input: PlanTabInput, width: number, height: number, t
136
201
  if (height <= 0) return [];
137
202
  const notice = input.notice ? [paint(theme, "accent", input.notice)] : [];
138
203
  const view = input.session;
139
- if (!view) return fill([...notice, rule(width, "Plan", theme, input.profile), ...intro(input, width, theme)], height, width);
204
+ if (!view) return fill([...notice, ...box(width, height - notice.length, intro(input, width - 4, theme), { title: "Plan", right: input.profile, theme })], height, width);
140
205
  const header = [
141
206
  ...notice,
142
- rule(width, view.title ? `Planning · ${view.title}` : "Planning", theme, input.profile),
143
- statusLine(view, input, theme),
207
+ ...wrap(`${bold(theme, view.title ? `Planning · ${view.title}` : "Planning")} ${statusLine(view, input, theme)}`, width),
144
208
  ...rosterLines(view, width, input.tick, theme),
145
209
  ];
146
210
  const bodyHeight = Math.max(0, height - header.length);
147
- const draftTitle = `${view.reply?.status === "ready" ? "Plan ✓" : "Draft plan"}${input.focus === "draft" ? " ◂" : ""}`;
148
- const talkTitle = `Conversation${input.focus === "talk" ? " ◂" : ""}`;
149
- if (width >= PLAN_COLUMNS_MIN) {
150
- const [left, right] = split(width, 0.5);
151
- const talk = fill([rule(left, talkTitle, theme), ...tail(conversationLines(view, left, theme), bodyHeight - 1, input.offset)], bodyHeight);
152
- const draft = draftLines(view, right, theme);
153
- const draftPane = fill([rule(right, draftTitle, theme), ...draft.slice(Math.max(0, Math.min(input.draftOffset, draft.length - 1)))], bodyHeight);
154
- return fill([...header, ...columns(talk, draftPane, left, right, " │ ", theme)], height, width);
211
+ const wide = width >= PLAN_COLUMNS_MIN;
212
+ const talkWidth = wide ? Math.round((width - 1) * 0.46) : width;
213
+ const draftWidth = wide ? width - 1 - talkWidth : width;
214
+ const talkHeight = wide ? bodyHeight : Math.max(3, Math.ceil(bodyHeight * 0.5));
215
+ const draftHeight = wide ? bodyHeight : bodyHeight - talkHeight;
216
+ const talkLines = conversationLines(view, talkWidth - 4, theme, input.query);
217
+ const talkView = tailWindow(talkLines, talkHeight - 2, input.offset);
218
+ const talkNote = [talkView.offset > 0 ? `↓${talkView.offset}` : "", input.query ? "filtered" : ""].filter(Boolean).join(" · ");
219
+ const talk = box(talkWidth, talkHeight, talkView.shown, { title: "Conversation", ...(talkNote ? { right: talkNote } : {}), focused: input.focus === "talk", scroll: { total: talkLines.length, start: talkView.start }, theme });
220
+ notePane(input.panes, "talk", header.length, 0, talkWidth, talkHeight, talkLines.length);
221
+ const draft = draftLines(view, draftWidth - 4, theme);
222
+ const rows = Math.max(0, draftHeight - 2);
223
+ const start = Math.max(0, Math.min(input.draftOffset, Math.max(0, draft.length - rows)));
224
+ const cursor = input.focus === "draft" ? input.cursor : undefined;
225
+ const shown = draft.slice(start, start + rows).map((entry, index) => selectRow(theme, entry.text, draftWidth - 4, start + index === cursor, true));
226
+ const draftTitle = view.reply?.status === "ready" ? "Plan ✓" : "Draft plan";
227
+ const position = draft.length > rows ? `${start + 1}-${Math.min(draft.length, start + rows)}/${draft.length}` : "";
228
+ const draftPane = draftHeight >= 3 ? box(draftWidth, draftHeight, shown, { title: draftTitle, ...(position ? { right: position } : {}), focused: input.focus === "draft", scroll: { total: draft.length, start }, theme }) : [];
229
+ if (draftHeight >= 3) notePane(input.panes, "draft", header.length + (wide ? 0 : talkHeight), wide ? talkWidth + 1 : 0, draftWidth, draftHeight, draft.length);
230
+ if (input.layout) {
231
+ Object.assign(input.layout, {
232
+ draftTop: header.length + (wide ? 0 : talkHeight) + 1,
233
+ draftLeft: (wide ? talkWidth + 1 : 0) + 2,
234
+ draftWidth: draftWidth - 4,
235
+ draftRows: rows,
236
+ draftStart: start,
237
+ draftText: draft.map((entry) => entry.plain),
238
+ } satisfies PlanLayout);
155
239
  }
156
- const talkHeight = Math.max(2, Math.ceil(bodyHeight * 0.55));
157
- const talk = [rule(width, talkTitle, theme), ...fill(tail(conversationLines(view, width, theme), talkHeight - 1, input.offset), talkHeight - 1)];
158
- const draft = draftLines(view, width, theme);
159
- return fill([...header, ...talk, rule(width, draftTitle, theme), ...draft.slice(Math.max(0, Math.min(input.draftOffset, draft.length - 1)))], height, width);
240
+ const body = wide ? beside([talk, draftPane]) : [...talk, ...draftPane];
241
+ return fill([...header, ...body], height, width);
160
242
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { shortDuration } from "../../text.ts";
7
7
  import { jobTitle, type QuickFixJob } from "../quickfix.ts";
8
- import { bold, clock, columns, fill, markdownLines, paint, rule, selectRow, spinner, split, windowStart, wrap, wrapHanging, type LobbyTheme } from "../layout.ts";
8
+ import { beside, bold, box, clock, detailWindow, fill, markdownLines, notePane, paint, position, rule, selectRow, spinner, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
9
9
 
10
10
  export interface QuickFixTabInput {
11
11
  jobs: readonly QuickFixJob[];
@@ -16,6 +16,10 @@ export interface QuickFixTabInput {
16
16
  profile: string;
17
17
  tick: number;
18
18
  now: number;
19
+ /** The search in force: only jobs that mention it are listed. */
20
+ query?: string;
21
+ /** Filled with where the list and the detail landed, for scrolling. */
22
+ panes?: PaneLayout;
19
23
  }
20
24
 
21
25
  export const QUICKFIX_COLUMNS_MIN = 90;
@@ -77,25 +81,49 @@ function intro(input: QuickFixTabInput, width: number, theme?: LobbyTheme): stri
77
81
  "",
78
82
  "Keep them small: a rename, a typo, a one-function fix. If it turns out bigger, the agent stops and says so, and you can plan it as a task.",
79
83
  "",
80
- paint(theme, "dim", `quick fix: ${input.profile}`),
84
+ paint(theme, "dim", `quick fix: ${input.profile} — esc, then m changes the model, thinking, time limit and instructions (alt+s: all settings)`),
81
85
  ].flatMap((line) => wrap(line, width));
82
86
  }
83
87
 
88
+ /** Jobs whose prompt, steps or report mention `query`. */
89
+ export function filterJobs(jobs: readonly QuickFixJob[], query: string | undefined): QuickFixJob[] {
90
+ const needle = query?.trim().toLowerCase();
91
+ if (!needle) return [...jobs];
92
+ return jobs.filter((job) => [job.prompt, job.report ?? "", job.error ?? "", ...job.steps.map((step) => step.text)].join("\n").toLowerCase().includes(needle));
93
+ }
94
+
95
+ /** Outer widths of the list and detail panes. */
96
+ export function quickFixWidths(width: number): { list: number; detail: number; wide: boolean } {
97
+ if (width < QUICKFIX_COLUMNS_MIN) return { list: width, detail: width, wide: false };
98
+ const list = Math.max(36, Math.round((width - 1) * 0.36));
99
+ return { list, detail: width - 1 - list, wide: true };
100
+ }
101
+
84
102
  export function renderQuickFix(input: QuickFixTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
85
103
  if (height <= 0) return [];
86
- if (input.jobs.length === 0) return fill([rule(width, "Quick fix", theme, input.profile), ...intro(input, width, theme)], height, width);
87
- const jobs = newestFirst(input.jobs);
104
+ const jobs = newestFirst(filterJobs(input.jobs, input.query));
105
+ if (jobs.length === 0) {
106
+ const content = input.query && input.jobs.length > 0 ? [paint(theme, "dim", `No quick fix mentions "${input.query}".`)] : intro(input, width - 4, theme);
107
+ return box(width, height, content, { title: "Quick fix", right: input.profile, theme });
108
+ }
88
109
  const selected = Math.min(Math.max(0, input.selected), jobs.length - 1);
89
- const wide = width >= QUICKFIX_COLUMNS_MIN;
90
- const [listWidth, detailWidth] = wide ? split(width, 0.38, 3, 40) : [width, width];
110
+ const { list: listWidth, detail: detailWidth, wide } = quickFixWidths(width);
91
111
  const rows = jobs.map((job, index) => {
92
112
  const time = elapsed(job, input.now);
93
113
  const text = `${mark(job, input.tick, theme)} ${index === selected ? bold(theme, jobTitle(job)) : jobTitle(job)}${time ? ` ${paint(theme, "dim", time)}` : ""}`;
94
- return selectRow(theme, text, listWidth, index === selected, input.focus === "list");
114
+ return selectRow(theme, text, listWidth - 4, index === selected, input.focus === "list");
95
115
  });
96
- const listPane = fill([rule(listWidth, "Quick fixes", theme, `${jobs.length}`), ...rows.slice(windowStart(selected, rows.length, height - 1))], height);
97
- const detail = jobDetailLines(jobs[selected]!, detailWidth, input.tick, input.now, theme);
98
- const detailPane = fill([rule(detailWidth, input.focus === "detail" ? "Detail ◂" : "Detail", theme, input.profile), ...detail.slice(Math.max(0, Math.min(input.detailOffset, detail.length - 1)))], height);
99
- if (!wide) return fill(input.focus === "detail" ? detailPane : listPane, height, width);
100
- return fill(columns(listPane, detailPane, listWidth, detailWidth, " │ ", theme), height, width);
116
+ const count = input.query ? `${jobs.length} match${jobs.length === 1 ? "" : "es"}` : `${jobs.length}`;
117
+ const listStart = windowStart(selected, rows.length, height - 2);
118
+ const listPane = box(listWidth, height, rows.slice(listStart), { title: "Quick fixes", right: count, focused: input.focus === "list", scroll: { total: rows.length, start: listStart }, theme });
119
+ const detail = jobDetailLines(jobs[selected]!, detailWidth - 4, input.tick, input.now, theme);
120
+ const detailStart = detailWindow(detail.length, height - 2, input.detailOffset);
121
+ const detailNote = [detail.length > height - 2 ? position(detailStart, height - 2, detail.length) : "", input.profile].filter(Boolean).join(" · ");
122
+ const detailPane = box(detailWidth, height, detail.slice(detailStart), { title: "Detail", right: detailNote, focused: input.focus === "detail", scroll: { total: detail.length, start: detailStart }, theme });
123
+ const showList = wide || input.focus !== "detail";
124
+ const showDetail = wide || input.focus === "detail";
125
+ if (showList) notePane(input.panes, "list", 0, 0, listWidth, height, rows.length);
126
+ if (showDetail) notePane(input.panes, "detail", 0, wide ? listWidth + 1 : 0, detailWidth, height, detail.length);
127
+ if (!wide) return fill(showDetail ? detailPane : listPane, height, width);
128
+ return fill(beside([listPane, detailPane]), height, width);
101
129
  }