@a-t-h-i/bot-lobby 0.4.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.
Files changed (41) hide show
  1. package/README.md +238 -10
  2. package/package.json +5 -2
  3. package/prompts/master.md +12 -0
  4. package/prompts/panel.md +44 -0
  5. package/prompts/planner.md +69 -0
  6. package/prompts/quickfix.md +41 -0
  7. package/src/execution/agent-runner.ts +7 -2
  8. package/src/execution/pi-runner.ts +25 -0
  9. package/src/index.ts +3 -0
  10. package/src/lobby/ask.ts +226 -0
  11. package/src/lobby/feed.ts +253 -0
  12. package/src/lobby/issues.ts +227 -0
  13. package/src/lobby/keys.ts +51 -0
  14. package/src/lobby/layout.ts +363 -0
  15. package/src/lobby/markdown.ts +48 -0
  16. package/src/lobby/planner.ts +581 -0
  17. package/src/lobby/quickfix.ts +227 -0
  18. package/src/lobby/runtime.ts +591 -0
  19. package/src/lobby/tabs/home.ts +242 -0
  20. package/src/lobby/tabs/issues.ts +72 -0
  21. package/src/lobby/tabs/metrics.ts +275 -0
  22. package/src/lobby/tabs/plan.ts +242 -0
  23. package/src/lobby/tabs/quickfix.ts +129 -0
  24. package/src/lobby/tabs/tasks.ts +243 -0
  25. package/src/lobby/view.ts +1610 -0
  26. package/src/pi/activity.ts +120 -0
  27. package/src/pi/commands.ts +19 -57
  28. package/src/pi/events.ts +5 -2
  29. package/src/pi/model-support.ts +34 -0
  30. package/src/pi/run-summary.ts +2 -0
  31. package/src/pi/settings-ui.ts +69 -1
  32. package/src/pi/start-task.ts +63 -0
  33. package/src/pi/tools.ts +2 -2
  34. package/src/pi/ui.ts +116 -55
  35. package/src/schemas/configuration.ts +102 -3
  36. package/src/schemas/findings.ts +6 -0
  37. package/src/schemas/task.ts +1 -0
  38. package/src/state/backlog.ts +106 -0
  39. package/src/state/comments.ts +136 -0
  40. package/src/state/metrics.ts +305 -0
  41. package/src/workflow/workflow.ts +39 -7
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The Plan tab: task planning mode with a planning panel. The roster shows
3
+ * each seat — the oracle chairing, DEV, DESIGN, QA and RESEARCH — and what it
4
+ * is doing; the conversation shows every seat's questions, attributed, with
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.
8
+ */
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";
11
+ import type { PlannedTask } from "../../state/backlog.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";
14
+
15
+ export interface SeatView {
16
+ label: string;
17
+ /** Sits on the panel next round. */
18
+ seated: boolean;
19
+ status: "idle" | "thinking" | "done" | "failed";
20
+ step?: string;
21
+ /** Open questions it asked in the latest round. */
22
+ questions: number;
23
+ ready: boolean;
24
+ /** `provider/model · thinking` the seat runs on. */
25
+ profile?: string;
26
+ }
27
+
28
+ export interface PlanView {
29
+ messages: readonly PlannerMessage[];
30
+ reply?: PlannerReply;
31
+ questions: readonly PanelQuestion[];
32
+ notes: readonly PanelNote[];
33
+ seats: readonly SeatView[];
34
+ busy: boolean;
35
+ step?: string;
36
+ error?: string;
37
+ turns: number;
38
+ seed?: PlannerSeed;
39
+ saved?: PlannedTask;
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[];
60
+ }
61
+
62
+ export interface PlanTabInput {
63
+ session?: PlanView;
64
+ /** `provider/model · thinking` the oracle plans on. */
65
+ profile: string;
66
+ /** The seats as they would sit before a session exists. */
67
+ seats: readonly SeatView[];
68
+ offset: number;
69
+ focus: "talk" | "draft";
70
+ draftOffset: number;
71
+ /** The draft line the cursor is on, while the draft has the focus. */
72
+ cursor?: number;
73
+ tick: number;
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
+ }
81
+
82
+ export const PLAN_COLUMNS_MIN = 100;
83
+ const LABEL_WIDTH = 8;
84
+
85
+ function intro(input: PlanTabInput, width: number, theme?: LobbyTheme): string[] {
86
+ // The roster leads with the chair; the intro names it separately.
87
+ const members = input.seats.filter((seat) => seat.label !== ORACLE_LABEL);
88
+ const seats = members.filter((seat) => seat.seated).map((seat) => seat.label);
89
+ const text = [
90
+ bold(theme, "Plan a task with the whole team before anyone writes code."),
91
+ "",
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.",
95
+ "",
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.",
97
+ "",
98
+ paint(theme, "dim", `oracle: ${input.profile} (m changes it; each seat uses its domain's settings, alt+s)`),
99
+ ...members.map((seat) => paint(theme, "dim", `${seat.label.toLowerCase()}: ${seat.seated ? seat.profile ?? "" : "not seated"}`)),
100
+ ];
101
+ return text.flatMap((line) => wrap(line, width));
102
+ }
103
+
104
+ function statusLine(view: PlanView, input: PlanTabInput, theme?: LobbyTheme): string {
105
+ const parts: string[] = [];
106
+ if (view.busy) parts.push(`${paint(theme, "accent", spinner(input.tick))} round ${view.turns}`);
107
+ else if (view.error) parts.push(paint(theme, "error", `✗ ${view.error.split("\n")[0]} — esc, then r retries`));
108
+ else if (view.reply?.status === "ready") parts.push(paint(theme, "success", "✓ READY — the panel agrees; s saves it as a pending task"));
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`));
114
+ if (view.saved) parts.push(paint(theme, "success", `saved as ${view.saved.id}`));
115
+ if (!view.busy && view.turns > 0) parts.push(paint(theme, "dim", `round ${view.turns}`));
116
+ return parts.join(paint(theme, "dim", " · ")) || paint(theme, "dim", "Answer below; enter sends.");
117
+ }
118
+
119
+ /** One seat on the roster: a spinner and its step while it works, then ready, its question count or a failure. */
120
+ function seatCell(seat: SeatView, tick: number, theme?: LobbyTheme): string {
121
+ const name = paint(theme, seat.seated ? sourceColor(seat.label) : "dim", seat.label);
122
+ if (!seat.seated) return `${name} ${paint(theme, "dim", "off")}`;
123
+ if (seat.status === "thinking") return `${name} ${paint(theme, "accent", spinner(tick))} ${paint(theme, "muted", seat.step ?? "thinking")}`;
124
+ if (seat.status === "failed") return `${name} ${paint(theme, "error", "✗ failed — r retries")}`;
125
+ if (seat.status === "idle") return `${name} ${paint(theme, "dim", "·")}`;
126
+ if (seat.ready) return `${name} ${paint(theme, "success", "✓ ready")}`;
127
+ return `${name} ${paint(theme, "warning", `${seat.questions} question${seat.questions === 1 ? "" : "s"}`)}`;
128
+ }
129
+
130
+ /** The panel roster; while working, each seat's current step, so one busy seat does not look like a hang. */
131
+ export function rosterLines(view: PlanView, width: number, tick: number, theme?: LobbyTheme): string[] {
132
+ const cells = view.seats.map((seat) => seatCell(seat, tick, theme));
133
+ return wrap(`${paint(theme, "dim", "panel")} ${cells.join(paint(theme, "dim", " · "))}`, width);
134
+ }
135
+
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];
143
+ });
144
+ }
145
+
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[] {
151
+ const lines: string[] = [];
152
+ if (view.seed) {
153
+ lines.push(...wrap(paint(theme, "dim", `from issue #${view.seed.issue.number} — ${view.seed.issue.title}`), width), "");
154
+ }
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;
160
+ if (lines.length > 0 && lines.at(-1) !== "") lines.push("");
161
+ if (message.role === "you") {
162
+ lines.push(...wrapHanging(`${bold(theme, paint(theme, "accent", "you"))} ${paint(theme, "dim", "▸")} `, message.text, width));
163
+ continue;
164
+ }
165
+ lines.push(`${bold(theme, paint(theme, "toolTitle", "panel"))} ${paint(theme, "dim", "▸")}`);
166
+ lines.push(...(questions.length > 0 ? questionLines(questions, width, theme) : wrap(message.text, width)));
167
+ }
168
+ if (query && lines.length === 0) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${query}".`), width);
169
+ return lines;
170
+ }
171
+
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[] {
179
+ const plan = view.reply?.plan;
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
+ }
197
+ return lines;
198
+ }
199
+
200
+ export function renderPlan(input: PlanTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
201
+ if (height <= 0) return [];
202
+ const notice = input.notice ? [paint(theme, "accent", input.notice)] : [];
203
+ const view = input.session;
204
+ if (!view) return fill([...notice, ...box(width, height - notice.length, intro(input, width - 4, theme), { title: "Plan", right: input.profile, theme })], height, width);
205
+ const header = [
206
+ ...notice,
207
+ ...wrap(`${bold(theme, view.title ? `Planning · ${view.title}` : "Planning")} ${statusLine(view, input, theme)}`, width),
208
+ ...rosterLines(view, width, input.tick, theme),
209
+ ];
210
+ const bodyHeight = Math.max(0, height - header.length);
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);
239
+ }
240
+ const body = wide ? beside([talk, draftPane]) : [...talk, ...draftPane];
241
+ return fill([...header, ...body], height, width);
242
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The Quick fix tab: direct prompts that skip the whole workflow. The job list
3
+ * on one side, the selected job's plain-words steps and final report on the
4
+ * other.
5
+ */
6
+ import { shortDuration } from "../../text.ts";
7
+ import { jobTitle, type QuickFixJob } from "../quickfix.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
+
10
+ export interface QuickFixTabInput {
11
+ jobs: readonly QuickFixJob[];
12
+ /** Index into `jobs` shown newest first. */
13
+ selected: number;
14
+ focus: "list" | "detail";
15
+ detailOffset: number;
16
+ profile: string;
17
+ tick: number;
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;
23
+ }
24
+
25
+ export const QUICKFIX_COLUMNS_MIN = 90;
26
+
27
+ const STATUS_COLORS: Record<QuickFixJob["status"], "accent" | "success" | "error" | "warning" | "dim" | "muted"> = {
28
+ queued: "muted",
29
+ running: "accent",
30
+ success: "success",
31
+ failed: "error",
32
+ timeout: "error",
33
+ cancelled: "warning",
34
+ };
35
+
36
+ function mark(job: QuickFixJob, tick: number, theme?: LobbyTheme): string {
37
+ if (job.status === "running") return paint(theme, "accent", spinner(tick));
38
+ if (job.status === "queued") return paint(theme, "muted", "…");
39
+ if (job.status === "success") return paint(theme, "success", "✓");
40
+ if (job.status === "cancelled") return paint(theme, "warning", "·");
41
+ return paint(theme, "error", "✗");
42
+ }
43
+
44
+ function elapsed(job: QuickFixJob, now: number): string {
45
+ if (!job.startedAt) return "";
46
+ return shortDuration((job.finishedAt ?? now) - job.startedAt);
47
+ }
48
+
49
+ /** Jobs newest first, the order the list shows. */
50
+ export function newestFirst(jobs: readonly QuickFixJob[]): QuickFixJob[] {
51
+ return [...jobs].reverse();
52
+ }
53
+
54
+ export function jobDetailLines(job: QuickFixJob, width: number, tick: number, now: number, theme?: LobbyTheme): string[] {
55
+ const facts = [
56
+ paint(theme, STATUS_COLORS[job.status], job.status),
57
+ elapsed(job, now),
58
+ job.model ? `${job.model}${job.thinking ? ` · ${job.thinking}` : ""}` : job.thinking ?? "",
59
+ job.tools ? `${job.tools} tool${job.tools === 1 ? "" : "s"}` : "",
60
+ job.usage?.cost ? `$${job.usage.cost.toFixed(2)}` : "",
61
+ ].filter(Boolean);
62
+ const lines = [paint(theme, "dim", job.id), facts.join(paint(theme, "dim", " · ")), "", ...wrap(bold(theme, job.prompt), width)];
63
+ lines.push("", rule(width, "Steps", theme));
64
+ if (job.steps.length === 0) lines.push(paint(theme, "dim", job.status === "queued" ? "Waiting for the quick fix ahead of it." : job.status === "running" ? `${spinner(tick)} starting…` : "No tool calls."));
65
+ for (const step of job.steps) {
66
+ const lead = `${paint(theme, "dim", clock(step.at))} ${step.pending && job.status === "running" ? paint(theme, "accent", spinner(tick)) : paint(theme, "dim", "·")} `;
67
+ lines.push(...wrapHanging(lead, step.pending && job.status === "running" ? `${step.text}…` : paint(theme, "muted", step.text), width));
68
+ }
69
+ if (job.error) lines.push("", ...wrap(paint(theme, "error", `✗ ${job.error}`), width));
70
+ if (job.report) {
71
+ lines.push("", rule(width, "Report", theme), ...markdownLines(job.report, width, theme));
72
+ }
73
+ return lines;
74
+ }
75
+
76
+ function intro(input: QuickFixTabInput, width: number, theme?: LobbyTheme): string[] {
77
+ return [
78
+ bold(theme, "Make a direct change, the way you would ask pi."),
79
+ "",
80
+ "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.",
81
+ "",
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.",
83
+ "",
84
+ paint(theme, "dim", `quick fix: ${input.profile} — esc, then m changes the model, thinking, time limit and instructions (alt+s: all settings)`),
85
+ ].flatMap((line) => wrap(line, width));
86
+ }
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
+
102
+ export function renderQuickFix(input: QuickFixTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
103
+ if (height <= 0) return [];
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
+ }
109
+ const selected = Math.min(Math.max(0, input.selected), jobs.length - 1);
110
+ const { list: listWidth, detail: detailWidth, wide } = quickFixWidths(width);
111
+ const rows = jobs.map((job, index) => {
112
+ const time = elapsed(job, input.now);
113
+ const text = `${mark(job, input.tick, theme)} ${index === selected ? bold(theme, jobTitle(job)) : jobTitle(job)}${time ? ` ${paint(theme, "dim", time)}` : ""}`;
114
+ return selectRow(theme, text, listWidth - 4, index === selected, input.focus === "list");
115
+ });
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);
129
+ }
@@ -0,0 +1,243 @@
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/zen.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 { ago, beside, bold, box, detailWindow, fill, markdownLines, notePane, paint, position, rule, selectRow, since, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
15
+
16
+ export type TaskSection = "mine" | "others" | "pending" | "recent";
17
+
18
+ export interface TaskRow {
19
+ kind: "task" | "plan";
20
+ id: string;
21
+ title: string;
22
+ section: TaskSection;
23
+ /** Task state, or `pending` for a saved plan. */
24
+ status: string;
25
+ /** Right-hand note: progress, owner or issue. */
26
+ meta: string;
27
+ }
28
+
29
+ export const SECTION_TITLES: Record<TaskSection, string> = {
30
+ mine: "THIS SESSION",
31
+ others: "OTHER SESSIONS",
32
+ pending: "PENDING (planned)",
33
+ recent: "RECENT",
34
+ };
35
+
36
+ /** Finished tasks listed under RECENT. */
37
+ export const RECENT_LIMIT = 8;
38
+
39
+ /** Checklist progress as `3/7`, or undefined before a plan exists. */
40
+ export function taskProgress(task: Task): { done: number; total: number } | undefined {
41
+ if (!task.plan) return undefined;
42
+ const steps = planChecklist(task.plan, persistedRuns(task));
43
+ if (steps.length === 0) return undefined;
44
+ return { done: steps.filter((step) => step.status === "done").length, total: steps.length };
45
+ }
46
+
47
+ function ownerLabel(task: Task, sessionId: string | undefined): string {
48
+ if (!task.ownerSessionId) return "no owner";
49
+ if (task.ownerSessionId === sessionId) return "this session";
50
+ return `session ${task.ownerSessionId.slice(0, 8)}`;
51
+ }
52
+
53
+ /** Rows in display order: this session's task, other sessions', pending plans, then recent finished tasks. */
54
+ export function taskRows(tasks: readonly Task[], plans: readonly PlannedTask[], sessionId: string | undefined, now: number): TaskRow[] {
55
+ const active = tasks.filter((task) => !TERMINAL_STATES.includes(task.state));
56
+ const row = (task: Task, section: TaskSection): TaskRow => {
57
+ const progress = taskProgress(task);
58
+ const meta = section === "recent" ? ago(now - Date.parse(task.updatedAt)) : progress ? `${progress.done}/${progress.total}` : section === "others" ? ownerLabel(task, sessionId) : "";
59
+ return { kind: "task", id: task.id, title: task.title, section, status: task.paused ? `${task.state} (paused)` : task.state, meta };
60
+ };
61
+ return [
62
+ ...active.filter((task) => sessionId && task.ownerSessionId === sessionId).map((task) => row(task, "mine")),
63
+ ...active.filter((task) => !sessionId || task.ownerSessionId !== sessionId).map((task) => row(task, "others")),
64
+ ...plans.filter((plan) => plan.status === "pending").map((plan): TaskRow => ({
65
+ kind: "plan", id: plan.id, title: plan.title, section: "pending", status: "pending", meta: plan.issue ? `#${plan.issue.number}` : "",
66
+ })),
67
+ ...tasks.filter((task) => TERMINAL_STATES.includes(task.state)).slice(0, RECENT_LIMIT).map((task) => row(task, "recent")),
68
+ ];
69
+ }
70
+
71
+ const STATE_COLORS: Record<string, "success" | "warning" | "error" | "accent" | "muted" | "dim"> = {
72
+ completed: "success",
73
+ abandoned: "dim",
74
+ blocked: "error",
75
+ awaiting_approval: "warning",
76
+ implementing: "accent",
77
+ reviewing: "accent",
78
+ pending: "muted",
79
+ };
80
+
81
+ function statusColor(status: string): "success" | "warning" | "error" | "accent" | "muted" | "dim" {
82
+ return STATE_COLORS[status.replace(/ \(paused\)$/, "")] ?? "muted";
83
+ }
84
+
85
+ /** List lines with section headers, plus which line holds the selected row. */
86
+ export function listLines(rows: readonly TaskRow[], selected: number, width: number, focused: boolean, theme?: LobbyTheme): { lines: string[]; selectedLine: number } {
87
+ const lines: string[] = [];
88
+ let selectedLine = 0;
89
+ let section: TaskSection | undefined;
90
+ rows.forEach((row, index) => {
91
+ if (row.section !== section) {
92
+ section = row.section;
93
+ if (lines.length > 0) lines.push("");
94
+ lines.push(paint(theme, "dim", SECTION_TITLES[section]));
95
+ }
96
+ const marker = index === selected ? paint(theme, "accent", "▸ ") : " ";
97
+ const status = paint(theme, statusColor(row.status), row.status.replace(/_/g, " "));
98
+ const meta = row.meta ? ` ${paint(theme, "dim", row.meta)}` : "";
99
+ const title = index === selected ? bold(theme, row.title) : row.title;
100
+ if (index === selected) selectedLine = lines.length;
101
+ lines.push(selectRow(theme, `${marker}${title} ${paint(theme, "dim", "·")} ${status}${meta}`, width, index === selected, focused));
102
+ });
103
+ return { lines, selectedLine };
104
+ }
105
+
106
+ const COMMENT_MARKS: Record<PlanComment["status"], string> = { open: "○", delivered: "◐", addressed: "✓" };
107
+ const COMMENT_WORDS: Record<PlanComment["status"], string> = { open: "waiting for the owning session", delivered: "sent to the oracle", addressed: "plan amended" };
108
+
109
+ function section(title: string, width: number, theme?: LobbyTheme, right = ""): string[] {
110
+ return ["", rule(width, title, theme, right)];
111
+ }
112
+
113
+ /** Detail lines for a task: header, plan with progress, comments, amendments, waits and runs. */
114
+ export function taskDetailLines(task: Task, comments: readonly PlanComment[], sessionId: string | undefined, width: number, now: number, theme?: LobbyTheme): string[] {
115
+ const lines: string[] = [];
116
+ lines.push(...wrap(bold(theme, `${task.title}`), width));
117
+ const facts = [
118
+ paint(theme, statusColor(task.state), `${task.state.replace(/_/g, " ")}${task.paused ? " (paused)" : ""}`),
119
+ ownerLabel(task, sessionId),
120
+ `started ${since(now - Date.parse(task.createdAt))}`,
121
+ task.domains.length > 0 ? task.domains.join(", ") : "",
122
+ ].filter(Boolean);
123
+ lines.push(paint(theme, "dim", task.id), facts.join(paint(theme, "dim", " · ")));
124
+ const request = taskRequest(task);
125
+ if (request && request !== task.title) lines.push(...section("Request", width, theme), ...wrap(paint(theme, "muted", request), width));
126
+ if (task.plan) {
127
+ const steps = planChecklist(task.plan, persistedRuns(task));
128
+ const done = steps.filter((step) => step.status === "done").length;
129
+ lines.push(...section("Approved plan", width, theme, steps.length > 0 ? `${done}/${steps.length} steps` : ""));
130
+ for (const [index, step] of steps.entries()) {
131
+ const mark = step.status === "done" ? paint(theme, "success", "✓") : step.status === "current" ? paint(theme, "accent", "▸") : paint(theme, "dim", "○");
132
+ const text = step.status === "pending" ? paint(theme, "muted", step.text) : step.text;
133
+ lines.push(...wrapHanging(`${mark} ${index + 1}. `, text, width));
134
+ }
135
+ if (steps.length > 0) lines.push("");
136
+ lines.push(...markdownLines(task.plan, width, theme));
137
+ } else if (task.proposal) {
138
+ lines.push(...section("Proposal (no plan yet)", width, theme), ...markdownLines(task.proposal, width, theme));
139
+ } else {
140
+ lines.push(...section("Plan", width, theme), paint(theme, "dim", "No proposal or plan yet — the oracle is still clarifying or scouting."));
141
+ }
142
+ const open = comments.filter((comment) => comment.status !== "addressed").length;
143
+ lines.push(...section("Comments", width, theme, open > 0 ? `${open} open` : ""));
144
+ 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."));
145
+ for (const comment of comments) {
146
+ const color = comment.status === "addressed" ? "success" : comment.status === "delivered" ? "accent" : "warning";
147
+ lines.push(...wrapHanging(`${paint(theme, color, COMMENT_MARKS[comment.status])} `, `${comment.text} ${paint(theme, "dim", `— ${COMMENT_WORDS[comment.status]}, ${since(now - Date.parse(comment.createdAt))}`)}`, width));
148
+ }
149
+ if (task.amendments.length > 0) {
150
+ lines.push(...section("Amendments", width, theme));
151
+ for (const amendment of task.amendments) lines.push(...wrapHanging("- ", amendment, width));
152
+ }
153
+ const approvals = pendingApprovals(task);
154
+ if (approvals.length > 0 || task.blockers.length > 0) {
155
+ lines.push(...section("Waiting on", width, theme));
156
+ for (const approval of approvals) lines.push(...wrapHanging(`${paint(theme, "warning", "!")} `, `${approval.kind} for ${approval.domain}: ${approval.detail}`, width));
157
+ for (const blocker of task.blockers) lines.push(...wrapHanging(`${paint(theme, "error", "✗")} `, `${blocker.reason} (needs: ${blocker.need})`, width));
158
+ }
159
+ const runs = (task.runLog ?? []).slice(-6);
160
+ if (runs.length > 0) {
161
+ lines.push(...section("Recent runs", width, theme));
162
+ for (const entry of runs) lines.push(...wrap(paint(theme, "muted", describeRun(runFromLog(entry, task.id), now)), width));
163
+ }
164
+ return lines;
165
+ }
166
+
167
+ /** Detail lines for a saved plan waiting to start. */
168
+ export function planDetailLines(plan: PlannedTask, width: number, now: number, theme?: LobbyTheme): string[] {
169
+ const lines = [
170
+ ...wrap(bold(theme, plan.title), width),
171
+ paint(theme, "dim", plan.id),
172
+ [paint(theme, "muted", "pending"), `saved ${since(now - Date.parse(plan.createdAt))}`].join(paint(theme, "dim", " · ")),
173
+ ];
174
+ if (plan.issue) lines.push(...wrap(`from issue #${plan.issue.number} — ${plan.issue.title}${plan.issue.url ? ` ${paint(theme, "dim", plan.issue.url)}` : ""}`, width));
175
+ lines.push("", paint(theme, "dim", "s starts it as a task in this session · d discards it"));
176
+ lines.push(...section("Agreed plan", width, theme), ...markdownLines(plan.brief, width, theme));
177
+ return lines;
178
+ }
179
+
180
+ export interface TasksInput {
181
+ rows: readonly TaskRow[];
182
+ selected: number;
183
+ /** Detail lines for the selected row, already fitted to the detail pane. */
184
+ detail: readonly string[];
185
+ focus: "list" | "detail";
186
+ detailOffset: number;
187
+ notice?: string;
188
+ /** The search in force; the rows are already filtered by it. */
189
+ query?: string;
190
+ /** Filled with where the list and the detail landed, for scrolling. */
191
+ panes?: PaneLayout;
192
+ }
193
+
194
+ /** Narrow terminals show the list or the detail, not both. */
195
+ export const TASKS_COLUMNS_MIN = 90;
196
+
197
+ /** Outer widths of the list and detail panes (the detail's text is 4 columns narrower). */
198
+ export function tasksWidths(width: number): { list: number; detail: number; wide: boolean } {
199
+ if (width < TASKS_COLUMNS_MIN) return { list: width, detail: width, wide: false };
200
+ const list = Math.max(36, Math.round((width - 1) * 0.36));
201
+ return { list, detail: width - 1 - list, wide: true };
202
+ }
203
+
204
+ /** Everything a search looks at for one row: id, title, request, proposal, plan. */
205
+ function rowText(row: TaskRow, tasks: readonly Task[], plans: readonly PlannedTask[]): string {
206
+ if (row.kind === "plan") {
207
+ const plan = plans.find((entry) => entry.id === row.id);
208
+ return [row.id, row.title, plan?.brief ?? "", plan?.issue?.title ?? ""].join("\n");
209
+ }
210
+ const task = tasks.find((entry) => entry.id === row.id);
211
+ return [row.id, row.title, row.status, task ? taskRequest(task) : "", task?.proposal ?? "", task?.plan ?? ""].join("\n");
212
+ }
213
+
214
+ /** The rows whose task or plan mentions `query` anywhere. */
215
+ export function filterRows(rows: readonly TaskRow[], tasks: readonly Task[], plans: readonly PlannedTask[], query: string | undefined): TaskRow[] {
216
+ const needle = query?.trim().toLowerCase();
217
+ if (!needle) return [...rows];
218
+ return rows.filter((row) => rowText(row, tasks, plans).toLowerCase().includes(needle));
219
+ }
220
+
221
+ export function renderTasks(input: TasksInput, width: number, height: number, theme?: LobbyTheme): string[] {
222
+ if (height <= 0) return [];
223
+ const notice = input.notice ? [paint(theme, "accent", input.notice)] : [];
224
+ const bodyHeight = height - notice.length;
225
+ if (input.rows.length === 0) {
226
+ 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.";
227
+ return fill([...notice, ...box(width, bodyHeight, [paint(theme, "dim", empty)], { title: "Tasks", theme })], height, width);
228
+ }
229
+ const { list: listWidth, detail: detailWidth, wide } = tasksWidths(width);
230
+ const list = listLines(input.rows, input.selected, listWidth - 4, input.focus === "list", theme);
231
+ const rows = Math.max(0, bodyHeight - 2);
232
+ const listStart = windowStart(list.selectedLine, list.lines.length, rows);
233
+ const count = input.query ? `${input.rows.length} match${input.rows.length === 1 ? "" : "es"}` : `${input.rows.length}`;
234
+ 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 });
235
+ const detailStart = detailWindow(input.detail.length, rows, input.detailOffset);
236
+ 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 });
237
+ const showList = wide || input.focus !== "detail";
238
+ const showDetail = wide || input.focus === "detail";
239
+ if (showList) notePane(input.panes, "list", notice.length, 0, listWidth, bodyHeight, list.lines.length);
240
+ if (showDetail) notePane(input.panes, "detail", notice.length, wide ? listWidth + 1 : 0, detailWidth, bodyHeight, input.detail.length);
241
+ if (!wide) return fill([...notice, ...(showDetail ? detailPane : listPane)], height, width);
242
+ return fill([...notice, ...beside([listPane, detailPane])], height, width);
243
+ }