@a-t-h-i/bot-lobby 0.6.13 → 0.6.15

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.
Files changed (71) hide show
  1. package/README.md +70 -79
  2. package/package.json +4 -2
  3. package/prompts/master.md +3 -2
  4. package/prompts/qa.md +33 -11
  5. package/prompts/reviewer.md +5 -2
  6. package/prompts/worker.md +5 -2
  7. package/src/ask/relay.ts +1 -2
  8. package/src/ask/tool.ts +4 -6
  9. package/src/ask/types.ts +16 -0
  10. package/src/ask/web.ts +63 -0
  11. package/src/index.ts +7 -1
  12. package/src/lobby/ask.ts +4 -5
  13. package/src/lobby/blocks.ts +57 -0
  14. package/src/lobby/feed.ts +1 -1
  15. package/src/lobby/host.ts +178 -0
  16. package/src/lobby/keys.ts +10 -41
  17. package/src/lobby/prompt-hub.ts +30 -77
  18. package/src/lobby/prompts.ts +8 -12
  19. package/src/lobby/runtime.ts +49 -310
  20. package/src/lobby/service.ts +54 -134
  21. package/src/lobby/task-rows.ts +28 -0
  22. package/src/pi/commands.ts +14 -20
  23. package/src/pi/model-settings.ts +112 -0
  24. package/src/pi/start-task.ts +1 -1
  25. package/src/pi/tools.ts +6 -7
  26. package/src/pi/ui.ts +0 -9
  27. package/src/schemas/configuration.ts +9 -33
  28. package/src/state/attachments.ts +82 -0
  29. package/src/webui/api/index.ts +17 -15
  30. package/src/webui/api/lobby.ts +4 -3
  31. package/src/webui/api/planner.ts +6 -4
  32. package/src/webui/api/quickfix.ts +4 -3
  33. package/src/webui/api/sessions.ts +9 -7
  34. package/src/webui/api/settings.ts +20 -8
  35. package/src/webui/api/status.ts +4 -15
  36. package/src/webui/api/tasks.ts +11 -15
  37. package/src/webui/command.ts +35 -19
  38. package/src/webui/dev/fake-service.ts +1 -2
  39. package/src/webui/dev/fixtures.ts +2 -5
  40. package/src/webui/events.ts +1 -1
  41. package/src/webui/protocol.ts +22 -11
  42. package/src/webui/server.ts +42 -1
  43. package/src/webui/uploads.ts +68 -0
  44. package/webui/dist/assets/index-B1PnCyl2.css +2 -0
  45. package/webui/dist/assets/index-DI9Yq9IC.js +90 -0
  46. package/webui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  47. package/webui/dist/build.json +142 -110
  48. package/webui/dist/index.html +4 -3
  49. package/src/ask/dialog.ts +0 -193
  50. package/src/ask/image.ts +0 -202
  51. package/src/ask/png.ts +0 -179
  52. package/src/ask/state.ts +0 -175
  53. package/src/ask/view.ts +0 -163
  54. package/src/lobby/layout.ts +0 -496
  55. package/src/lobby/markdown.ts +0 -121
  56. package/src/lobby/mini.ts +0 -167
  57. package/src/lobby/tabs/excalidraw.ts +0 -95
  58. package/src/lobby/tabs/git.ts +0 -162
  59. package/src/lobby/tabs/home.ts +0 -544
  60. package/src/lobby/tabs/issues.ts +0 -73
  61. package/src/lobby/tabs/knowledge.ts +0 -135
  62. package/src/lobby/tabs/metrics.ts +0 -300
  63. package/src/lobby/tabs/plan.ts +0 -262
  64. package/src/lobby/tabs/quickfix.ts +0 -136
  65. package/src/lobby/tabs/tasks.ts +0 -412
  66. package/src/lobby/theme.ts +0 -30
  67. package/src/lobby/view.ts +0 -3018
  68. package/src/pi/settings-ui.ts +0 -708
  69. package/src/width.ts +0 -102
  70. package/webui/dist/assets/index-Bk-lpAGm.css +0 -2
  71. package/webui/dist/assets/index-E6MPcbdC.js +0 -80
@@ -1,412 +0,0 @@
1
- /**
2
- * The Tasks tab: every task in the project — this session's, those other pi
3
- * sessions are driving, plans saved from the planner and recently finished
4
- * ones — with a detail pane showing the approved plan and its progress, the
5
- * user's comments on it, amendments, what the task waits on and recent runs.
6
- */
7
- import { TERMINAL_STATES, taskRequest, type Task } from "../../schemas/task.ts";
8
- import type { PlannedTask } from "../../state/backlog.ts";
9
- import type { PlanComment } from "../../state/comments.ts";
10
- import { planChecklist } from "../../pi/plan-checklist.ts";
11
- import { persistedRuns } from "../../pi/ui.ts";
12
- import { describeRun, runFromLog } from "../../pi/run-summary.ts";
13
- import { pendingApprovals } from "../../workflow/approvals.ts";
14
- import { textWidth } from "../../width.ts";
15
- import { ago, beside, BRANCH_GLYPH, bold, box, detailWindow, fill, markdownHanging, markdownLines, notePane, paint, position, rule, selectRow, since, spread, strike, windowStart, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
16
-
17
- export type TaskSection = "mine" | "others" | "pending" | "recent" | "archived";
18
-
19
- /** How a row is ticked off: still to do (a plan, or a task under way), completed, or abandoned. */
20
- export type CheckState = "open" | "done" | "dropped";
21
-
22
- export interface TaskRow {
23
- /** A task on the list, a saved plan, or a task in the archive. */
24
- kind: "task" | "plan" | "archived";
25
- id: string;
26
- title: string;
27
- section: TaskSection;
28
- /** Task state, or `pending` for a saved plan. */
29
- status: string;
30
- paused?: boolean;
31
- check: CheckState;
32
- /** Plan steps done, once the task has a plan. */
33
- progress?: { done: number; total: number };
34
- /** Who drives it, for tasks other sessions own. */
35
- owner?: string;
36
- /** How long ago it finished (finished tasks), was saved (plans) or was archived, as `3h`. */
37
- age?: string;
38
- /** The GitHub issue a plan came from. */
39
- issue?: number;
40
- /** Auto mode is on: the oracle drives it without asking. */
41
- auto?: boolean;
42
- }
43
-
44
- /** What the lobby knows about sessions beyond this one: background sessions' names, tasks in auto mode. */
45
- export interface RowContext {
46
- /** Background sessions this window started, by pi session id. */
47
- names?: ReadonlyMap<string, string>;
48
- /** Tasks with auto mode on. */
49
- auto?: ReadonlySet<string>;
50
- /** Sessions running now (from their heartbeats); a task whose owner is not among them says so. */
51
- live?: ReadonlySet<string>;
52
- /** Archived tasks, listed last when shown. */
53
- archived?: readonly Task[];
54
- }
55
-
56
- export const SECTION_TITLES: Record<TaskSection, string> = {
57
- mine: "THIS SESSION",
58
- others: "OTHER SESSIONS",
59
- pending: "PENDING",
60
- recent: "FINISHED",
61
- archived: "ARCHIVED",
62
- };
63
-
64
- /** The box each row wears: empty while there is work to do, ticked when completed, crossed when abandoned. */
65
- export const CHECK_MARKS: Record<CheckState, string> = { open: "☐", done: "☑", dropped: "☒" };
66
-
67
- export function checkState(task: Task): CheckState {
68
- if (task.state === "completed") return "done";
69
- if (task.state === "abandoned") return "dropped";
70
- return "open";
71
- }
72
-
73
- /** Checklist progress as `3/7`, or undefined before a plan exists. */
74
- export function taskProgress(task: Task): { done: number; total: number } | undefined {
75
- if (!task.plan) return undefined;
76
- const steps = planChecklist(task.plan, persistedRuns(task));
77
- if (steps.length === 0) return undefined;
78
- return { done: steps.filter((step) => step.status === "done").length, total: steps.length };
79
- }
80
-
81
- function ownerLabel(task: Task, sessionId: string | undefined, names?: ReadonlyMap<string, string>, live?: ReadonlySet<string>): string {
82
- if (!task.ownerSessionId) return "not running";
83
- if (task.ownerSessionId === sessionId) return "this session";
84
- const name = names?.get(task.ownerSessionId);
85
- // A background session is named after its task, so the row's title already says which one.
86
- if (name) return name === task.title ? "background" : `background · ${name}`;
87
- // An ended session's id tells nobody anything: say that nothing runs the task.
88
- return live && !live.has(task.ownerSessionId) ? "not running" : `session ${task.ownerSessionId.slice(0, 8)}`;
89
- }
90
-
91
- /** Rows in display order: this session's task, other sessions', pending plans, then recent finished tasks. */
92
- export function taskRows(tasks: readonly Task[], plans: readonly PlannedTask[], sessionId: string | undefined, now: number, context: RowContext = {}): TaskRow[] {
93
- const active = tasks.filter((task) => !TERMINAL_STATES.includes(task.state));
94
- const row = (task: Task, section: TaskSection): TaskRow => {
95
- const progress = section === "recent" ? undefined : taskProgress(task);
96
- const auto = section !== "recent" && context.auto?.has(task.id);
97
- return {
98
- kind: "task",
99
- id: task.id,
100
- title: task.title,
101
- section,
102
- status: task.state,
103
- ...(task.paused && section !== "recent" ? { paused: true } : {}),
104
- check: checkState(task),
105
- ...(progress ? { progress } : {}),
106
- ...(section === "others" ? { owner: ownerLabel(task, sessionId, context.names, context.live) } : {}),
107
- ...(section === "recent" ? { age: ago(now - Date.parse(task.updatedAt)) } : {}),
108
- ...(auto ? { auto: true } : {}),
109
- };
110
- };
111
- return [
112
- ...active.filter((task) => sessionId && task.ownerSessionId === sessionId).map((task) => row(task, "mine")),
113
- ...active.filter((task) => !sessionId || task.ownerSessionId !== sessionId).map((task) => row(task, "others")),
114
- ...plans.filter((plan) => plan.status === "pending").map((plan): TaskRow => ({
115
- kind: "plan",
116
- id: plan.id,
117
- title: plan.split ? `${plan.title} (${plan.split.part}/${plan.split.of})` : plan.title,
118
- section: "pending",
119
- status: "pending",
120
- check: "open",
121
- ...(plan.createdAt ? { age: ago(now - Date.parse(plan.createdAt)) } : {}),
122
- ...(plan.issue ? { issue: plan.issue.number } : {}),
123
- })),
124
- ...tasks.filter((task) => TERMINAL_STATES.includes(task.state)).map((task) => row(task, "recent")),
125
- ...(context.archived ?? []).map((task): TaskRow => ({
126
- kind: "archived",
127
- id: task.id,
128
- title: task.title,
129
- section: "archived",
130
- status: task.state,
131
- check: checkState(task),
132
- age: ago(now - Date.parse(task.archivedAt ?? task.updatedAt)),
133
- })),
134
- ];
135
- }
136
-
137
- type StateColor = Extract<LobbyColor, "success" | "warning" | "error" | "accent" | "muted" | "dim">;
138
-
139
- const STATE_COLORS: Record<string, StateColor> = {
140
- completed: "success",
141
- abandoned: "dim",
142
- blocked: "error",
143
- awaiting_approval: "warning",
144
- planning: "accent",
145
- implementing: "accent",
146
- reviewing: "accent",
147
- pending: "muted",
148
- };
149
-
150
- function statusColor(status: string): StateColor {
151
- return STATE_COLORS[status] ?? "muted";
152
- }
153
-
154
- /** A task's state in words: `awaiting approval`, `implementing · paused`. */
155
- function stateWords(status: string, paused?: boolean): string {
156
- return `${status.replace(/_/g, " ")}${paused ? " · paused" : ""}`;
157
- }
158
-
159
- /** The row's box, coloured by what it says: the state's colour while open, green when done, dim when dropped. */
160
- export function checkMark(row: Pick<TaskRow, "check" | "status" | "paused">, theme?: LobbyTheme): string {
161
- const color: StateColor = row.check === "done" ? "success" : row.check === "dropped" ? "dim" : row.paused ? "warning" : statusColor(row.status);
162
- return paint(theme, color, CHECK_MARKS[row.check]);
163
- }
164
-
165
- /** A title as its box says: struck through and dim once abandoned, bold while selected. */
166
- function rowTitle(row: TaskRow, selected: boolean, theme?: LobbyTheme): string {
167
- if (row.check === "dropped") return paint(theme, "dim", strike(theme, row.title));
168
- if (row.kind === "archived") return selected ? bold(theme, row.title) : paint(theme, "dim", row.title);
169
- if (row.check === "done") return selected ? bold(theme, row.title) : paint(theme, "muted", row.title);
170
- return selected ? bold(theme, row.title) : row.title;
171
- }
172
-
173
- /** Progress as pips, `cells` wide: filled for the share done, hollow for the rest. */
174
- export function pips(done: number, total: number, cells: number, theme?: LobbyTheme): string {
175
- if (cells <= 0 || total <= 0) return "";
176
- const filled = Math.min(cells, Math.round((Math.max(0, done) / total) * cells));
177
- return `${paint(theme, done >= total ? "success" : "accent", "▰".repeat(filled))}${paint(theme, "borderMuted", "▱".repeat(cells - filled))}`;
178
- }
179
-
180
- /** Plan progress on a row: a pip per step (up to eight) and `3/5`. */
181
- function progressText(progress: TaskRow["progress"], theme?: LobbyTheme): string {
182
- if (!progress) return "";
183
- return `${pips(progress.done, progress.total, Math.min(progress.total, 8), theme)} ${paint(theme, "muted", `${progress.done}/${progress.total}`)}`;
184
- }
185
-
186
- /** The line under an open row's title: its state, who drives it and whether auto mode is on; a plan's age and issue. */
187
- function detailsLine(row: TaskRow, theme?: LobbyTheme): string {
188
- const dot = paint(theme, "dim", " · ");
189
- if (row.kind === "plan") {
190
- return [paint(theme, "muted", "planned"), row.age ? paint(theme, "dim", row.age === "now" ? "saved just now" : `saved ${row.age} ago`) : "", row.issue ? paint(theme, "dim", `#${row.issue}`) : ""].filter(Boolean).join(dot);
191
- }
192
- return [
193
- paint(theme, row.paused ? "warning" : statusColor(row.status), stateWords(row.status, row.paused)),
194
- row.auto ? paint(theme, "success", "⟳ auto") : "",
195
- row.owner ? paint(theme, "dim", row.owner) : "",
196
- ].filter(Boolean).join(dot);
197
- }
198
-
199
- /**
200
- * List lines with a rule over each section, plus which line holds the
201
- * selected row. A task or plan still to do takes two lines — its box and
202
- * title (with plan progress on the right), then its state, owner and auto
203
- * mode; a finished one takes one, ticked or struck through, with its age.
204
- */
205
- export function listLines(rows: readonly TaskRow[], selected: number, width: number, focused: boolean, theme?: LobbyTheme): { lines: string[]; selectedLine: number } {
206
- const lines: string[] = [];
207
- let selectedLine = 0;
208
- let section: TaskSection | undefined;
209
- const counts = new Map<TaskSection, number>();
210
- for (const row of rows) counts.set(row.section, (counts.get(row.section) ?? 0) + 1);
211
- rows.forEach((row, index) => {
212
- if (row.section !== section) {
213
- section = row.section;
214
- if (lines.length > 0) lines.push("");
215
- lines.push(rule(width, SECTION_TITLES[section], theme, String(counts.get(section) ?? 0)));
216
- }
217
- const chosen = index === selected;
218
- const marker = chosen ? paint(theme, "accent", "▸") : " ";
219
- const head = `${marker} ${checkMark(row, theme)} ${rowTitle(row, chosen, theme)}`;
220
- if (chosen) selectedLine = lines.length;
221
- if (row.check !== "open") {
222
- lines.push(selectRow(theme, spread(head, row.age ? paint(theme, "dim", row.age) : "", width), width, chosen, focused));
223
- return;
224
- }
225
- lines.push(selectRow(theme, spread(head, progressText(row.progress, theme), width), width, chosen, focused));
226
- lines.push(selectRow(theme, ` ${detailsLine(row, theme)}`, width, chosen, focused));
227
- });
228
- return { lines, selectedLine };
229
- }
230
-
231
- /** The list pane's right-hand count: what is open and what is finished, or how many rows match a search. */
232
- export function listCount(rows: readonly TaskRow[], query?: string): string {
233
- if (query) return `${rows.length} match${rows.length === 1 ? "" : "es"}`;
234
- const listed = rows.filter((row) => row.kind !== "archived");
235
- const open = listed.filter((row) => row.check === "open").length;
236
- const finished = listed.length - open;
237
- const archived = rows.length - listed.length;
238
- return [open ? `${open} open` : "", finished ? `${finished} finished` : "", archived ? `${archived} archived` : ""].filter(Boolean).join(" · ") || "0";
239
- }
240
-
241
- const COMMENT_MARKS: Record<PlanComment["status"], string> = { open: "○", delivered: "◐", addressed: "✓" };
242
- const COMMENT_WORDS: Record<PlanComment["status"], string> = { open: "waiting for the owning session", delivered: "sent to the oracle", addressed: "plan amended" };
243
-
244
- /** `note` at the end of the last line when it fits there, else on a line of its own under it. */
245
- function trailing(lines: string[], note: string, width: number): string[] {
246
- const last = lines.at(-1) ?? "";
247
- if (textWidth(last) + 1 + textWidth(note) <= width) return [...lines.slice(0, -1), `${last} ${note}`];
248
- return [...lines, ...wrapHanging(" ", note, width)];
249
- }
250
-
251
- function section(title: string, width: number, theme?: LobbyTheme, right = ""): string[] {
252
- return ["", rule(width, title, theme, right)];
253
- }
254
-
255
- /** Detail lines for a task: header, plan with progress, comments, amendments, waits and runs. */
256
- export function taskDetailLines(task: Task, comments: readonly PlanComment[], sessionId: string | undefined, width: number, now: number, theme?: LobbyTheme): string[] {
257
- const lines: string[] = [];
258
- const check = checkState(task);
259
- const row = { check, status: task.state, ...(task.paused ? { paused: true } : {}) };
260
- const title = check === "dropped" ? paint(theme, "dim", strike(theme, task.title)) : bold(theme, task.title);
261
- lines.push(...wrapHanging(`${checkMark(row, theme)} `, title, width));
262
- const dot = paint(theme, "dim", " · ");
263
- const finished = TERMINAL_STATES.includes(task.state);
264
- const facts = [
265
- bold(theme, paint(theme, task.paused ? "warning" : statusColor(task.state), stateWords(task.state, task.paused))),
266
- finished ? "" : paint(theme, "muted", ownerLabel(task, sessionId)),
267
- paint(theme, "muted", `started ${since(now - Date.parse(task.createdAt))}`),
268
- finished ? paint(theme, "muted", `${task.state === "abandoned" ? "dropped" : "done"} ${since(now - Date.parse(task.updatedAt))}`) : "",
269
- ].filter(Boolean);
270
- lines.push(...wrapHanging(" ", facts.join(dot), width));
271
- const track = task.track ? (task.track.path === "fast" ? `fast track (${task.track.size})` : `full workflow (${task.track.size})`) : "";
272
- lines.push(...wrapHanging(" ", paint(theme, "dim", [task.id, track, task.domains.length > 0 ? task.domains.join(", ") : ""].filter(Boolean).join(" · ")), width));
273
- if (task.git) {
274
- const where = task.git.mode === "worktree" && task.git.path ? ` · worktree ${task.git.path}` : "";
275
- lines.push(...wrapHanging(" ", paint(theme, "dim", `${BRANCH_GLYPH} ${task.git.branch}${task.git.from ? ` · from ${task.git.from}` : ""}${where}`), width));
276
- }
277
- const steps = task.plan ? planChecklist(task.plan, persistedRuns(task)) : [];
278
- const done = steps.filter((step) => step.status === "done").length;
279
- if (steps.length > 0) {
280
- const cells = Math.max(4, Math.min(24, width - 20));
281
- lines.push("", ` ${pips(done, steps.length, cells, theme)} ${paint(theme, "muted", `${done} of ${steps.length} steps`)}`);
282
- }
283
- const request = taskRequest(task);
284
- // Requests are often Markdown: a plan agreed in the planning panel, an issue, a pasted spec.
285
- if (request && request !== task.title) lines.push(...section("Request", width, theme), ...markdownLines(request, width, theme));
286
- if (task.plan) {
287
- if (steps.length > 0) {
288
- lines.push(...section("Progress", width, theme, `${done}/${steps.length} steps`));
289
- for (const [index, step] of steps.entries()) {
290
- const mark = step.status === "done" ? paint(theme, "success", CHECK_MARKS.done) : paint(theme, step.status === "current" ? "accent" : "dim", CHECK_MARKS.open);
291
- const text = step.status === "done" ? paint(theme, "muted", step.text) : step.status === "current" ? bold(theme, step.text) : paint(theme, "muted", step.text);
292
- const current = step.status === "current" ? ` ${paint(theme, "accent", "◂ now")}` : "";
293
- lines.push(...wrapHanging(`${mark} ${paint(theme, "dim", `${index + 1}.`)} `, `${text}${current}`, width));
294
- }
295
- }
296
- lines.push(...section("Approved plan", width, theme));
297
- lines.push(...markdownLines(task.plan, width, theme));
298
- } else if (task.proposal) {
299
- lines.push(...section(TERMINAL_STATES.includes(task.state) ? "Proposal" : "Proposal (no plan yet)", width, theme), ...markdownLines(task.proposal, width, theme));
300
- } else {
301
- lines.push(...section("Plan", width, theme), paint(theme, "dim", TERMINAL_STATES.includes(task.state) ? "It ended before a plan was made." : "No proposal or plan yet — the oracle is still clarifying or scouting."));
302
- }
303
- const open = comments.filter((comment) => comment.status !== "addressed").length;
304
- lines.push(...section("Comments", width, theme, open > 0 ? `${open} open` : ""));
305
- if (comments.length === 0) lines.push(paint(theme, "dim", TERMINAL_STATES.includes(task.state) ? "No comments." : "No comments yet — press c to comment on the plan; the oracle amends it."));
306
- for (const comment of comments) {
307
- const color = comment.status === "addressed" ? "success" : comment.status === "delivered" ? "accent" : "warning";
308
- const said = markdownHanging(`${paint(theme, color, COMMENT_MARKS[comment.status])} `, comment.text, width, theme);
309
- lines.push(...trailing(said, paint(theme, "dim", `— ${COMMENT_WORDS[comment.status]}, ${since(now - Date.parse(comment.createdAt))}`), width));
310
- }
311
- if (task.amendments.length > 0) {
312
- lines.push(...section("Amendments", width, theme));
313
- for (const amendment of task.amendments) lines.push(...markdownHanging("- ", amendment, width, theme));
314
- }
315
- const approvals = pendingApprovals(task);
316
- if (approvals.length > 0 || task.blockers.length > 0) {
317
- lines.push(...section("Waiting on", width, theme));
318
- for (const approval of approvals) lines.push(...wrapHanging(`${paint(theme, "warning", "!")} `, `${approval.kind} for ${approval.domain}: ${approval.detail}`, width));
319
- for (const blocker of task.blockers) lines.push(...wrapHanging(`${paint(theme, "error", "✗")} `, `${blocker.reason} (needs: ${blocker.need})`, width));
320
- }
321
- const runs = (task.runLog ?? []).slice(-6);
322
- if (runs.length > 0) {
323
- lines.push(...section("Recent runs", width, theme));
324
- for (const entry of runs) lines.push(...wrap(paint(theme, "muted", describeRun(runFromLog(entry, task.id), now)), width));
325
- }
326
- return lines;
327
- }
328
-
329
- /** Detail lines for a saved plan waiting to start. */
330
- export function planDetailLines(plan: PlannedTask, width: number, now: number, theme?: LobbyTheme): string[] {
331
- const dot = paint(theme, "dim", " · ");
332
- const lines = [
333
- ...wrapHanging(`${checkMark({ check: "open", status: "pending" }, theme)} `, bold(theme, plan.title), width),
334
- ...wrapHanging(" ", [bold(theme, paint(theme, "muted", "pending")), paint(theme, "muted", `saved ${since(now - Date.parse(plan.createdAt))}`)].join(dot), width),
335
- ...wrapHanging(" ", paint(theme, "dim", plan.id), width),
336
- ];
337
- if (plan.issue) lines.push(...wrapHanging(" ", `from issue #${plan.issue.number} — ${plan.issue.title}${plan.issue.url ? ` ${paint(theme, "dim", plan.issue.url)}` : ""}`, width));
338
- if (plan.split) {
339
- const { part, of, titles, after } = plan.split;
340
- lines.push(...wrapHanging(" ", `${paint(theme, "accent", `part ${part} of ${of}`)} of one plan that was split into tasks${after.length > 0 ? `, ${paint(theme, "warning", `after ${after.map((number) => `part ${number}`).join(" and ")}`)}` : ""}`, width));
341
- lines.push(...wrapHanging(" ", titles.map((title, index) => (index + 1 === part ? bold(theme, `${index + 1}. ${title}`) : paint(theme, "muted", `${index + 1}. ${title}`))).join(paint(theme, "dim", " · ")), width));
342
- }
343
- const key = (text: string) => paint(theme, "accent", text);
344
- lines.push("", ...wrapHanging(" ", [`${key("s")} ${paint(theme, "muted", "start it in a new session")}`, `${key("h")} ${paint(theme, "muted", "start it here")}`, `${key("d d")} ${paint(theme, "muted", "discard it")}`].join(paint(theme, "dim", " ")), width));
345
- lines.push(...section("Agreed plan", width, theme), ...markdownLines(plan.brief, width, theme));
346
- return lines;
347
- }
348
-
349
- export interface TasksInput {
350
- rows: readonly TaskRow[];
351
- selected: number;
352
- /** Detail lines for the selected row, already fitted to the detail pane. */
353
- detail: readonly string[];
354
- focus: "list" | "detail";
355
- detailOffset: number;
356
- notice?: string;
357
- /** The search in force; the rows are already filtered by it. */
358
- query?: string;
359
- /** Filled with where the list and the detail landed, for scrolling. */
360
- panes?: PaneLayout;
361
- }
362
-
363
- /** Narrow terminals show the list or the detail, not both. */
364
- export const TASKS_COLUMNS_MIN = 90;
365
-
366
- /** Outer widths of the list and detail panes (the detail's text is 4 columns narrower). */
367
- export function tasksWidths(width: number): { list: number; detail: number; wide: boolean } {
368
- if (width < TASKS_COLUMNS_MIN) return { list: width, detail: width, wide: false };
369
- const list = Math.max(36, Math.round((width - 1) * 0.36));
370
- return { list, detail: width - 1 - list, wide: true };
371
- }
372
-
373
- /** Everything a search looks at for one row: id, title, request, proposal, plan. */
374
- function rowText(row: TaskRow, tasks: readonly Task[], plans: readonly PlannedTask[]): string {
375
- if (row.kind === "plan") {
376
- const plan = plans.find((entry) => entry.id === row.id);
377
- return [row.id, row.title, plan?.brief ?? "", plan?.issue?.title ?? ""].join("\n");
378
- }
379
- const task = tasks.find((entry) => entry.id === row.id);
380
- return [row.id, row.title, row.status, task ? taskRequest(task) : "", task?.proposal ?? "", task?.plan ?? ""].join("\n");
381
- }
382
-
383
- /** The rows whose task or plan mentions `query` anywhere. */
384
- export function filterRows(rows: readonly TaskRow[], tasks: readonly Task[], plans: readonly PlannedTask[], query: string | undefined): TaskRow[] {
385
- const needle = query?.trim().toLowerCase();
386
- if (!needle) return [...rows];
387
- return rows.filter((row) => rowText(row, tasks, plans).toLowerCase().includes(needle));
388
- }
389
-
390
- export function renderTasks(input: TasksInput, width: number, height: number, theme?: LobbyTheme): string[] {
391
- if (height <= 0) return [];
392
- const notice = input.notice ? [paint(theme, "accent", input.notice)] : [];
393
- const bodyHeight = height - notice.length;
394
- if (input.rows.length === 0) {
395
- const empty = input.query ? `No task or plan mentions "${input.query}".` : "No tasks yet. Start one from the Lobby tab, or plan one in the Plan tab.";
396
- return fill([...notice, ...box(width, bodyHeight, [paint(theme, "dim", empty)], { title: "Tasks", theme })], height, width);
397
- }
398
- const { list: listWidth, detail: detailWidth, wide } = tasksWidths(width);
399
- const list = listLines(input.rows, input.selected, listWidth - 4, input.focus === "list", theme);
400
- const rows = Math.max(0, bodyHeight - 2);
401
- const listStart = windowStart(list.selectedLine, list.lines.length, rows);
402
- const count = listCount(input.rows, input.query);
403
- const listPane = box(listWidth, bodyHeight, list.lines.slice(listStart), { title: "Tasks", right: count, focused: input.focus === "list", scroll: { total: list.lines.length, start: listStart }, theme });
404
- const detailStart = detailWindow(input.detail.length, rows, input.detailOffset);
405
- const detailPane = box(detailWidth, bodyHeight, input.detail.slice(detailStart), { title: "Detail", ...(input.detail.length > rows ? { right: position(detailStart, rows, input.detail.length) } : {}), focused: input.focus === "detail", scroll: { total: input.detail.length, start: detailStart }, theme });
406
- const showList = wide || input.focus !== "detail";
407
- const showDetail = wide || input.focus === "detail";
408
- if (showList) notePane(input.panes, "list", notice.length, 0, listWidth, bodyHeight, list.lines.length);
409
- if (showDetail) notePane(input.panes, "detail", notice.length, wide ? listWidth + 1 : 0, detailWidth, bodyHeight, input.detail.length);
410
- if (!wide) return fill([...notice, ...(showDetail ? detailPane : listPane)], height, width);
411
- return fill([...notice, ...beside([listPane, detailPane])], height, width);
412
- }
@@ -1,30 +0,0 @@
1
- /**
2
- * pi's theme as bot-lobby draws with it (the lobby, the questionnaire), plus
3
- * Markdown rendering; one wrapper (and one Markdown renderer with its cache)
4
- * per theme, so caches stay warm and a theme switch starts fresh.
5
- */
6
- import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
7
- import type { LobbyTheme } from "./layout.ts";
8
- import { createMarkdownRenderer } from "./markdown.ts";
9
-
10
- const lobbyThemes = new WeakMap<Theme, LobbyTheme>();
11
-
12
- export function lobbyTheme(theme: Theme): LobbyTheme {
13
- let wrapped = lobbyThemes.get(theme);
14
- if (!wrapped) {
15
- wrapped = {
16
- fg: (color, text) => theme.fg(color, text),
17
- bold: (text) => theme.bold(text),
18
- italic: (text) => theme.italic(text),
19
- bg: (color, text) => theme.bg(color, text),
20
- // The same looks pi gives your messages and the thinking it shows.
21
- markdown: createMarkdownRenderer(getMarkdownTheme(), {
22
- you: { color: (text) => theme.fg("accent", text) },
23
- thought: { color: (text) => theme.fg("thinkingText", text), italic: true },
24
- }),
25
- strike: (text) => theme.strikethrough(text),
26
- };
27
- lobbyThemes.set(theme, wrapped);
28
- }
29
- return wrapped;
30
- }