@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/tasks.ts
CHANGED
|
@@ -11,10 +11,13 @@ import { planChecklist } from "../../pi/zen.ts";
|
|
|
11
11
|
import { persistedRuns } from "../../pi/ui.ts";
|
|
12
12
|
import { describeRun, runFromLog } from "../../pi/run-summary.ts";
|
|
13
13
|
import { pendingApprovals } from "../../workflow/approvals.ts";
|
|
14
|
-
import { ago, bold,
|
|
14
|
+
import { ago, beside, bold, box, detailWindow, fill, markdownLines, notePane, paint, position, rule, selectRow, since, spread, strike, windowStart, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
|
|
15
15
|
|
|
16
16
|
export type TaskSection = "mine" | "others" | "pending" | "recent";
|
|
17
17
|
|
|
18
|
+
/** How a row is ticked off: still to do (a plan, or a task under way), completed, or abandoned. */
|
|
19
|
+
export type CheckState = "open" | "done" | "dropped";
|
|
20
|
+
|
|
18
21
|
export interface TaskRow {
|
|
19
22
|
kind: "task" | "plan";
|
|
20
23
|
id: string;
|
|
@@ -22,17 +25,44 @@ export interface TaskRow {
|
|
|
22
25
|
section: TaskSection;
|
|
23
26
|
/** Task state, or `pending` for a saved plan. */
|
|
24
27
|
status: string;
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
paused?: boolean;
|
|
29
|
+
check: CheckState;
|
|
30
|
+
/** Plan steps done, once the task has a plan. */
|
|
31
|
+
progress?: { done: number; total: number };
|
|
32
|
+
/** Who drives it, for tasks other sessions own. */
|
|
33
|
+
owner?: string;
|
|
34
|
+
/** How long ago it finished (finished tasks) or was saved (plans), as `3h`. */
|
|
35
|
+
age?: string;
|
|
36
|
+
/** The GitHub issue a plan came from. */
|
|
37
|
+
issue?: number;
|
|
38
|
+
/** Auto mode is on: the oracle drives it without asking. */
|
|
39
|
+
auto?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What the lobby knows about sessions beyond this one: background sessions' names, tasks in auto mode. */
|
|
43
|
+
export interface RowContext {
|
|
44
|
+
/** Background sessions this window started, by pi session id. */
|
|
45
|
+
names?: ReadonlyMap<string, string>;
|
|
46
|
+
/** Tasks with auto mode on. */
|
|
47
|
+
auto?: ReadonlySet<string>;
|
|
27
48
|
}
|
|
28
49
|
|
|
29
50
|
export const SECTION_TITLES: Record<TaskSection, string> = {
|
|
30
51
|
mine: "THIS SESSION",
|
|
31
52
|
others: "OTHER SESSIONS",
|
|
32
|
-
pending: "PENDING
|
|
33
|
-
recent: "
|
|
53
|
+
pending: "PENDING",
|
|
54
|
+
recent: "FINISHED",
|
|
34
55
|
};
|
|
35
56
|
|
|
57
|
+
/** The box each row wears: empty while there is work to do, ticked when completed, crossed when abandoned. */
|
|
58
|
+
export const CHECK_MARKS: Record<CheckState, string> = { open: "☐", done: "☑", dropped: "☒" };
|
|
59
|
+
|
|
60
|
+
export function checkState(task: Task): CheckState {
|
|
61
|
+
if (task.state === "completed") return "done";
|
|
62
|
+
if (task.state === "abandoned") return "dropped";
|
|
63
|
+
return "open";
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
/** Finished tasks listed under RECENT. */
|
|
37
67
|
export const RECENT_LIMIT = 8;
|
|
38
68
|
|
|
@@ -44,65 +74,153 @@ export function taskProgress(task: Task): { done: number; total: number } | unde
|
|
|
44
74
|
return { done: steps.filter((step) => step.status === "done").length, total: steps.length };
|
|
45
75
|
}
|
|
46
76
|
|
|
47
|
-
function ownerLabel(task: Task, sessionId: string | undefined): string {
|
|
77
|
+
function ownerLabel(task: Task, sessionId: string | undefined, names?: ReadonlyMap<string, string>): string {
|
|
48
78
|
if (!task.ownerSessionId) return "no owner";
|
|
49
79
|
if (task.ownerSessionId === sessionId) return "this session";
|
|
80
|
+
const name = names?.get(task.ownerSessionId);
|
|
81
|
+
// A background session is named after its task, so the row's title already says which one.
|
|
82
|
+
if (name) return name === task.title ? "background" : `background · ${name}`;
|
|
50
83
|
return `session ${task.ownerSessionId.slice(0, 8)}`;
|
|
51
84
|
}
|
|
52
85
|
|
|
53
86
|
/** 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[] {
|
|
87
|
+
export function taskRows(tasks: readonly Task[], plans: readonly PlannedTask[], sessionId: string | undefined, now: number, context: RowContext = {}): TaskRow[] {
|
|
55
88
|
const active = tasks.filter((task) => !TERMINAL_STATES.includes(task.state));
|
|
56
89
|
const row = (task: Task, section: TaskSection): TaskRow => {
|
|
57
|
-
const progress = taskProgress(task);
|
|
58
|
-
const
|
|
59
|
-
return {
|
|
90
|
+
const progress = section === "recent" ? undefined : taskProgress(task);
|
|
91
|
+
const auto = section !== "recent" && context.auto?.has(task.id);
|
|
92
|
+
return {
|
|
93
|
+
kind: "task",
|
|
94
|
+
id: task.id,
|
|
95
|
+
title: task.title,
|
|
96
|
+
section,
|
|
97
|
+
status: task.state,
|
|
98
|
+
...(task.paused && section !== "recent" ? { paused: true } : {}),
|
|
99
|
+
check: checkState(task),
|
|
100
|
+
...(progress ? { progress } : {}),
|
|
101
|
+
...(section === "others" ? { owner: ownerLabel(task, sessionId, context.names) } : {}),
|
|
102
|
+
...(section === "recent" ? { age: ago(now - Date.parse(task.updatedAt)) } : {}),
|
|
103
|
+
...(auto ? { auto: true } : {}),
|
|
104
|
+
};
|
|
60
105
|
};
|
|
61
106
|
return [
|
|
62
107
|
...active.filter((task) => sessionId && task.ownerSessionId === sessionId).map((task) => row(task, "mine")),
|
|
63
108
|
...active.filter((task) => !sessionId || task.ownerSessionId !== sessionId).map((task) => row(task, "others")),
|
|
64
109
|
...plans.filter((plan) => plan.status === "pending").map((plan): TaskRow => ({
|
|
65
|
-
kind: "plan",
|
|
110
|
+
kind: "plan",
|
|
111
|
+
id: plan.id,
|
|
112
|
+
title: plan.title,
|
|
113
|
+
section: "pending",
|
|
114
|
+
status: "pending",
|
|
115
|
+
check: "open",
|
|
116
|
+
...(plan.createdAt ? { age: ago(now - Date.parse(plan.createdAt)) } : {}),
|
|
117
|
+
...(plan.issue ? { issue: plan.issue.number } : {}),
|
|
66
118
|
})),
|
|
67
119
|
...tasks.filter((task) => TERMINAL_STATES.includes(task.state)).slice(0, RECENT_LIMIT).map((task) => row(task, "recent")),
|
|
68
120
|
];
|
|
69
121
|
}
|
|
70
122
|
|
|
71
|
-
|
|
123
|
+
type StateColor = Extract<LobbyColor, "success" | "warning" | "error" | "accent" | "muted" | "dim">;
|
|
124
|
+
|
|
125
|
+
const STATE_COLORS: Record<string, StateColor> = {
|
|
72
126
|
completed: "success",
|
|
73
127
|
abandoned: "dim",
|
|
74
128
|
blocked: "error",
|
|
75
129
|
awaiting_approval: "warning",
|
|
130
|
+
planning: "accent",
|
|
76
131
|
implementing: "accent",
|
|
77
132
|
reviewing: "accent",
|
|
78
133
|
pending: "muted",
|
|
79
134
|
};
|
|
80
135
|
|
|
81
|
-
function statusColor(status: string):
|
|
82
|
-
return STATE_COLORS[status
|
|
136
|
+
function statusColor(status: string): StateColor {
|
|
137
|
+
return STATE_COLORS[status] ?? "muted";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A task's state in words: `awaiting approval`, `implementing · paused`. */
|
|
141
|
+
function stateWords(status: string, paused?: boolean): string {
|
|
142
|
+
return `${status.replace(/_/g, " ")}${paused ? " · paused" : ""}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The row's box, coloured by what it says: the state's colour while open, green when done, dim when dropped. */
|
|
146
|
+
export function checkMark(row: Pick<TaskRow, "check" | "status" | "paused">, theme?: LobbyTheme): string {
|
|
147
|
+
const color: StateColor = row.check === "done" ? "success" : row.check === "dropped" ? "dim" : row.paused ? "warning" : statusColor(row.status);
|
|
148
|
+
return paint(theme, color, CHECK_MARKS[row.check]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A title as its box says: struck through and dim once abandoned, bold while selected. */
|
|
152
|
+
function rowTitle(row: TaskRow, selected: boolean, theme?: LobbyTheme): string {
|
|
153
|
+
if (row.check === "dropped") return paint(theme, "dim", strike(theme, row.title));
|
|
154
|
+
if (row.check === "done") return selected ? bold(theme, row.title) : paint(theme, "muted", row.title);
|
|
155
|
+
return selected ? bold(theme, row.title) : row.title;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Progress as pips, `cells` wide: filled for the share done, hollow for the rest. */
|
|
159
|
+
export function pips(done: number, total: number, cells: number, theme?: LobbyTheme): string {
|
|
160
|
+
if (cells <= 0 || total <= 0) return "";
|
|
161
|
+
const filled = Math.min(cells, Math.round((Math.max(0, done) / total) * cells));
|
|
162
|
+
return `${paint(theme, done >= total ? "success" : "accent", "▰".repeat(filled))}${paint(theme, "borderMuted", "▱".repeat(cells - filled))}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Plan progress on a row: a pip per step (up to eight) and `3/5`. */
|
|
166
|
+
function progressText(progress: TaskRow["progress"], theme?: LobbyTheme): string {
|
|
167
|
+
if (!progress) return "";
|
|
168
|
+
return `${pips(progress.done, progress.total, Math.min(progress.total, 8), theme)} ${paint(theme, "muted", `${progress.done}/${progress.total}`)}`;
|
|
83
169
|
}
|
|
84
170
|
|
|
85
|
-
/**
|
|
171
|
+
/** 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. */
|
|
172
|
+
function detailsLine(row: TaskRow, theme?: LobbyTheme): string {
|
|
173
|
+
const dot = paint(theme, "dim", " · ");
|
|
174
|
+
if (row.kind === "plan") {
|
|
175
|
+
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);
|
|
176
|
+
}
|
|
177
|
+
return [
|
|
178
|
+
paint(theme, row.paused ? "warning" : statusColor(row.status), stateWords(row.status, row.paused)),
|
|
179
|
+
row.auto ? paint(theme, "success", "⟳ auto") : "",
|
|
180
|
+
row.owner ? paint(theme, "dim", row.owner) : "",
|
|
181
|
+
].filter(Boolean).join(dot);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* List lines with a rule over each section, plus which line holds the
|
|
186
|
+
* selected row. A task or plan still to do takes two lines — its box and
|
|
187
|
+
* title (with plan progress on the right), then its state, owner and auto
|
|
188
|
+
* mode; a finished one takes one, ticked or struck through, with its age.
|
|
189
|
+
*/
|
|
86
190
|
export function listLines(rows: readonly TaskRow[], selected: number, width: number, focused: boolean, theme?: LobbyTheme): { lines: string[]; selectedLine: number } {
|
|
87
191
|
const lines: string[] = [];
|
|
88
192
|
let selectedLine = 0;
|
|
89
193
|
let section: TaskSection | undefined;
|
|
194
|
+
const counts = new Map<TaskSection, number>();
|
|
195
|
+
for (const row of rows) counts.set(row.section, (counts.get(row.section) ?? 0) + 1);
|
|
90
196
|
rows.forEach((row, index) => {
|
|
91
197
|
if (row.section !== section) {
|
|
92
198
|
section = row.section;
|
|
93
199
|
if (lines.length > 0) lines.push("");
|
|
94
|
-
lines.push(
|
|
200
|
+
lines.push(rule(width, SECTION_TITLES[section], theme, String(counts.get(section) ?? 0)));
|
|
201
|
+
}
|
|
202
|
+
const chosen = index === selected;
|
|
203
|
+
const marker = chosen ? paint(theme, "accent", "▸") : " ";
|
|
204
|
+
const head = `${marker} ${checkMark(row, theme)} ${rowTitle(row, chosen, theme)}`;
|
|
205
|
+
if (chosen) selectedLine = lines.length;
|
|
206
|
+
if (row.check !== "open") {
|
|
207
|
+
lines.push(selectRow(theme, spread(head, row.age ? paint(theme, "dim", row.age) : "", width), width, chosen, focused));
|
|
208
|
+
return;
|
|
95
209
|
}
|
|
96
|
-
|
|
97
|
-
|
|
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));
|
|
210
|
+
lines.push(selectRow(theme, spread(head, progressText(row.progress, theme), width), width, chosen, focused));
|
|
211
|
+
lines.push(selectRow(theme, ` ${detailsLine(row, theme)}`, width, chosen, focused));
|
|
102
212
|
});
|
|
103
213
|
return { lines, selectedLine };
|
|
104
214
|
}
|
|
105
215
|
|
|
216
|
+
/** The list pane's right-hand count: what is open and what is finished, or how many rows match a search. */
|
|
217
|
+
export function listCount(rows: readonly TaskRow[], query?: string): string {
|
|
218
|
+
if (query) return `${rows.length} match${rows.length === 1 ? "" : "es"}`;
|
|
219
|
+
const open = rows.filter((row) => row.check === "open").length;
|
|
220
|
+
const finished = rows.length - open;
|
|
221
|
+
return [open ? `${open} open` : "", finished ? `${finished} finished` : ""].filter(Boolean).join(" · ") || "0";
|
|
222
|
+
}
|
|
223
|
+
|
|
106
224
|
const COMMENT_MARKS: Record<PlanComment["status"], string> = { open: "○", delivered: "◐", addressed: "✓" };
|
|
107
225
|
const COMMENT_WORDS: Record<PlanComment["status"], string> = { open: "waiting for the owning session", delivered: "sent to the oracle", addressed: "plan amended" };
|
|
108
226
|
|
|
@@ -113,31 +231,44 @@ function section(title: string, width: number, theme?: LobbyTheme, right = ""):
|
|
|
113
231
|
/** Detail lines for a task: header, plan with progress, comments, amendments, waits and runs. */
|
|
114
232
|
export function taskDetailLines(task: Task, comments: readonly PlanComment[], sessionId: string | undefined, width: number, now: number, theme?: LobbyTheme): string[] {
|
|
115
233
|
const lines: string[] = [];
|
|
116
|
-
|
|
234
|
+
const check = checkState(task);
|
|
235
|
+
const row = { check, status: task.state, ...(task.paused ? { paused: true } : {}) };
|
|
236
|
+
const title = check === "dropped" ? paint(theme, "dim", strike(theme, task.title)) : bold(theme, task.title);
|
|
237
|
+
lines.push(...wrapHanging(`${checkMark(row, theme)} `, title, width));
|
|
238
|
+
const dot = paint(theme, "dim", " · ");
|
|
239
|
+
const finished = TERMINAL_STATES.includes(task.state);
|
|
117
240
|
const facts = [
|
|
118
|
-
paint(theme, statusColor(task.state),
|
|
119
|
-
ownerLabel(task, sessionId),
|
|
120
|
-
`started ${since(now - Date.parse(task.createdAt))}
|
|
121
|
-
task.
|
|
241
|
+
bold(theme, paint(theme, task.paused ? "warning" : statusColor(task.state), stateWords(task.state, task.paused))),
|
|
242
|
+
finished ? "" : paint(theme, "muted", ownerLabel(task, sessionId)),
|
|
243
|
+
paint(theme, "muted", `started ${since(now - Date.parse(task.createdAt))}`),
|
|
244
|
+
finished ? paint(theme, "muted", `${task.state === "abandoned" ? "dropped" : "done"} ${since(now - Date.parse(task.updatedAt))}`) : "",
|
|
122
245
|
].filter(Boolean);
|
|
123
|
-
lines.push(
|
|
246
|
+
lines.push(...wrapHanging(" ", facts.join(dot), width));
|
|
247
|
+
lines.push(...wrapHanging(" ", paint(theme, "dim", [task.id, task.domains.length > 0 ? task.domains.join(", ") : ""].filter(Boolean).join(" · ")), width));
|
|
248
|
+
const steps = task.plan ? planChecklist(task.plan, persistedRuns(task)) : [];
|
|
249
|
+
const done = steps.filter((step) => step.status === "done").length;
|
|
250
|
+
if (steps.length > 0) {
|
|
251
|
+
const cells = Math.max(4, Math.min(24, width - 20));
|
|
252
|
+
lines.push("", ` ${pips(done, steps.length, cells, theme)} ${paint(theme, "muted", `${done} of ${steps.length} steps`)}`);
|
|
253
|
+
}
|
|
124
254
|
const request = taskRequest(task);
|
|
125
255
|
if (request && request !== task.title) lines.push(...section("Request", width, theme), ...wrap(paint(theme, "muted", request), width));
|
|
126
256
|
if (task.plan) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
257
|
+
if (steps.length > 0) {
|
|
258
|
+
lines.push(...section("Progress", width, theme, `${done}/${steps.length} steps`));
|
|
259
|
+
for (const [index, step] of steps.entries()) {
|
|
260
|
+
const mark = step.status === "done" ? paint(theme, "success", CHECK_MARKS.done) : paint(theme, step.status === "current" ? "accent" : "dim", CHECK_MARKS.open);
|
|
261
|
+
const text = step.status === "done" ? paint(theme, "muted", step.text) : step.status === "current" ? bold(theme, step.text) : paint(theme, "muted", step.text);
|
|
262
|
+
const current = step.status === "current" ? ` ${paint(theme, "accent", "◂ now")}` : "";
|
|
263
|
+
lines.push(...wrapHanging(`${mark} ${paint(theme, "dim", `${index + 1}.`)} `, `${text}${current}`, width));
|
|
264
|
+
}
|
|
134
265
|
}
|
|
135
|
-
|
|
266
|
+
lines.push(...section("Approved plan", width, theme));
|
|
136
267
|
lines.push(...markdownLines(task.plan, width, theme));
|
|
137
268
|
} else if (task.proposal) {
|
|
138
|
-
lines.push(...section("Proposal (no plan yet)", width, theme), ...markdownLines(task.proposal, width, theme));
|
|
269
|
+
lines.push(...section(TERMINAL_STATES.includes(task.state) ? "Proposal" : "Proposal (no plan yet)", width, theme), ...markdownLines(task.proposal, width, theme));
|
|
139
270
|
} else {
|
|
140
|
-
lines.push(...section("Plan", width, theme), paint(theme, "dim", "No proposal or plan yet — the oracle is still clarifying or scouting."));
|
|
271
|
+
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."));
|
|
141
272
|
}
|
|
142
273
|
const open = comments.filter((comment) => comment.status !== "addressed").length;
|
|
143
274
|
lines.push(...section("Comments", width, theme, open > 0 ? `${open} open` : ""));
|
|
@@ -166,13 +297,15 @@ export function taskDetailLines(task: Task, comments: readonly PlanComment[], se
|
|
|
166
297
|
|
|
167
298
|
/** Detail lines for a saved plan waiting to start. */
|
|
168
299
|
export function planDetailLines(plan: PlannedTask, width: number, now: number, theme?: LobbyTheme): string[] {
|
|
300
|
+
const dot = paint(theme, "dim", " · ");
|
|
169
301
|
const lines = [
|
|
170
|
-
...
|
|
171
|
-
paint(theme, "
|
|
172
|
-
|
|
302
|
+
...wrapHanging(`${checkMark({ check: "open", status: "pending" }, theme)} `, bold(theme, plan.title), width),
|
|
303
|
+
...wrapHanging(" ", [bold(theme, paint(theme, "muted", "pending")), paint(theme, "muted", `saved ${since(now - Date.parse(plan.createdAt))}`)].join(dot), width),
|
|
304
|
+
...wrapHanging(" ", paint(theme, "dim", plan.id), width),
|
|
173
305
|
];
|
|
174
|
-
if (plan.issue) lines.push(...
|
|
175
|
-
|
|
306
|
+
if (plan.issue) lines.push(...wrapHanging(" ", `from issue #${plan.issue.number} — ${plan.issue.title}${plan.issue.url ? ` ${paint(theme, "dim", plan.issue.url)}` : ""}`, width));
|
|
307
|
+
const key = (text: string) => paint(theme, "accent", text);
|
|
308
|
+
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));
|
|
176
309
|
lines.push(...section("Agreed plan", width, theme), ...markdownLines(plan.brief, width, theme));
|
|
177
310
|
return lines;
|
|
178
311
|
}
|
|
@@ -180,30 +313,64 @@ export function planDetailLines(plan: PlannedTask, width: number, now: number, t
|
|
|
180
313
|
export interface TasksInput {
|
|
181
314
|
rows: readonly TaskRow[];
|
|
182
315
|
selected: number;
|
|
183
|
-
/** Detail lines for the selected row. */
|
|
316
|
+
/** Detail lines for the selected row, already fitted to the detail pane. */
|
|
184
317
|
detail: readonly string[];
|
|
185
318
|
focus: "list" | "detail";
|
|
186
319
|
detailOffset: number;
|
|
187
320
|
notice?: string;
|
|
321
|
+
/** The search in force; the rows are already filtered by it. */
|
|
322
|
+
query?: string;
|
|
323
|
+
/** Filled with where the list and the detail landed, for scrolling. */
|
|
324
|
+
panes?: PaneLayout;
|
|
188
325
|
}
|
|
189
326
|
|
|
190
327
|
/** Narrow terminals show the list or the detail, not both. */
|
|
191
328
|
export const TASKS_COLUMNS_MIN = 90;
|
|
192
329
|
|
|
330
|
+
/** Outer widths of the list and detail panes (the detail's text is 4 columns narrower). */
|
|
331
|
+
export function tasksWidths(width: number): { list: number; detail: number; wide: boolean } {
|
|
332
|
+
if (width < TASKS_COLUMNS_MIN) return { list: width, detail: width, wide: false };
|
|
333
|
+
const list = Math.max(36, Math.round((width - 1) * 0.36));
|
|
334
|
+
return { list, detail: width - 1 - list, wide: true };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Everything a search looks at for one row: id, title, request, proposal, plan. */
|
|
338
|
+
function rowText(row: TaskRow, tasks: readonly Task[], plans: readonly PlannedTask[]): string {
|
|
339
|
+
if (row.kind === "plan") {
|
|
340
|
+
const plan = plans.find((entry) => entry.id === row.id);
|
|
341
|
+
return [row.id, row.title, plan?.brief ?? "", plan?.issue?.title ?? ""].join("\n");
|
|
342
|
+
}
|
|
343
|
+
const task = tasks.find((entry) => entry.id === row.id);
|
|
344
|
+
return [row.id, row.title, row.status, task ? taskRequest(task) : "", task?.proposal ?? "", task?.plan ?? ""].join("\n");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The rows whose task or plan mentions `query` anywhere. */
|
|
348
|
+
export function filterRows(rows: readonly TaskRow[], tasks: readonly Task[], plans: readonly PlannedTask[], query: string | undefined): TaskRow[] {
|
|
349
|
+
const needle = query?.trim().toLowerCase();
|
|
350
|
+
if (!needle) return [...rows];
|
|
351
|
+
return rows.filter((row) => rowText(row, tasks, plans).toLowerCase().includes(needle));
|
|
352
|
+
}
|
|
353
|
+
|
|
193
354
|
export function renderTasks(input: TasksInput, width: number, height: number, theme?: LobbyTheme): string[] {
|
|
194
355
|
if (height <= 0) return [];
|
|
195
356
|
const notice = input.notice ? [paint(theme, "accent", input.notice)] : [];
|
|
196
357
|
const bodyHeight = height - notice.length;
|
|
197
358
|
if (input.rows.length === 0) {
|
|
198
|
-
|
|
359
|
+
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.";
|
|
360
|
+
return fill([...notice, ...box(width, bodyHeight, [paint(theme, "dim", empty)], { title: "Tasks", theme })], height, width);
|
|
199
361
|
}
|
|
200
|
-
const wide = width
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
const listPane =
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
362
|
+
const { list: listWidth, detail: detailWidth, wide } = tasksWidths(width);
|
|
363
|
+
const list = listLines(input.rows, input.selected, listWidth - 4, input.focus === "list", theme);
|
|
364
|
+
const rows = Math.max(0, bodyHeight - 2);
|
|
365
|
+
const listStart = windowStart(list.selectedLine, list.lines.length, rows);
|
|
366
|
+
const count = listCount(input.rows, input.query);
|
|
367
|
+
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 });
|
|
368
|
+
const detailStart = detailWindow(input.detail.length, rows, input.detailOffset);
|
|
369
|
+
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 });
|
|
370
|
+
const showList = wide || input.focus !== "detail";
|
|
371
|
+
const showDetail = wide || input.focus === "detail";
|
|
372
|
+
if (showList) notePane(input.panes, "list", notice.length, 0, listWidth, bodyHeight, list.lines.length);
|
|
373
|
+
if (showDetail) notePane(input.panes, "detail", notice.length, wide ? listWidth + 1 : 0, detailWidth, bodyHeight, input.detail.length);
|
|
374
|
+
if (!wide) return fill([...notice, ...(showDetail ? detailPane : listPane)], height, width);
|
|
375
|
+
return fill([...notice, ...beside([listPane, detailPane])], height, width);
|
|
209
376
|
}
|