@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
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Metrics tab: how each model performs at each thinking level
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
return fill([
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
}
|
package/src/lobby/tabs/plan.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
-
"
|
|
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.
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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(...(
|
|
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
|
-
|
|
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
|
|
129
|
-
if (view.
|
|
130
|
-
|
|
131
|
-
for (const
|
|
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,
|
|
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
|
-
|
|
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
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
157
|
-
|
|
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,
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
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
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
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
|
}
|