@a-t-h-i/bot-lobby 0.6.7 → 0.6.8
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 +132 -15
- package/package.json +1 -1
- package/prompts/master.md +15 -0
- package/prompts/panel.md +4 -0
- package/prompts/planner.md +5 -0
- package/prompts/pr-review.md +62 -0
- package/prompts/splitter.md +58 -0
- package/src/ask/dialog.ts +19 -5
- package/src/ask/state.ts +8 -2
- package/src/ask/tool.ts +3 -1
- package/src/ask/view.ts +6 -3
- package/src/classifier/instance.ts +6 -0
- package/src/classifier/knowledge.ts +158 -0
- package/src/classifier/review.ts +146 -0
- package/src/execution/workspace.ts +185 -0
- package/src/knowledge/edit.ts +109 -0
- package/src/knowledge/notes.ts +143 -0
- package/src/knowledge/selector.ts +1 -1
- package/src/knowledge/store.ts +16 -2
- package/src/lobby/ask.ts +42 -21
- package/src/lobby/issues.ts +1 -1
- package/src/lobby/knowledge.ts +179 -0
- package/src/lobby/layout.ts +95 -18
- package/src/lobby/planner.ts +199 -7
- package/src/lobby/pr-review.ts +360 -0
- package/src/lobby/pulls.ts +250 -0
- package/src/lobby/runtime.ts +115 -13
- package/src/lobby/split.ts +230 -0
- package/src/lobby/tabs/git.ts +162 -0
- package/src/lobby/tabs/knowledge.ts +135 -0
- package/src/lobby/tabs/plan.ts +3 -1
- package/src/lobby/tabs/tasks.ts +11 -2
- package/src/lobby/view.ts +432 -32
- package/src/master/master.ts +39 -13
- package/src/pi/commands.ts +12 -7
- package/src/pi/model-support.ts +9 -0
- package/src/pi/plan-checklist.ts +42 -0
- package/src/pi/route.ts +2 -1
- package/src/pi/settings-ui.ts +41 -1
- package/src/pi/start-flags.ts +14 -0
- package/src/pi/start-task.ts +56 -5
- package/src/pi/tools.ts +4 -2
- package/src/schemas/configuration.ts +32 -3
- package/src/schemas/task.ts +15 -0
- package/src/state/backlog.ts +27 -4
- package/src/state/metrics.ts +2 -2
- package/src/state/persistence.ts +5 -5
- package/src/text.ts +38 -0
- package/src/workflow/workflow.ts +11 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splitting a long plan into tasks. When the user saves a plan with many
|
|
3
|
+
* steps, the oracle proposes how to cut it into two to five tasks, the user
|
|
4
|
+
* decides (keep it whole, take the split, or say what to change), and each part
|
|
5
|
+
* is saved as a pending task that knows its siblings.
|
|
6
|
+
*
|
|
7
|
+
* The oracle decides where the lines go; the engine enforces the rest: at
|
|
8
|
+
* most `MAX_SPLIT_TASKS` tasks, every step of the plan in exactly one of them,
|
|
9
|
+
* and a task only ever building on earlier ones. A proposal that breaks a rule
|
|
10
|
+
* is sent back once with the problems, and never reaches the user. Each part's
|
|
11
|
+
* brief is composed here from the plan as written (its objective, decisions,
|
|
12
|
+
* assumptions and risks stay whole, its steps section holds only that part's
|
|
13
|
+
* steps), so nothing the user agreed can be lost in a model's retelling.
|
|
14
|
+
*/
|
|
15
|
+
import { loadPrompt } from "../prompts/loader.ts";
|
|
16
|
+
import { planStepBlocks } from "../pi/plan-checklist.ts";
|
|
17
|
+
import type { SplitInfo } from "../state/backlog.ts";
|
|
18
|
+
import type { AskQuestion } from "../ask/types.ts";
|
|
19
|
+
|
|
20
|
+
/** The most tasks a plan is split into. */
|
|
21
|
+
export const MAX_SPLIT_TASKS = 5;
|
|
22
|
+
/** Times the user may ask the oracle to change a split before it is final. */
|
|
23
|
+
export const MAX_SPLIT_REVISIONS = 3;
|
|
24
|
+
|
|
25
|
+
export interface SplitTask {
|
|
26
|
+
title: string;
|
|
27
|
+
goal: string;
|
|
28
|
+
/** The plan's steps (numbered from 1) this task does. */
|
|
29
|
+
covers: number[];
|
|
30
|
+
/** Earlier tasks (numbered from 1) it builds on. */
|
|
31
|
+
after: number[];
|
|
32
|
+
done: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SplitProposal {
|
|
36
|
+
tasks: SplitTask[];
|
|
37
|
+
/** What the oracle tells the user about the split. */
|
|
38
|
+
note?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function splitPrompt(instructions?: string): string {
|
|
42
|
+
const custom = instructions?.trim();
|
|
43
|
+
return custom ? `${loadPrompt("splitter.md")}\n\n## Custom Instructions\n\n${custom}` : loadPrompt("splitter.md");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `3, 5-6` as [3, 5, 6]; `none` (or nothing) as []. Ranges are bounded, so a stray `1-999999` cannot fill memory. */
|
|
47
|
+
export function parseNumbers(text: string): number[] {
|
|
48
|
+
const found = new Set<number>();
|
|
49
|
+
for (const match of text.matchAll(/(\d+)(?:\s*[-–—]\s*(\d+))?/g)) {
|
|
50
|
+
const first = Number(match[1]);
|
|
51
|
+
const last = match[2] ? Number(match[2]) : first;
|
|
52
|
+
for (let number = first; number <= Math.min(last, first + 200); number += 1) found.add(number);
|
|
53
|
+
}
|
|
54
|
+
return [...found].sort((a, b) => a - b);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read the oracle's reply into a proposal; throws a one-line reason when it has no tasks at all. */
|
|
58
|
+
export function parseSplit(text: string): SplitProposal {
|
|
59
|
+
const [head = "", ...after] = text.split(/^##\s+Note\s*$/im);
|
|
60
|
+
const note = after.join("\n").trim().replace(/\s+/g, " ") || undefined;
|
|
61
|
+
const parts = head.split(/^###\s+\d+[.):]?\s*/m).slice(1);
|
|
62
|
+
if (parts.length === 0) throw new Error("no tasks found: write each as `### 1. Title` with Goal, Covers, After and Done when");
|
|
63
|
+
const tasks = parts.map((part): SplitTask => {
|
|
64
|
+
const lines = part.split("\n");
|
|
65
|
+
const title = (lines[0] ?? "").replace(/[*_`#]/g, "").trim();
|
|
66
|
+
const value = (key: string) => new RegExp(`^\\s*[-*]?\\s*\\**${key}\\**\\s*:\\**\\s*(.*)$`, "im").exec(part)?.[1]?.trim() ?? "";
|
|
67
|
+
const doneAt = lines.findIndex((line) => /^\s*\**done when\**\s*:/i.test(line));
|
|
68
|
+
const inline = doneAt < 0 ? undefined : /:\**\s*(\S.*)$/.exec(lines[doneAt]!)?.[1];
|
|
69
|
+
const bullets = doneAt < 0 ? [] : lines.slice(doneAt + 1).filter((line) => /^\s*[-*]\s+\S/.test(line)).map((line) => line.replace(/^\s*[-*]\s+/, "").trim());
|
|
70
|
+
const done = [...(inline ? [inline] : []), ...bullets];
|
|
71
|
+
return { title, goal: value("goal"), covers: parseNumbers(value("covers")), after: parseNumbers(value("after")), done };
|
|
72
|
+
});
|
|
73
|
+
return { tasks, ...(note ? { note } : {}) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Every rule a proposal breaks, in words the oracle can act on; empty when it is sound. */
|
|
77
|
+
export function splitProblems(proposal: SplitProposal, stepCount: number, max = MAX_SPLIT_TASKS): string[] {
|
|
78
|
+
const problems: string[] = [];
|
|
79
|
+
const { tasks } = proposal;
|
|
80
|
+
if (tasks.length < 2) problems.push("a split needs at least 2 tasks");
|
|
81
|
+
if (tasks.length > max) problems.push(`a plan is split into at most ${max} tasks, not ${tasks.length}`);
|
|
82
|
+
const owner = new Map<number, number>();
|
|
83
|
+
tasks.forEach((task, index) => {
|
|
84
|
+
const number = index + 1;
|
|
85
|
+
if (!task.title) problems.push(`task ${number} has no title`);
|
|
86
|
+
if (task.covers.length === 0) problems.push(`task ${number} covers no steps`);
|
|
87
|
+
for (const step of task.covers) {
|
|
88
|
+
if (step < 1 || step > stepCount) problems.push(`task ${number} covers step ${step}, but the plan has steps 1 to ${stepCount}`);
|
|
89
|
+
else if (owner.has(step)) problems.push(`step ${step} is in both task ${owner.get(step)} and task ${number}`);
|
|
90
|
+
else owner.set(step, number);
|
|
91
|
+
}
|
|
92
|
+
for (const earlier of task.after) if (earlier >= number || earlier < 1) problems.push(`task ${number} cannot come after task ${earlier}: list the tasks in the order they can be done, each building only on earlier ones`);
|
|
93
|
+
});
|
|
94
|
+
const missing = Array.from({ length: stepCount }, (_, index) => index + 1).filter((step) => !owner.has(step));
|
|
95
|
+
if (missing.length > 0) problems.push(`step${missing.length === 1 ? "" : "s"} ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} in no task: every step belongs to exactly one`);
|
|
96
|
+
return [...new Set(problems)];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `1-3, 5` for step numbers. */
|
|
100
|
+
export function rangeWords(numbers: readonly number[]): string {
|
|
101
|
+
const parts: string[] = [];
|
|
102
|
+
for (let index = 0; index < numbers.length; ) {
|
|
103
|
+
let end = index;
|
|
104
|
+
while (end + 1 < numbers.length && numbers[end + 1] === numbers[end]! + 1) end += 1;
|
|
105
|
+
parts.push(end > index ? `${numbers[index]}-${numbers[end]}` : String(numbers[index]));
|
|
106
|
+
index = end + 1;
|
|
107
|
+
}
|
|
108
|
+
return parts.join(", ");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What the oracle is asked: the plan, its steps as numbered here, and, on a revision, the split so far with what the user wants changed. */
|
|
112
|
+
export function splitRequest(input: { title: string; plan: string; steps: readonly string[]; max?: number; revision?: { previous: SplitProposal; words: string }; rejected?: readonly string[] }): string {
|
|
113
|
+
const max = input.max ?? MAX_SPLIT_TASKS;
|
|
114
|
+
const lines = [
|
|
115
|
+
`Split this plan into 2 to ${max} tasks: ${input.title}`,
|
|
116
|
+
"",
|
|
117
|
+
`The plan has ${input.steps.length} steps. They are numbered here in the order of its Steps section; use these numbers in Covers.`,
|
|
118
|
+
...input.steps.map((step, index) => `${index + 1}. ${step.replace(/\s+/g, " ").trim()}`),
|
|
119
|
+
"",
|
|
120
|
+
"## The plan",
|
|
121
|
+
"",
|
|
122
|
+
input.plan.trim(),
|
|
123
|
+
];
|
|
124
|
+
if (input.revision) {
|
|
125
|
+
lines.push("", "## The split so far", "", proposalText(input.revision.previous), "", `## What the user wants changed`, "", input.revision.words.trim(), "", "Apply exactly that and keep the rest of the split as it is.");
|
|
126
|
+
}
|
|
127
|
+
if (input.rejected && input.rejected.length > 0) {
|
|
128
|
+
lines.push("", "## Your last split was rejected", "", ...input.rejected.map((problem) => `- ${problem}`), "", "Answer again in the required format with a split that breaks none of these.");
|
|
129
|
+
}
|
|
130
|
+
return lines.join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A proposal as the oracle's own format, so a revision starts from what it (and the user) saw. */
|
|
134
|
+
export function proposalText(proposal: SplitProposal): string {
|
|
135
|
+
return [
|
|
136
|
+
"## Tasks",
|
|
137
|
+
...proposal.tasks.flatMap((task, index) => [
|
|
138
|
+
`### ${index + 1}. ${task.title}`,
|
|
139
|
+
`Goal: ${task.goal}`,
|
|
140
|
+
`Covers: ${rangeWords(task.covers)}`,
|
|
141
|
+
`After: ${task.after.length > 0 ? rangeWords(task.after) : "none"}`,
|
|
142
|
+
...(task.done.length > 0 ? ["Done when:", ...task.done.map((point) => `- ${point}`)] : []),
|
|
143
|
+
"",
|
|
144
|
+
]),
|
|
145
|
+
...(proposal.note ? ["## Note", proposal.note] : []),
|
|
146
|
+
].join("\n").trimEnd();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Markdown for the questionnaire's preview: each task with the steps it takes. */
|
|
150
|
+
export function splitPreview(proposal: SplitProposal, steps: readonly string[]): string {
|
|
151
|
+
const clip = (text: string) => {
|
|
152
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
153
|
+
return flat.length > 110 ? `${flat.slice(0, 109)}…` : flat;
|
|
154
|
+
};
|
|
155
|
+
return proposal.tasks.map((task, index) => [
|
|
156
|
+
`**${index + 1}. ${task.title}** · step${task.covers.length === 1 ? "" : "s"} ${rangeWords(task.covers)}${task.after.length > 0 ? ` · after ${rangeWords(task.after)}` : ""}`,
|
|
157
|
+
...(task.goal ? [`_${clip(task.goal)}_`] : []),
|
|
158
|
+
...task.covers.map((step) => `- ${step}. ${clip(steps[step - 1] ?? "")}`),
|
|
159
|
+
].join("\n")).join("\n\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The question the user answers: take the split, keep the plan whole, or write what to change. */
|
|
163
|
+
export function splitQuestion(input: { stepCount: number; proposal: SplitProposal; steps: readonly string[]; revisions: number }): AskQuestion {
|
|
164
|
+
const count = input.proposal.tasks.length;
|
|
165
|
+
const last = input.revisions >= MAX_SPLIT_REVISIONS;
|
|
166
|
+
return {
|
|
167
|
+
header: "Split plan",
|
|
168
|
+
question: [
|
|
169
|
+
`This plan has **${input.stepCount} steps**. The oracle suggests splitting it into **${count} tasks**, each finished and reviewed on its own.`,
|
|
170
|
+
...(input.proposal.note ? [input.proposal.note] : []),
|
|
171
|
+
last ? "This is the last revision: take it, or keep the plan whole." : "Type what to change (for example *merge 2 and 3*) to revise it.",
|
|
172
|
+
].join("\n\n"),
|
|
173
|
+
options: [
|
|
174
|
+
{ label: `${splitLabel(count)} (Recommended)`, description: "Each part is saved as its own pending task, in order, knowing the others.", preview: splitPreview(input.proposal, input.steps) },
|
|
175
|
+
{ label: "Keep it as one task", description: `One task with all ${input.stepCount} steps.` },
|
|
176
|
+
],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const KEEP_WHOLE = "Keep it as one task";
|
|
181
|
+
|
|
182
|
+
export function splitLabel(count: number): string {
|
|
183
|
+
return `Split into ${count} tasks`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Ask whether to save a plan as one task after the oracle could not split it. */
|
|
187
|
+
export function splitFailedQuestion(reason: string): AskQuestion {
|
|
188
|
+
return {
|
|
189
|
+
header: "Split plan",
|
|
190
|
+
question: `The oracle could not split this plan (${reason.replace(/\s+/g, " ").trim()}). Save it as one task?`,
|
|
191
|
+
options: [
|
|
192
|
+
{ label: "Save it as one task", description: "All the steps in one pending task." },
|
|
193
|
+
{ label: "Not now", description: "Save nothing; ctrl+s asks again." },
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The original plan with its Steps section holding only `covers` (renumbered); undefined when the plan's steps are not a list. */
|
|
199
|
+
function planWithSteps(plan: string, covers: readonly number[], stepCount: number): string | undefined {
|
|
200
|
+
const found = planStepBlocks(plan);
|
|
201
|
+
if (!found || found.blocks.length !== stepCount) return undefined;
|
|
202
|
+
const lines = plan.split("\n");
|
|
203
|
+
const kept = covers.map((step, index) => found.blocks[step - 1]!.replace(/^(\s*)\d+([.)])/, (_all, indent: string, mark: string) => `${indent}${index + 1}${mark}`));
|
|
204
|
+
const spacer = found.to > found.from && !(lines[found.to - 1] ?? "").trim() ? [""] : [];
|
|
205
|
+
return [...lines.slice(0, found.from), ...kept.flatMap((block) => block.split("\n")), ...spacer, ...lines.slice(found.to)].join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** One part's brief: where it sits, what it delivers, then the plan as written with only its own steps. */
|
|
209
|
+
export function partBrief(input: { plan: string; steps: readonly string[]; proposal: SplitProposal; index: number }): string {
|
|
210
|
+
const { proposal, index } = input;
|
|
211
|
+
const task = proposal.tasks[index]!;
|
|
212
|
+
const of = proposal.tasks.length;
|
|
213
|
+
const head = [
|
|
214
|
+
`### Part ${index + 1} of ${of} — ${task.title}`,
|
|
215
|
+
...(task.goal ? [`**Goal of this part:** ${task.goal}`] : []),
|
|
216
|
+
...(task.after.length > 0 ? [`**Builds on:** ${task.after.map((part) => `part ${part} — ${proposal.tasks[part - 1]?.title ?? ""}`).join("; ")} (finish that first).`] : []),
|
|
217
|
+
...(task.done.length > 0 ? ["**Done when:**", ...task.done.map((point) => `- ${point}`)] : []),
|
|
218
|
+
].join("\n");
|
|
219
|
+
const own = planWithSteps(input.plan, task.covers, input.steps.length);
|
|
220
|
+
if (own) return `${head}\n\n${own.trim()}`;
|
|
221
|
+
// Steps that are not a list: the whole plan, with this part's steps named on top.
|
|
222
|
+
const named = task.covers.map((step, position) => `${position + 1}. ${(input.steps[step - 1] ?? "").replace(/\s+/g, " ").trim()}`);
|
|
223
|
+
return `${head}\n\n**Steps of this part (do only these; the plan below is the whole plan, for context):**\n${named.join("\n")}\n\n${input.plan.trim()}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** What each saved part knows of the others. */
|
|
227
|
+
export function partInfo(proposal: SplitProposal, index: number, group: string): SplitInfo {
|
|
228
|
+
const task = proposal.tasks[index]!;
|
|
229
|
+
return { group, part: index + 1, of: proposal.tasks.length, titles: proposal.tasks.map((entry) => entry.title), after: [...task.after] };
|
|
230
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Git tab: the repository's open pull requests through `gh`, the selected
|
|
3
|
+
* one with its checks, files, description and discussion, and — the point of
|
|
4
|
+
* the tab — a review of it by a read-only agent (v, or f with a focus), or a
|
|
5
|
+
* quick read by Jev (t), shown right under its facts.
|
|
6
|
+
*/
|
|
7
|
+
import { textWidth } from "../../width.ts";
|
|
8
|
+
import type { PullDetail, PullSummary } from "../pulls.ts";
|
|
9
|
+
import { isStale, type PullReadState, type PullReview, type Verdict } from "../pr-review.ts";
|
|
10
|
+
import { BRANCH_GLYPH, bold, columns, fill, markdownLines, paint, rule, selectRow, since, spinner, split, spread, windowStart, wrap, wrapHanging, type LobbyTheme } from "../layout.ts";
|
|
11
|
+
|
|
12
|
+
export interface GitTabInput {
|
|
13
|
+
pulls: readonly PullSummary[];
|
|
14
|
+
selected: number;
|
|
15
|
+
detail?: PullDetail;
|
|
16
|
+
/** The agent's review of the selected pull request, and Jev's read of it. */
|
|
17
|
+
review?: PullReview;
|
|
18
|
+
read?: PullReadState;
|
|
19
|
+
focus: "list" | "detail";
|
|
20
|
+
detailOffset: number;
|
|
21
|
+
loading: boolean;
|
|
22
|
+
loaded: boolean;
|
|
23
|
+
error?: string;
|
|
24
|
+
tick: number;
|
|
25
|
+
now: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const GIT_COLUMNS_MIN = 90;
|
|
29
|
+
|
|
30
|
+
const VERDICTS: Record<Verdict, { words: string; color: "success" | "error" | "warning" }> = {
|
|
31
|
+
approve: { words: "approve", color: "success" },
|
|
32
|
+
changes: { words: "request changes", color: "error" },
|
|
33
|
+
comment: { words: "comment", color: "warning" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** `✓` passing, `✗` failing, `●` still running, blank without checks. */
|
|
37
|
+
function checkMark(pull: PullSummary, theme?: LobbyTheme): string {
|
|
38
|
+
if (pull.checks === "passing") return paint(theme, "success", "✓");
|
|
39
|
+
if (pull.checks === "failing") return paint(theme, "error", "✗");
|
|
40
|
+
if (pull.checks === "pending") return paint(theme, "warning", "●");
|
|
41
|
+
return " ";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The size of a change, `+12 −3`, added lines green and removed ones red. */
|
|
45
|
+
export function changeSize(additions: number, deletions: number, theme?: LobbyTheme): string {
|
|
46
|
+
return `${paint(theme, "success", `+${additions}`)} ${paint(theme, "error", `−${deletions}`)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function decisionWords(decision: string | undefined): { words: string; color: "success" | "error" | "warning" } | undefined {
|
|
50
|
+
if (decision === "APPROVED") return { words: "approved", color: "success" };
|
|
51
|
+
if (decision === "CHANGES_REQUESTED") return { words: "changes requested", color: "error" };
|
|
52
|
+
if (decision === "REVIEW_REQUIRED") return { words: "review required", color: "warning" };
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** How long a review took, `2m 05s`. */
|
|
57
|
+
function took(review: PullReview, now: number): string {
|
|
58
|
+
const ms = Math.max(0, (review.finishedAt ?? now) - review.startedAt);
|
|
59
|
+
const seconds = Math.round(ms / 1000);
|
|
60
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The review of the selected pull request: running steps, or the finished review, or why it failed. */
|
|
64
|
+
export function reviewLines(review: PullReview, detail: PullDetail | undefined, width: number, tick: number, now: number, theme?: LobbyTheme): string[] {
|
|
65
|
+
const lines: string[] = [];
|
|
66
|
+
const verdict = review.verdict ? VERDICTS[review.verdict] : undefined;
|
|
67
|
+
// Short enough to fit beside the title in a narrow pane; the model goes on a line of its own.
|
|
68
|
+
const right = review.status === "running"
|
|
69
|
+
? `${spinner(tick)} ${took(review, now)}`
|
|
70
|
+
: [verdict ? paint(theme, verdict.color, verdict.words) : "", review.status === "done" ? took(review, now) : review.status].filter(Boolean).join(paint(theme, "dim", " · "));
|
|
71
|
+
lines.push(rule(width, "Review", theme, right));
|
|
72
|
+
if (review.model) lines.push(paint(theme, "dim", [review.model, review.thinking].filter(Boolean).join(" · ")));
|
|
73
|
+
if (review.focus) lines.push(...wrapHanging(paint(theme, "dim", "focus: "), review.focus, width));
|
|
74
|
+
if (review.saved) lines.push(paint(theme, "dim", `kept from ${since(now - review.startedAt)}`));
|
|
75
|
+
if (isStale(review, detail)) lines.push(paint(theme, "warning", "the pull request has new commits since this review — v reviews it again"));
|
|
76
|
+
if (review.status === "running") {
|
|
77
|
+
for (const step of review.steps.slice(-6)) lines.push(...wrapHanging(paint(theme, "dim", "· "), paint(theme, "muted", step), width));
|
|
78
|
+
lines.push(paint(theme, "dim", "x stops it"));
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
if (review.status === "cancelled") lines.push(paint(theme, "warning", "stopped"));
|
|
82
|
+
if (review.status === "failed" || review.status === "timeout") lines.push(...wrap(paint(theme, "error", `✗ ${review.error ?? review.status}`), width));
|
|
83
|
+
if (review.text) lines.push(...markdownLines(review.text, width, theme));
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Jev's read: one line, or the reason there is none. */
|
|
88
|
+
export function readLines(read: PullReadState, width: number, tick: number, theme?: LobbyTheme): string[] {
|
|
89
|
+
if (read.status === "running") return [rule(width, "Jev's read", theme, spinner(tick)), paint(theme, "dim", "reading the diff…")];
|
|
90
|
+
if (read.status === "failed") return [rule(width, "Jev's read", theme), ...wrap(paint(theme, "warning", read.error ?? "Jev could not read it"), width)];
|
|
91
|
+
return [rule(width, "Jev's read", theme, read.read ? `${read.read.model} · ${read.read.ms} ms` : ""), ...wrap(read.line ?? "", width)];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function pullDetailLines(pull: PullDetail, review: PullReview | undefined, read: PullReadState | undefined, width: number, tick: number, now: number, theme?: LobbyTheme): string[] {
|
|
95
|
+
const dot = paint(theme, "dim", " · ");
|
|
96
|
+
const decision = decisionWords(pull.decision);
|
|
97
|
+
const facts = [
|
|
98
|
+
pull.state ? paint(theme, pull.state === "OPEN" ? "success" : "dim", pull.draft ? "draft" : pull.state.toLowerCase()) : pull.draft ? paint(theme, "dim", "draft") : "",
|
|
99
|
+
pull.author ? `by ${pull.author}` : "",
|
|
100
|
+
pull.updatedAt ? `updated ${since(now - Date.parse(pull.updatedAt))}` : "",
|
|
101
|
+
].filter(Boolean);
|
|
102
|
+
const checks = pull.checks ? `checks ${paint(theme, pull.checks === "passing" ? "success" : pull.checks === "failing" ? "error" : "warning", pull.checks)} (${pull.checkCount})` : "";
|
|
103
|
+
const merge = pull.mergeable ? (pull.mergeable === "CONFLICTING" ? paint(theme, "error", "conflicts") : pull.mergeable === "MERGEABLE" ? "mergeable" : "") : "";
|
|
104
|
+
const lines = [
|
|
105
|
+
...wrap(bold(theme, `#${pull.number} ${pull.title}`), width),
|
|
106
|
+
facts.join(dot),
|
|
107
|
+
`${paint(theme, "accent", `${BRANCH_GLYPH} ${pull.headRef || "?"}`)} → ${pull.baseRef || "?"}${dot}${changeSize(pull.additions, pull.deletions, theme)}${dot}${pull.changedFiles} file${pull.changedFiles === 1 ? "" : "s"}`,
|
|
108
|
+
[checks, decision ? paint(theme, decision.color, decision.words) : "", merge, pull.labels.length > 0 ? paint(theme, "warning", pull.labels.join(", ")) : ""].filter(Boolean).join(dot),
|
|
109
|
+
].filter((line) => line.length > 0);
|
|
110
|
+
if (pull.url) lines.push(paint(theme, "dim", pull.url));
|
|
111
|
+
lines.push("", ...wrap(paint(theme, "dim", "v reviews it with an agent · f with a focus you type · t is Jev's quick read"), width));
|
|
112
|
+
if (read) lines.push("", ...readLines(read, width, tick, theme));
|
|
113
|
+
if (review) lines.push("", ...reviewLines(review, pull, width, tick, now, theme));
|
|
114
|
+
if (pull.files.length > 0) {
|
|
115
|
+
lines.push("", rule(width, "Files", theme, String(pull.files.length)));
|
|
116
|
+
const numbers = pull.files.map((file) => textWidth(changeSize(file.additions, file.deletions)));
|
|
117
|
+
const room = Math.max(...numbers);
|
|
118
|
+
for (const file of pull.files) lines.push(spread(file.path, `${" ".repeat(room - textWidth(changeSize(file.additions, file.deletions)))}${changeSize(file.additions, file.deletions, theme)}`, width));
|
|
119
|
+
}
|
|
120
|
+
lines.push("", rule(width, "Description", theme), ...(pull.body.trim() ? markdownLines(pull.body.trim(), width, theme) : [paint(theme, "dim", "(no description)")]));
|
|
121
|
+
for (const note of pull.notes) {
|
|
122
|
+
const verdict = note.state && note.state !== "COMMENTED" ? paint(theme, note.state === "APPROVED" ? "success" : note.state === "CHANGES_REQUESTED" ? "error" : "warning", note.state.toLowerCase().replace(/_/g, " ")) : "";
|
|
123
|
+
lines.push("", rule(width, note.author ?? "comment", theme, [verdict, note.at ? since(now - Date.parse(note.at)) : ""].filter(Boolean).join(" · ")), ...(note.body.trim() ? markdownLines(note.body.trim(), width, theme) : []));
|
|
124
|
+
}
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function empty(input: GitTabInput, width: number, theme?: LobbyTheme): string[] {
|
|
129
|
+
if (input.loading) return [`${paint(theme, "accent", spinner(input.tick))} loading pull requests from GitHub…`];
|
|
130
|
+
if (input.error) return [];
|
|
131
|
+
if (!input.loaded) return wrap(paint(theme, "dim", "Press r to load open pull requests with the GitHub CLI (gh)."), width);
|
|
132
|
+
return wrap(paint(theme, "dim", "No open pull requests."), width);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One row of the list: number, checks, title, and the review's outcome when there is one. */
|
|
136
|
+
function row(pull: PullSummary, width: number, selected: boolean, focused: boolean, review: PullReview | undefined, theme?: LobbyTheme): string {
|
|
137
|
+
const verdict = review?.status === "done" && review.verdict ? paint(theme, VERDICTS[review.verdict].color, review.verdict === "approve" ? "✓ reviewed" : review.verdict === "changes" ? "✗ reviewed" : "◆ reviewed") : review?.status === "running" ? paint(theme, "accent", "reviewing") : "";
|
|
138
|
+
const title = selected ? bold(theme, pull.title) : pull.title;
|
|
139
|
+
const draft = pull.draft ? `${paint(theme, "dim", "draft")} ` : "";
|
|
140
|
+
const head = `${paint(theme, "dim", `#${pull.number}`)} ${checkMark(pull, theme)} ${draft}${title}`;
|
|
141
|
+
return selectRow(theme, spread(head, [verdict, changeSize(pull.additions, pull.deletions, theme)].filter(Boolean).join(" "), width), width, selected, focused);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function renderGit(input: GitTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
|
|
145
|
+
if (height <= 0) return [];
|
|
146
|
+
const banner = input.error ? wrap(paint(theme, "error", `✗ ${input.error}`), width) : [];
|
|
147
|
+
const bodyHeight = Math.max(0, height - banner.length);
|
|
148
|
+
if (input.pulls.length === 0) return fill([...banner, rule(width, "Pull requests", theme), ...empty(input, width, theme)], height, width);
|
|
149
|
+
const selected = Math.min(Math.max(0, input.selected), input.pulls.length - 1);
|
|
150
|
+
const wide = width >= GIT_COLUMNS_MIN;
|
|
151
|
+
const [listWidth, detailWidth] = wide ? split(width, 0.4, 3, 40) : [width, width];
|
|
152
|
+
const rows = input.pulls.map((pull, index) => row(pull, listWidth, index === selected, input.focus === "list", pull.number === input.review?.number ? input.review : undefined, theme));
|
|
153
|
+
const right = input.loading ? `${spinner(input.tick)} refreshing` : `${input.pulls.length} open`;
|
|
154
|
+
const listPane = fill([rule(listWidth, "Pull requests", theme, right), ...rows.slice(windowStart(selected, rows.length, bodyHeight - 1))], bodyHeight);
|
|
155
|
+
const current = input.pulls[selected]!;
|
|
156
|
+
const detail = input.detail && input.detail.number === current.number
|
|
157
|
+
? pullDetailLines(input.detail, input.review, input.read, detailWidth, input.tick, input.now, theme)
|
|
158
|
+
: [`${paint(theme, "accent", spinner(input.tick))} loading #${current.number}…`];
|
|
159
|
+
const detailPane = fill([rule(detailWidth, input.focus === "detail" ? "Pull request ◂" : "Pull request", theme), ...detail.slice(Math.max(0, Math.min(input.detailOffset, detail.length - 1)))], bodyHeight);
|
|
160
|
+
if (!wide) return fill([...banner, ...(input.focus === "detail" ? detailPane : listPane)], height, width);
|
|
161
|
+
return fill([...banner, ...columns(listPane, detailPane, listWidth, detailWidth, " │ ", theme)], height, width);
|
|
162
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Knowledge tab: everything each agent knows about the project — its
|
|
3
|
+
* knowledge, standards, decisions and completed tasks — with the file on the
|
|
4
|
+
* left and its entries on the right, one of them picked. The picked entry can
|
|
5
|
+
* be edited, deleted or commented on; the notes sit right under the entries
|
|
6
|
+
* they are about, as every agent reads them.
|
|
7
|
+
*/
|
|
8
|
+
import type { KnowledgeNote } from "../../knowledge/notes.ts";
|
|
9
|
+
import { AGENT_DIR_NAMES, type KnowledgeAgent } from "../../knowledge/paths.ts";
|
|
10
|
+
import { AGENT_LABELS, type KnowledgeFileInfo, type KnowledgeView } from "../knowledge.ts";
|
|
11
|
+
import { bold, columns, fill, markdownLines, paint, rule, selectRow, spread, split, windowStart, wrap, wrapHanging, type LobbyTheme } from "../layout.ts";
|
|
12
|
+
|
|
13
|
+
export interface KnowledgeLayout {
|
|
14
|
+
/** The first line of the entries shown, as the last frame drew them. */
|
|
15
|
+
offset: number;
|
|
16
|
+
/** How many lines the entries take, and how many the pane shows. */
|
|
17
|
+
total: number;
|
|
18
|
+
rows: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface KnowledgeTabInput {
|
|
22
|
+
files: readonly KnowledgeFileInfo[];
|
|
23
|
+
selected: number;
|
|
24
|
+
/** The selected file, read. */
|
|
25
|
+
view?: KnowledgeView;
|
|
26
|
+
/** The picked entry, an index into the file's entries. */
|
|
27
|
+
cursor: number;
|
|
28
|
+
focus: "list" | "detail";
|
|
29
|
+
/** The first line to show, unless the picked entry needs another. */
|
|
30
|
+
offset: number;
|
|
31
|
+
layout: KnowledgeLayout;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const KNOWLEDGE_COLUMNS_MIN = 90;
|
|
35
|
+
|
|
36
|
+
/** `812`, `1.2k`, `24k`. */
|
|
37
|
+
export function sizeWords(chars: number): string {
|
|
38
|
+
if (chars < 1000) return String(chars);
|
|
39
|
+
const thousands = chars / 1000;
|
|
40
|
+
return `${thousands >= 10 ? Math.round(thousands) : thousands.toFixed(1)}k`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fileRow(info: KnowledgeFileInfo, width: number, selected: boolean, focused: boolean, theme?: LobbyTheme): string {
|
|
44
|
+
const facts = [
|
|
45
|
+
info.over ? paint(theme, "warning", `${sizeWords(info.chars)} · over`) : paint(theme, "dim", sizeWords(info.chars)),
|
|
46
|
+
info.notes > 0 ? paint(theme, "accent", `✎ ${info.notes}`) : "",
|
|
47
|
+
].filter(Boolean).join(" ");
|
|
48
|
+
return selectRow(theme, spread(selected ? bold(theme, info.label) : info.label, facts, width - 2), width, selected, focused);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The files, grouped by agent, the selected one in view. */
|
|
52
|
+
export function listLines(files: readonly KnowledgeFileInfo[], selected: number, width: number, height: number, focused: boolean, theme?: LobbyTheme): string[] {
|
|
53
|
+
const lines: string[] = [];
|
|
54
|
+
let at = 0;
|
|
55
|
+
let previous: KnowledgeAgent | undefined;
|
|
56
|
+
files.forEach((info, index) => {
|
|
57
|
+
if (info.agent !== previous) {
|
|
58
|
+
previous = info.agent;
|
|
59
|
+
if (lines.length > 0) lines.push("");
|
|
60
|
+
const notes = files.filter((file) => file.agent === info.agent).reduce((total, file) => total + file.notes, 0);
|
|
61
|
+
lines.push(rule(width, AGENT_LABELS[info.agent], theme, notes > 0 ? `✎ ${notes}` : ""));
|
|
62
|
+
}
|
|
63
|
+
if (index === selected) at = lines.length;
|
|
64
|
+
lines.push(fileRow(info, width, index === selected, focused, theme));
|
|
65
|
+
});
|
|
66
|
+
return lines.slice(windowStart(at, lines.length, height));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One note under its entry. */
|
|
70
|
+
function noteLines(note: KnowledgeNote, width: number, theme?: LobbyTheme, about?: string): string[] {
|
|
71
|
+
const lead = paint(theme, "accent", " ✎ ");
|
|
72
|
+
const text = about ? `about “${about.replace(/\s+/g, " ").trim().slice(0, 50)}”: ${note.text}` : note.text;
|
|
73
|
+
return wrapHanging(lead, paint(theme, "muted", text), width);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The entries of the open file, each drawn once, with where the picked one sits. */
|
|
77
|
+
export function entryLines(view: KnowledgeView, cursor: number, width: number, focused: boolean, theme?: LobbyTheme): { lines: string[]; from: number; to: number } {
|
|
78
|
+
const lines: string[] = [];
|
|
79
|
+
let from = 0;
|
|
80
|
+
let to = 0;
|
|
81
|
+
let previousEnd = 0;
|
|
82
|
+
view.entries.forEach((entry, index) => {
|
|
83
|
+
if (index > 0 && entry.start > previousEnd) lines.push("");
|
|
84
|
+
previousEnd = entry.end;
|
|
85
|
+
const picked = index === cursor;
|
|
86
|
+
const body = markdownLines(entry.text, Math.max(8, width - 2), theme);
|
|
87
|
+
if (picked) from = lines.length;
|
|
88
|
+
body.forEach((line, row) => {
|
|
89
|
+
const mark = picked ? paint(theme, "accent", row === 0 ? "▸ " : " ") : " ";
|
|
90
|
+
lines.push(picked ? selectRow(theme, `${mark}${line}`, width, true, focused) : `${mark}${line}`);
|
|
91
|
+
});
|
|
92
|
+
for (const note of view.attached.filter((candidate) => candidate.entry === entry.text)) lines.push(...noteLines(note, width, theme));
|
|
93
|
+
if (picked) to = lines.length;
|
|
94
|
+
});
|
|
95
|
+
if (view.detached.length > 0) {
|
|
96
|
+
lines.push("", rule(width, "Notes on entries that changed", theme, String(view.detached.length)));
|
|
97
|
+
for (const note of view.detached) lines.push(...noteLines(note, width, theme, note.entry));
|
|
98
|
+
}
|
|
99
|
+
return { lines, from, to };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderKnowledge(input: KnowledgeTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
|
|
103
|
+
if (height <= 0) return [];
|
|
104
|
+
if (input.files.length === 0) return fill([rule(width, "Knowledge", theme), ...wrap(paint(theme, "dim", "No knowledge files yet."), width)], height, width);
|
|
105
|
+
const selected = Math.min(Math.max(0, input.selected), input.files.length - 1);
|
|
106
|
+
const wide = width >= KNOWLEDGE_COLUMNS_MIN;
|
|
107
|
+
const [listWidth, detailWidth] = wide ? split(width, 0.34, 3, 44) : [width, width];
|
|
108
|
+
const total = input.files.reduce((sum, info) => sum + info.notes, 0);
|
|
109
|
+
const listPane = fill([rule(listWidth, "Knowledge", theme, total > 0 ? `✎ ${total}` : ""), ...listLines(input.files, selected, listWidth, height - 1, input.focus === "list", theme)], height);
|
|
110
|
+
const info = input.files[selected]!;
|
|
111
|
+
const view = input.view && input.view.agent === info.agent && input.view.file === info.file ? input.view : undefined;
|
|
112
|
+
const title = `${AGENT_DIR_NAMES[info.agent]} · ${info.file}${input.focus === "detail" ? " ◂" : ""}`;
|
|
113
|
+
const head: string[] = [rule(detailWidth, title, theme, view ? `${view.entries.length} entr${view.entries.length === 1 ? "y" : "ies"} · ${sizeWords(view.chars)} chars` : "")];
|
|
114
|
+
if (view?.over) head.push(...wrap(paint(theme, "warning", `over the compaction threshold — ask the oracle to compact ${info.file}`), detailWidth));
|
|
115
|
+
const rows = Math.max(1, height - head.length);
|
|
116
|
+
let body: string[];
|
|
117
|
+
if (!view) body = [paint(theme, "dim", "reading…")];
|
|
118
|
+
else if (view.entries.length === 0 && view.detached.length === 0) body = wrap(paint(theme, "dim", "Nothing here yet. n adds the first entry."), detailWidth);
|
|
119
|
+
else {
|
|
120
|
+
const drawn = entryLines(view, Math.min(input.cursor, Math.max(0, view.entries.length - 1)), detailWidth, input.focus === "detail", theme);
|
|
121
|
+
let start = Math.max(0, Math.min(input.offset, Math.max(0, drawn.lines.length - rows)));
|
|
122
|
+
// The picked entry stays in view (an entry taller than the pane shows its first lines).
|
|
123
|
+
if (input.focus === "detail" && view.entries.length > 0) {
|
|
124
|
+
if (drawn.from < start) start = drawn.from;
|
|
125
|
+
else if (drawn.to > start + rows) start = Math.min(drawn.from, drawn.to - rows);
|
|
126
|
+
}
|
|
127
|
+
input.layout.offset = start;
|
|
128
|
+
input.layout.total = drawn.lines.length;
|
|
129
|
+
input.layout.rows = rows;
|
|
130
|
+
body = drawn.lines.slice(start);
|
|
131
|
+
}
|
|
132
|
+
const detailPane = fill([...head, ...body], height);
|
|
133
|
+
if (!wide) return fill(input.focus === "detail" ? detailPane : listPane, height, width);
|
|
134
|
+
return fill(columns(listPane, detailPane, listWidth, detailWidth, " │ ", theme), height, width);
|
|
135
|
+
}
|
package/src/lobby/tabs/plan.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface PlanView {
|
|
|
45
45
|
nextMode?: RoundMode;
|
|
46
46
|
seed?: PlannerSeed;
|
|
47
47
|
saved?: PlannedTask;
|
|
48
|
+
/** How many tasks the plan was saved as, when it was split. */
|
|
49
|
+
savedParts?: number;
|
|
48
50
|
title?: string;
|
|
49
51
|
/** The round's questions wait for answers. */
|
|
50
52
|
awaitingAnswers: boolean;
|
|
@@ -120,7 +122,7 @@ function statusLine(view: PlanView, input: PlanTabInput, theme?: LobbyTheme): st
|
|
|
120
122
|
else if (view.reply?.status === "ready") parts.push(paint(theme, "success", `✓ ready — ${input.saveKey ?? "Ctrl+S"} saves it`));
|
|
121
123
|
else if (view.awaitingAnswers) parts.push(paint(theme, "warning", `● ${count(view.questions.length, "question")} — enter ${view.answeredChunks > 0 ? "resumes" : "answers them"}`));
|
|
122
124
|
if (view.lineComments.length > 0) parts.push(paint(theme, "accent", `◆ ${count(view.lineComments.length, "comment")} to send`));
|
|
123
|
-
if (view.saved) parts.push(paint(theme, "success", `saved as ${view.saved.id}`));
|
|
125
|
+
if (view.saved) parts.push(paint(theme, "success", view.savedParts && view.savedParts > 1 ? `saved as ${view.savedParts} tasks, from ${view.saved.id}` : `saved as ${view.saved.id}`));
|
|
124
126
|
if (!view.busy && view.turns > 0) parts.push(paint(theme, "dim", roundLabel(view.turns, view.limit)));
|
|
125
127
|
if (!view.busy && view.nextMode === "final") parts.push(paint(theme, "warning", "the next round is the last: the oracle settles the rest"));
|
|
126
128
|
return parts.join(paint(theme, "dim", " · "));
|
package/src/lobby/tabs/tasks.ts
CHANGED
|
@@ -12,7 +12,7 @@ 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
14
|
import { textWidth } from "../../width.ts";
|
|
15
|
-
import { ago, beside, 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";
|
|
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
16
|
|
|
17
17
|
export type TaskSection = "mine" | "others" | "pending" | "recent" | "archived";
|
|
18
18
|
|
|
@@ -114,7 +114,7 @@ export function taskRows(tasks: readonly Task[], plans: readonly PlannedTask[],
|
|
|
114
114
|
...plans.filter((plan) => plan.status === "pending").map((plan): TaskRow => ({
|
|
115
115
|
kind: "plan",
|
|
116
116
|
id: plan.id,
|
|
117
|
-
title: plan.title,
|
|
117
|
+
title: plan.split ? `${plan.title} (${plan.split.part}/${plan.split.of})` : plan.title,
|
|
118
118
|
section: "pending",
|
|
119
119
|
status: "pending",
|
|
120
120
|
check: "open",
|
|
@@ -270,6 +270,10 @@ export function taskDetailLines(task: Task, comments: readonly PlanComment[], se
|
|
|
270
270
|
lines.push(...wrapHanging(" ", facts.join(dot), width));
|
|
271
271
|
const track = task.track ? (task.track.path === "fast" ? `fast track (${task.track.size})` : `full workflow (${task.track.size})`) : "";
|
|
272
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
|
+
}
|
|
273
277
|
const steps = task.plan ? planChecklist(task.plan, persistedRuns(task)) : [];
|
|
274
278
|
const done = steps.filter((step) => step.status === "done").length;
|
|
275
279
|
if (steps.length > 0) {
|
|
@@ -331,6 +335,11 @@ export function planDetailLines(plan: PlannedTask, width: number, now: number, t
|
|
|
331
335
|
...wrapHanging(" ", paint(theme, "dim", plan.id), width),
|
|
332
336
|
];
|
|
333
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
|
+
}
|
|
334
343
|
const key = (text: string) => paint(theme, "accent", text);
|
|
335
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));
|
|
336
345
|
lines.push(...section("Agreed plan", width, theme), ...markdownLines(plan.brief, width, theme));
|