@a-t-h-i/bot-lobby 0.5.0 → 0.6.0

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.
@@ -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, clock, fill, fit, italic, markdownLines, notePane, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
13
+ import { sourceColor, speakerLine, tailWindow, youLines } 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,39 +68,43 @@ 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;
80
+ /** The key that saves the plan, as the status line names it. */
81
+ saveKey?: string;
52
82
  }
53
83
 
54
84
  export const PLAN_COLUMNS_MIN = 100;
55
85
  const LABEL_WIDTH = 8;
56
86
 
87
+ /** Before a session: one sentence, then who sits on the panel and the model each runs on. */
57
88
  function intro(input: PlanTabInput, width: number, theme?: LobbyTheme): string[] {
58
- // The roster leads with the chair; the intro names it separately.
59
- const members = input.seats.filter((seat) => seat.label !== ORACLE_LABEL);
60
- const seats = members.filter((seat) => seat.seated).map((seat) => seat.label);
61
- const text = [
62
- bold(theme, "Plan a task with the whole team before anyone writes code."),
63
- "",
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.`,
65
- "",
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.",
67
- "",
68
- paint(theme, "dim", `oracle: ${input.profile}`),
69
- ...members.map((seat) => paint(theme, "dim", `${seat.label.toLowerCase()}: ${seat.seated ? seat.profile ?? "" : "not seated"}`)),
70
- ];
71
- return text.flatMap((line) => wrap(line, width));
89
+ const rows = input.seats.map((seat) => {
90
+ const name = paint(theme, seat.seated ? sourceColor(seat.label) : "dim", seat.label.padEnd(LABEL_WIDTH + 2));
91
+ const model = seat.label === ORACLE_LABEL ? input.profile : seat.seated ? seat.profile ?? "" : "not seated";
92
+ return fit(`${name}${paint(theme, seat.seated ? "muted" : "dim", model)}`, width);
93
+ });
94
+ return [...wrap(bold(theme, "Describe a task below and the panel questions you until the plan is clear."), width), "", ...rows];
72
95
  }
73
96
 
74
97
  function statusLine(view: PlanView, input: PlanTabInput, theme?: LobbyTheme): string {
75
98
  const parts: string[] = [];
99
+ const count = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
76
100
  if (view.busy) parts.push(`${paint(theme, "accent", spinner(input.tick))} round ${view.turns}`);
77
- else if (view.error) parts.push(paint(theme, "error", `✗ ${view.error.split("\n")[0]} — esc, then r retries`));
78
- 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"}`));
101
+ else if (view.error) parts.push(paint(theme, "error", `✗ ${view.error.split("\n")[0]} — r retries`));
102
+ else if (view.reply?.status === "ready") parts.push(paint(theme, "success", `✓ ready — ${input.saveKey ?? "Ctrl+S"} saves it`));
103
+ else if (view.awaitingAnswers) parts.push(paint(theme, "warning", `● ${count(view.questions.length, "question")} — enter ${view.answeredChunks > 0 ? "resumes" : "answers them"}`));
104
+ if (view.lineComments.length > 0) parts.push(paint(theme, "accent", `◆ ${count(view.lineComments.length, "comment")} to send`));
80
105
  if (view.saved) parts.push(paint(theme, "success", `saved as ${view.saved.id}`));
81
106
  if (!view.busy && view.turns > 0) parts.push(paint(theme, "dim", `round ${view.turns}`));
82
- return parts.join(paint(theme, "dim", " · ")) || paint(theme, "dim", "Answer below; enter sends.");
107
+ return parts.join(paint(theme, "dim", " · "));
83
108
  }
84
109
 
85
110
  /** One seat on the roster: a spinner and its step while it works, then ready, its question count or a failure. */
@@ -90,6 +115,8 @@ function seatCell(seat: SeatView, tick: number, theme?: LobbyTheme): string {
90
115
  if (seat.status === "failed") return `${name} ${paint(theme, "error", "✗ failed — r retries")}`;
91
116
  if (seat.status === "idle") return `${name} ${paint(theme, "dim", "·")}`;
92
117
  if (seat.ready) return `${name} ${paint(theme, "success", "✓ ready")}`;
118
+ // A seat whose questions the oracle settled itself has nothing waiting on you.
119
+ if (seat.questions === 0) return `${name} ${paint(theme, "dim", "done")}`;
93
120
  return `${name} ${paint(theme, "warning", `${seat.questions} question${seat.questions === 1 ? "" : "s"}`)}`;
94
121
  }
95
122
 
@@ -99,36 +126,69 @@ export function rosterLines(view: PlanView, width: number, tick: number, theme?:
99
126
  return wrap(`${paint(theme, "dim", "panel")} ${cells.join(paint(theme, "dim", " · "))}`, width);
100
127
  }
101
128
 
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);
129
+ /** Each question numbered as the panel asked it (a search may show only some), with its options beneath. */
130
+ function questionLines(questions: ReadonlyArray<{ question: PanelQuestion; number: number }>, width: number, theme?: LobbyTheme): string[] {
131
+ return questions.flatMap(({ question, number }) => {
132
+ const lead = `${paint(theme, "dim", `${String(number).padStart(2)}.`)} ${bold(theme, paint(theme, sourceColor(question.from), question.from.padEnd(LABEL_WIDTH)))} `;
133
+ const indent = " ".repeat(4 + LABEL_WIDTH);
134
+ const options = question.options.map((option) => wrapHanging(`${indent}${paint(theme, "dim", "○")} `, `${option.label}${option.description ? paint(theme, "dim", ` — ${option.description}`) : ""}`, width)).flat();
135
+ return [...wrapHanging(lead, question.text, width), ...options];
106
136
  });
107
137
  }
108
138
 
109
- export function conversationLines(view: PlanView, width: number, theme?: LobbyTheme): string[] {
139
+ function matches(text: string, query: string | undefined): boolean {
140
+ return !query || text.toLowerCase().includes(query.toLowerCase());
141
+ }
142
+
143
+ export function conversationLines(view: PlanView, width: number, theme?: LobbyTheme, query?: string): string[] {
110
144
  const lines: string[] = [];
111
145
  if (view.seed) {
112
146
  lines.push(...wrap(paint(theme, "dim", `from issue #${view.seed.issue.number} — ${view.seed.issue.title}`), width), "");
113
147
  }
114
148
  for (const message of view.messages) {
149
+ const questions = (message.questions ?? [])
150
+ .map((question, index) => ({ question, number: index + 1 }))
151
+ .filter(({ question }) => matches(`${question.from} ${question.text} ${question.options.map((option) => option.label).join(" ")}`, query));
152
+ if (message.role === "you" ? !matches(message.text, query) : message.questions?.length ? questions.length === 0 : !matches(message.text, query)) continue;
115
153
  if (lines.length > 0 && lines.at(-1) !== "") lines.push("");
154
+ // The same turns as the Lobby's conversation: who speaks and when, your words in a band.
155
+ const time = message.at > 0 ? clock(message.at) : "";
116
156
  if (message.role === "you") {
117
- lines.push(...wrapHanging(`${bold(theme, paint(theme, "accent", "you"))} ${paint(theme, "dim", "▸")} `, message.text, width));
157
+ lines.push(speakerLine("you", width, theme, time), ...youLines(message.text, width, theme));
118
158
  continue;
119
159
  }
120
- 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)));
160
+ lines.push(speakerLine("panel", width, theme, time));
161
+ lines.push(...(questions.length > 0 ? questionLines(questions, width, theme) : wrap(message.text, width).map((line) => (line ? ` ${line}` : ""))));
122
162
  }
163
+ if (query && lines.length === 0) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${query}".`), width);
123
164
  return lines;
124
165
  }
125
166
 
126
- export function draftLines(view: PlanView, width: number, theme?: LobbyTheme): string[] {
167
+ interface DraftLine {
168
+ text: string;
169
+ plain: string;
170
+ }
171
+
172
+ /** The draft as lines: the plan in Markdown, commented lines marked with their comments beneath, then each seat's needs. */
173
+ export function draftLines(view: PlanView, width: number, theme?: LobbyTheme): DraftLine[] {
127
174
  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));
175
+ const line = (text: string): DraftLine => ({ text, plain: stripTerminalSequences(text).trimEnd() });
176
+ if (!plan) return wrap(paint(theme, "dim", view.busy ? "The draft appears after the panel's first round." : "No draft yet."), width).map(line);
177
+ const comments = new Map<string, string[]>();
178
+ for (const comment of view.lineComments) comments.set(comment.line, [...(comments.get(comment.line) ?? []), comment.text]);
179
+ const lines: DraftLine[] = [];
180
+ for (const rendered of markdownLines(plan, Math.max(1, width - 2), theme)) {
181
+ const entry = line(rendered);
182
+ const notes = comments.get(entry.plain.trim());
183
+ lines.push({ text: `${notes ? paint(theme, "warning", "◆") : " "} ${rendered}`, plain: entry.plain });
184
+ for (const note of notes ?? []) {
185
+ for (const wrapped of wrap(italic(theme, paint(theme, "accent", `↳ ${note}`)), Math.max(1, width - 4))) lines.push({ text: ` ${wrapped}`, plain: entry.plain });
186
+ }
187
+ }
188
+ if (view.notes.length > 0) {
189
+ lines.push(line(""), line(bold(theme, paint(theme, "mdHeading", "What each seat needs"))));
190
+ 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));
191
+ }
132
192
  return lines;
133
193
  }
134
194
 
@@ -136,25 +196,42 @@ export function renderPlan(input: PlanTabInput, width: number, height: number, t
136
196
  if (height <= 0) return [];
137
197
  const notice = input.notice ? [paint(theme, "accent", input.notice)] : [];
138
198
  const view = input.session;
139
- if (!view) return fill([...notice, rule(width, "Plan", theme, input.profile), ...intro(input, width, theme)], height, width);
199
+ if (!view) return fill([...notice, ...box(width, height - notice.length, intro(input, width - 4, theme), { title: "Plan", theme })], height, width);
140
200
  const header = [
141
201
  ...notice,
142
- rule(width, view.title ? `Planning · ${view.title}` : "Planning", theme, input.profile),
143
- statusLine(view, input, theme),
202
+ ...wrap(`${bold(theme, view.title ? `Planning · ${view.title}` : "Planning")} ${statusLine(view, input, theme)}`, width),
144
203
  ...rosterLines(view, width, input.tick, theme),
145
204
  ];
146
205
  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);
206
+ const wide = width >= PLAN_COLUMNS_MIN;
207
+ const talkWidth = wide ? Math.round((width - 1) * 0.46) : width;
208
+ const draftWidth = wide ? width - 1 - talkWidth : width;
209
+ const talkHeight = wide ? bodyHeight : Math.max(3, Math.ceil(bodyHeight * 0.5));
210
+ const draftHeight = wide ? bodyHeight : bodyHeight - talkHeight;
211
+ const talkLines = conversationLines(view, talkWidth - 4, theme, input.query);
212
+ const talkView = tailWindow(talkLines, talkHeight - 2, input.offset);
213
+ const talkNote = [talkView.offset > 0 ? `↓${talkView.offset}` : "", input.query ? "filtered" : ""].filter(Boolean).join(" · ");
214
+ const talk = box(talkWidth, talkHeight, talkView.shown, { title: "Conversation", ...(talkNote ? { right: talkNote } : {}), focused: input.focus === "talk", scroll: { total: talkLines.length, start: talkView.start }, theme });
215
+ notePane(input.panes, "talk", header.length, 0, talkWidth, talkHeight, talkLines.length);
216
+ const draft = draftLines(view, draftWidth - 4, theme);
217
+ const rows = Math.max(0, draftHeight - 2);
218
+ const start = Math.max(0, Math.min(input.draftOffset, Math.max(0, draft.length - rows)));
219
+ const cursor = input.focus === "draft" ? input.cursor : undefined;
220
+ const shown = draft.slice(start, start + rows).map((entry, index) => selectRow(theme, entry.text, draftWidth - 4, start + index === cursor, true));
221
+ const draftTitle = view.reply?.status === "ready" ? "Plan ✓" : "Draft plan";
222
+ const position = draft.length > rows ? `${start + 1}-${Math.min(draft.length, start + rows)}/${draft.length}` : "";
223
+ const draftPane = draftHeight >= 3 ? box(draftWidth, draftHeight, shown, { title: draftTitle, ...(position ? { right: position } : {}), focused: input.focus === "draft", scroll: { total: draft.length, start }, theme }) : [];
224
+ if (draftHeight >= 3) notePane(input.panes, "draft", header.length + (wide ? 0 : talkHeight), wide ? talkWidth + 1 : 0, draftWidth, draftHeight, draft.length);
225
+ if (input.layout) {
226
+ Object.assign(input.layout, {
227
+ draftTop: header.length + (wide ? 0 : talkHeight) + 1,
228
+ draftLeft: (wide ? talkWidth + 1 : 0) + 2,
229
+ draftWidth: draftWidth - 4,
230
+ draftRows: rows,
231
+ draftStart: start,
232
+ draftText: draft.map((entry) => entry.plain),
233
+ } satisfies PlanLayout);
155
234
  }
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);
235
+ const body = wide ? beside([talk, draftPane]) : [...talk, ...draftPane];
236
+ return fill([...header, ...body], height, width);
160
237
  }
@@ -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;
@@ -69,33 +73,54 @@ export function jobDetailLines(job: QuickFixJob, width: number, tick: number, no
69
73
  return lines;
70
74
  }
71
75
 
76
+ /** Before the first fix: one sentence, then the agent and the model it runs on. */
72
77
  function intro(input: QuickFixTabInput, width: number, theme?: LobbyTheme): string[] {
73
78
  return [
74
- bold(theme, "Make a direct change, the way you would ask pi."),
79
+ ...wrap(bold(theme, "Describe a small change below and one agent makes it now, beside any running task."), width),
75
80
  "",
76
- "Type what you want below and press enter. One coding agent makes the change right away — no scouting, proposal, plan or review — while any bot-lobby task keeps running. Quick fixes run one at a time in the order you send them.",
77
- "",
78
- "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
- "",
80
- paint(theme, "dim", `quick fix: ${input.profile}`),
81
- ].flatMap((line) => wrap(line, width));
81
+ `${paint(theme, "mdCode", "QUICK FIX".padEnd(11))}${paint(theme, "muted", input.profile)}`,
82
+ ];
83
+ }
84
+
85
+ /** Jobs whose prompt, steps or report mention `query`. */
86
+ export function filterJobs(jobs: readonly QuickFixJob[], query: string | undefined): QuickFixJob[] {
87
+ const needle = query?.trim().toLowerCase();
88
+ if (!needle) return [...jobs];
89
+ return jobs.filter((job) => [job.prompt, job.report ?? "", job.error ?? "", ...job.steps.map((step) => step.text)].join("\n").toLowerCase().includes(needle));
90
+ }
91
+
92
+ /** Outer widths of the list and detail panes. */
93
+ export function quickFixWidths(width: number): { list: number; detail: number; wide: boolean } {
94
+ if (width < QUICKFIX_COLUMNS_MIN) return { list: width, detail: width, wide: false };
95
+ const list = Math.max(36, Math.round((width - 1) * 0.36));
96
+ return { list, detail: width - 1 - list, wide: true };
82
97
  }
83
98
 
84
99
  export function renderQuickFix(input: QuickFixTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
85
100
  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);
101
+ const jobs = newestFirst(filterJobs(input.jobs, input.query));
102
+ if (jobs.length === 0) {
103
+ const content = input.query && input.jobs.length > 0 ? [paint(theme, "dim", `No quick fix mentions "${input.query}".`)] : intro(input, width - 4, theme);
104
+ return box(width, height, content, { title: "Quick fix", ...(input.query ? { right: input.profile } : {}), theme });
105
+ }
88
106
  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];
107
+ const { list: listWidth, detail: detailWidth, wide } = quickFixWidths(width);
91
108
  const rows = jobs.map((job, index) => {
92
109
  const time = elapsed(job, input.now);
93
110
  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");
111
+ return selectRow(theme, text, listWidth - 4, index === selected, input.focus === "list");
95
112
  });
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);
113
+ const count = input.query ? `${jobs.length} match${jobs.length === 1 ? "" : "es"}` : `${jobs.length}`;
114
+ const listStart = windowStart(selected, rows.length, height - 2);
115
+ const listPane = box(listWidth, height, rows.slice(listStart), { title: "Quick fixes", right: count, focused: input.focus === "list", scroll: { total: rows.length, start: listStart }, theme });
116
+ const detail = jobDetailLines(jobs[selected]!, detailWidth - 4, input.tick, input.now, theme);
117
+ const detailStart = detailWindow(detail.length, height - 2, input.detailOffset);
118
+ const detailNote = [detail.length > height - 2 ? position(detailStart, height - 2, detail.length) : "", input.profile].filter(Boolean).join(" · ");
119
+ const detailPane = box(detailWidth, height, detail.slice(detailStart), { title: "Detail", right: detailNote, focused: input.focus === "detail", scroll: { total: detail.length, start: detailStart }, theme });
120
+ const showList = wide || input.focus !== "detail";
121
+ const showDetail = wide || input.focus === "detail";
122
+ if (showList) notePane(input.panes, "list", 0, 0, listWidth, height, rows.length);
123
+ if (showDetail) notePane(input.panes, "detail", 0, wide ? listWidth + 1 : 0, detailWidth, height, detail.length);
124
+ if (!wide) return fill(showDetail ? detailPane : listPane, height, width);
125
+ return fill(beside([listPane, detailPane]), height, width);
101
126
  }