@a-t-h-i/bot-lobby 0.4.0 → 0.5.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.
@@ -0,0 +1,209 @@
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, bold, columns, fill, markdownLines, paint, rule, selectRow, since, split, windowStart, wrap, wrapHanging, type LobbyTheme } 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. */
184
+ detail: readonly string[];
185
+ focus: "list" | "detail";
186
+ detailOffset: number;
187
+ notice?: string;
188
+ }
189
+
190
+ /** Narrow terminals show the list or the detail, not both. */
191
+ export const TASKS_COLUMNS_MIN = 90;
192
+
193
+ export function renderTasks(input: TasksInput, width: number, height: number, theme?: LobbyTheme): string[] {
194
+ if (height <= 0) return [];
195
+ const notice = input.notice ? [paint(theme, "accent", input.notice)] : [];
196
+ const bodyHeight = height - notice.length;
197
+ if (input.rows.length === 0) {
198
+ return fill([...notice, paint(theme, "dim", "No tasks yet. Start one from the Lobby tab, or plan one in the Plan tab.")], height, width);
199
+ }
200
+ const wide = width >= TASKS_COLUMNS_MIN;
201
+ const [listWidth, detailWidth] = wide ? split(width, 0.38, 3, 40) : [width, width];
202
+ const list = listLines(input.rows, input.selected, listWidth, input.focus === "list", theme);
203
+ const listShown = list.lines.slice(windowStart(list.selectedLine, list.lines.length, bodyHeight - 1));
204
+ const detailShown = input.detail.slice(Math.max(0, Math.min(input.detailOffset, input.detail.length - 1)));
205
+ const listPane = fill([rule(listWidth, "Tasks", theme, `${input.rows.length}`), ...listShown], bodyHeight);
206
+ const detailPane = fill([rule(detailWidth, input.focus === "detail" ? "Detail ◂" : "Detail", theme), ...detailShown], bodyHeight);
207
+ if (!wide) return fill([...notice, ...(input.focus === "detail" ? detailPane : listPane)], height, width);
208
+ return fill([...notice, ...columns(listPane, detailPane, listWidth, detailWidth, " │ ", theme)], height, width);
209
+ }