@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.
- package/README.md +264 -68
- package/package.json +5 -2
- package/prompts/panel.md +13 -4
- package/prompts/planner.md +31 -13
- package/src/execution/pi-runner.ts +1 -1
- package/src/index.ts +4 -2
- package/src/lobby/ask.ts +226 -0
- package/src/lobby/feed.ts +65 -9
- package/src/lobby/keys.ts +55 -0
- package/src/lobby/layout.ts +208 -4
- package/src/lobby/markdown.ts +48 -0
- package/src/lobby/planner.ts +135 -17
- package/src/lobby/runtime.ts +353 -66
- package/src/lobby/session-files.ts +126 -0
- package/src/lobby/sessions.ts +288 -0
- package/src/lobby/tabs/home.ts +225 -59
- package/src/lobby/tabs/metrics.ts +155 -42
- package/src/lobby/tabs/plan.ts +128 -51
- package/src/lobby/tabs/quickfix.ts +43 -18
- package/src/lobby/tabs/tasks.ts +221 -54
- package/src/lobby/view.ts +1303 -143
- package/src/pi/commands.ts +35 -7
- package/src/pi/events.ts +5 -2
- package/src/pi/notify.ts +18 -0
- package/src/pi/owner.ts +216 -0
- package/src/pi/settings-ui.ts +69 -1
- package/src/pi/start-task.ts +33 -2
- package/src/pi/ui.ts +3 -3
- package/src/pi/zen-large.ts +9 -1
- package/src/pi/zen.ts +7 -3
- package/src/schemas/configuration.ts +43 -4
- package/src/schemas/task.ts +2 -0
- package/src/state/auto.ts +40 -0
- package/src/state/inbox.ts +90 -0
- package/src/workflow/workflow.ts +27 -3
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, 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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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]} —
|
|
78
|
-
else if (view.reply?.status === "ready") parts.push(paint(theme, "success",
|
|
79
|
-
else if (view.
|
|
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", " · "))
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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(
|
|
157
|
+
lines.push(speakerLine("you", width, theme, time), ...youLines(message.text, width, theme));
|
|
118
158
|
continue;
|
|
119
159
|
}
|
|
120
|
-
lines.push(
|
|
121
|
-
lines.push(...(
|
|
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
|
-
|
|
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
|
|
129
|
-
if (view.
|
|
130
|
-
|
|
131
|
-
for (const
|
|
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,
|
|
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
|
-
|
|
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
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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);
|
|
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,
|
|
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, "
|
|
79
|
+
...wrap(bold(theme, "Describe a small change below and one agent makes it now, beside any running task."), width),
|
|
75
80
|
"",
|
|
76
|
-
"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
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
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
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
|
}
|