@danypops/pi-papyrus 0.57.3 → 0.57.5
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/extension/src/artifact/artifact-browser.ts +188 -52
- package/extension/src/artifact/binder-navigation.ts +208 -0
- package/extension/src/docs/docs.ts +13 -3
- package/extension/src/playbook/playbooks.ts +15 -5
- package/extension/src/rules/rules.ts +15 -5
- package/extension/src/task/tasks.ts +188 -48
- package/extension/src/tools/vehicle-notes-client.ts +5 -3
- package/package.json +2 -2
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
import { type Artifact, type OperationName, SEED_RELATIONS } from "@danypops/papyrus";
|
|
1
|
+
import { type Artifact, type BinderNode, type BinderTree, type OperationName, SEED_RELATIONS } from "@danypops/papyrus";
|
|
2
2
|
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Container, Input, matchesKey, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
5
5
|
import { callService } from "../service-client.ts";
|
|
6
6
|
import { showArtifactDetailView } from "./artifact-detail-view.ts";
|
|
7
7
|
import type { StatusPresentation } from "./artifact-status-presentation.ts";
|
|
8
|
+
import {
|
|
9
|
+
artifactBinderPath,
|
|
10
|
+
artifactSearchText,
|
|
11
|
+
artifactsInBinder,
|
|
12
|
+
binderSearchText,
|
|
13
|
+
childBinders,
|
|
14
|
+
createBinderInteractive,
|
|
15
|
+
currentBinderPath,
|
|
16
|
+
editBinderInteractive,
|
|
17
|
+
inheritedLabelsFor,
|
|
18
|
+
loadBinderTree,
|
|
19
|
+
moveArtifactInteractive,
|
|
20
|
+
moveBinderInteractive,
|
|
21
|
+
removeBinderInteractive,
|
|
22
|
+
} from "./binder-navigation.ts";
|
|
8
23
|
|
|
9
24
|
export { artifactDetailsText } from "./artifact-detail-format.ts";
|
|
10
25
|
|
|
@@ -20,6 +35,8 @@ export interface ArtifactBrowserConfig {
|
|
|
20
35
|
presentation: Record<string, StatusPresentation>;
|
|
21
36
|
listOperation?: OperationName;
|
|
22
37
|
listInput?: Record<string, unknown>;
|
|
38
|
+
/** Enables the Binder filesystem projection. Flat browsers such as Notes/Discuss remain unchanged. */
|
|
39
|
+
hierarchical?: boolean;
|
|
23
40
|
rowMeta(row: Artifact, theme: Theme): string;
|
|
24
41
|
actions(row: Artifact): string[];
|
|
25
42
|
handleAction(choice: string, row: Artifact, ctx: ExtensionCommandContext): Promise<void>;
|
|
@@ -109,57 +126,144 @@ export async function setArtifactStatus(ctx: ExtensionCommandContext, id: string
|
|
|
109
126
|
}
|
|
110
127
|
}
|
|
111
128
|
|
|
129
|
+
async function reloadBrowser(config: ArtifactBrowserConfig, projectRoot: string): Promise<{ rows: Artifact[]; tree?: BinderTree }> {
|
|
130
|
+
const rows = await loadArtifacts(config);
|
|
131
|
+
const tree = config.hierarchical
|
|
132
|
+
? await loadBinderTree(
|
|
133
|
+
projectRoot,
|
|
134
|
+
rows.map((row) => row.id),
|
|
135
|
+
)
|
|
136
|
+
: undefined;
|
|
137
|
+
return { rows, tree };
|
|
138
|
+
}
|
|
139
|
+
|
|
112
140
|
export async function showArtifactBrowser(ctx: ExtensionCommandContext, config: ArtifactBrowserConfig): Promise<void> {
|
|
113
141
|
if (!ctx.hasUI) {
|
|
114
142
|
ctx.ui.notify(`/${config.kind}s requires interactive mode`, "warning");
|
|
115
143
|
return;
|
|
116
144
|
}
|
|
117
|
-
let rows = await
|
|
118
|
-
|
|
145
|
+
let { rows, tree } = await reloadBrowser(config, ctx.cwd);
|
|
146
|
+
let currentBinderId: string | undefined;
|
|
147
|
+
if (rows.length === 0 && !config.hierarchical) {
|
|
119
148
|
ctx.ui.notify(`No ${config.kind} artifacts yet. Ask the agent to create one.`, "info");
|
|
120
149
|
return;
|
|
121
150
|
}
|
|
122
151
|
|
|
123
152
|
for (;;) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (selected
|
|
127
|
-
|
|
153
|
+
if (currentBinderId && !tree?.nodes.some((node) => node.binder.id === currentBinderId)) currentBinderId = undefined;
|
|
154
|
+
const selected = await renderPanel(ctx, rows, config, tree, currentBinderId);
|
|
155
|
+
if (!selected) return;
|
|
156
|
+
if (selected.type === "refresh") {
|
|
157
|
+
({ rows, tree } = await reloadBrowser(config, ctx.cwd));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (selected.type === "navigate") {
|
|
161
|
+
currentBinderId = selected.binderId;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (selected.type === "create-binder") {
|
|
165
|
+
if (await createBinderInteractive(ctx, currentBinderId)) ({ rows, tree } = await reloadBrowser(config, ctx.cwd));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (selected.type === "binder-action" && tree) {
|
|
169
|
+
const choice = await ctx.ui.select(selected.node.path, [
|
|
170
|
+
"Open",
|
|
171
|
+
"Create nested Binder",
|
|
172
|
+
"Rename / edit inherited labels",
|
|
173
|
+
"Move Binder",
|
|
174
|
+
"Remove empty Binder",
|
|
175
|
+
]);
|
|
176
|
+
if (!choice) continue;
|
|
177
|
+
if (choice === "Open") {
|
|
178
|
+
currentBinderId = selected.node.binder.id;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
let changed = false;
|
|
182
|
+
if (choice === "Create nested Binder") changed = await createBinderInteractive(ctx, selected.node.binder.id);
|
|
183
|
+
else if (choice === "Rename / edit inherited labels") changed = await editBinderInteractive(ctx, selected.node);
|
|
184
|
+
else if (choice === "Move Binder") changed = await moveBinderInteractive(ctx, tree, selected.node);
|
|
185
|
+
else if (choice === "Remove empty Binder") {
|
|
186
|
+
changed = await removeBinderInteractive(ctx, selected.node);
|
|
187
|
+
if (changed && currentBinderId === selected.node.binder.id) currentBinderId = selected.node.parentId;
|
|
188
|
+
}
|
|
189
|
+
if (changed) ({ rows, tree } = await reloadBrowser(config, ctx.cwd));
|
|
128
190
|
continue;
|
|
129
191
|
}
|
|
130
|
-
|
|
131
|
-
const
|
|
192
|
+
if (selected.type !== "artifact") continue;
|
|
193
|
+
const choices = [...(tree ? ["Move to Binder"] : []), ...config.actions(selected.row)];
|
|
194
|
+
const choice = await ctx.ui.select(selected.row.title, choices);
|
|
132
195
|
if (!choice) continue;
|
|
133
|
-
await
|
|
134
|
-
|
|
196
|
+
if (choice === "Move to Binder" && tree) await moveArtifactInteractive(ctx, tree, selected.row);
|
|
197
|
+
else await config.handleAction(choice, selected.row, ctx);
|
|
198
|
+
({ rows, tree } = await reloadBrowser(config, ctx.cwd));
|
|
135
199
|
}
|
|
136
200
|
}
|
|
137
201
|
|
|
202
|
+
type BrowserEntry = { type: "binder"; node: BinderNode } | { type: "artifact"; row: Artifact };
|
|
203
|
+
|
|
204
|
+
type BrowserPanelAction =
|
|
205
|
+
| { type: "artifact"; row: Artifact }
|
|
206
|
+
| { type: "binder-action"; node: BinderNode }
|
|
207
|
+
| { type: "navigate"; binderId?: string }
|
|
208
|
+
| { type: "create-binder" }
|
|
209
|
+
| { type: "refresh" };
|
|
210
|
+
|
|
211
|
+
export function browserEntries(
|
|
212
|
+
rows: Artifact[],
|
|
213
|
+
tree: BinderTree | undefined,
|
|
214
|
+
currentBinderId: string | undefined,
|
|
215
|
+
query: string,
|
|
216
|
+
): BrowserEntry[] {
|
|
217
|
+
const needle = query.trim().toLowerCase();
|
|
218
|
+
if (!tree) return filterArtifactRows(rows, query).map((row) => ({ type: "artifact", row }));
|
|
219
|
+
if (needle) {
|
|
220
|
+
return [
|
|
221
|
+
...tree.nodes.filter((node) => binderSearchText(node).includes(needle)).map((node): BrowserEntry => ({ type: "binder", node })),
|
|
222
|
+
...rows.filter((row) => artifactSearchText(row, tree).includes(needle)).map((row): BrowserEntry => ({ type: "artifact", row })),
|
|
223
|
+
].sort((left, right) => {
|
|
224
|
+
const leftPath = left.type === "binder" ? left.node.path : artifactBinderPath(left.row, tree);
|
|
225
|
+
const rightPath = right.type === "binder" ? right.node.path : artifactBinderPath(right.row, tree);
|
|
226
|
+
return leftPath.localeCompare(rightPath);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return [
|
|
230
|
+
...childBinders(tree, currentBinderId).map((node): BrowserEntry => ({ type: "binder", node })),
|
|
231
|
+
...artifactsInBinder(rows, tree, currentBinderId).map((row): BrowserEntry => ({ type: "artifact", row })),
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
|
|
138
235
|
function renderPanel(
|
|
139
236
|
ctx: ExtensionCommandContext,
|
|
140
237
|
rows: Artifact[],
|
|
141
238
|
config: ArtifactBrowserConfig,
|
|
142
|
-
|
|
143
|
-
|
|
239
|
+
tree: BinderTree | undefined,
|
|
240
|
+
currentBinderId: string | undefined,
|
|
241
|
+
): Promise<BrowserPanelAction | undefined> {
|
|
242
|
+
return ctx.ui.custom<BrowserPanelAction | undefined>((tui, theme, _keybindings, done) => {
|
|
144
243
|
const input = new Input();
|
|
145
244
|
let searchActive = false;
|
|
146
|
-
let filtered =
|
|
245
|
+
let filtered = browserEntries(rows, tree, currentBinderId, "");
|
|
147
246
|
let selectedIndex = 0;
|
|
148
247
|
|
|
149
248
|
function applyFilter(): void {
|
|
150
|
-
filtered =
|
|
249
|
+
filtered = browserEntries(rows, tree, currentBinderId, input.getValue());
|
|
151
250
|
selectedIndex = 0;
|
|
152
251
|
}
|
|
153
252
|
|
|
154
253
|
const header = {
|
|
155
254
|
invalidate() {},
|
|
156
255
|
render(width: number): string[] {
|
|
157
|
-
const
|
|
256
|
+
const path = tree ? ` · ${currentBinderPath(tree, currentBinderId)}` : "";
|
|
257
|
+
const title = theme.bold(`${config.title}${path}`);
|
|
158
258
|
const hint = searchActive
|
|
159
259
|
? rawKeyHint("esc", "clear")
|
|
160
|
-
: [
|
|
161
|
-
|
|
162
|
-
|
|
260
|
+
: [
|
|
261
|
+
rawKeyHint("enter", "open/actions"),
|
|
262
|
+
...(tree ? [rawKeyHint("a", "actions"), rawKeyHint("←", "up"), rawKeyHint("n", "new Binder")] : []),
|
|
263
|
+
rawKeyHint("/", "filter"),
|
|
264
|
+
rawKeyHint("r", "refresh"),
|
|
265
|
+
rawKeyHint("esc", "close"),
|
|
266
|
+
].join(theme.fg("muted", " · "));
|
|
163
267
|
const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
|
|
164
268
|
const summary = statusSummary(rows, config.statusOrder)
|
|
165
269
|
.map(({ status, count }) => {
|
|
@@ -168,9 +272,10 @@ function renderPanel(
|
|
|
168
272
|
return `${glyph} ${count} ${status}`;
|
|
169
273
|
})
|
|
170
274
|
.join(", ");
|
|
275
|
+
const binderSummary = tree ? `${tree.nodes.length} Binder${tree.nodes.length === 1 ? "" : "s"}` : "";
|
|
171
276
|
return [
|
|
172
277
|
truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""),
|
|
173
|
-
truncateToWidth(theme.fg("muted", summary), width, ""),
|
|
278
|
+
truncateToWidth(theme.fg("muted", [summary, binderSummary].filter(Boolean).join(" · ")), width, ""),
|
|
174
279
|
];
|
|
175
280
|
},
|
|
176
281
|
};
|
|
@@ -179,20 +284,48 @@ function renderPanel(
|
|
|
179
284
|
invalidate() {},
|
|
180
285
|
render(width: number): string[] {
|
|
181
286
|
const lines = searchActive ? [...input.render(width), ""] : [""];
|
|
182
|
-
if (filtered.length === 0) return [...lines, theme.fg("muted", ` No matching ${config.kind}s`)];
|
|
287
|
+
if (filtered.length === 0) return [...lines, theme.fg("muted", ` No ${tree ? "items" : `matching ${config.kind}s`}`)];
|
|
183
288
|
const start = Math.max(0, Math.min(selectedIndex - Math.floor(BROWSER_VISIBLE_ROWS / 2), filtered.length - BROWSER_VISIBLE_ROWS));
|
|
184
289
|
const end = Math.min(start + BROWSER_VISIBLE_ROWS, filtered.length);
|
|
185
290
|
for (let index = start; index < end; index++) {
|
|
186
|
-
const
|
|
291
|
+
const entry = filtered[index]!;
|
|
187
292
|
const selected = index === selectedIndex;
|
|
188
293
|
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
294
|
+
if (entry.type === "binder") {
|
|
295
|
+
const title = selected ? theme.bold(entry.node.binder.title) : entry.node.binder.title;
|
|
296
|
+
const details = [
|
|
297
|
+
entry.node.childIds.length > 0 ? `${entry.node.childIds.length} Binder${entry.node.childIds.length === 1 ? "" : "s"}` : "",
|
|
298
|
+
entry.node.effectiveLabels.length > 0 ? entry.node.effectiveLabels.join(", ") : "",
|
|
299
|
+
searchActive ? entry.node.path : "",
|
|
300
|
+
].filter(Boolean);
|
|
301
|
+
lines.push(
|
|
302
|
+
truncateToWidth(
|
|
303
|
+
`${cursor} ${theme.fg("accent", "▸")} ${title}${details.length ? theme.fg("dim", ` · ${details.join(" · ")}`) : ""}`,
|
|
304
|
+
width,
|
|
305
|
+
"",
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const row = entry.row;
|
|
189
311
|
const presentation = config.presentation[row.status];
|
|
190
312
|
const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : "?";
|
|
191
313
|
const title = selected ? theme.bold(row.title) : row.title;
|
|
192
|
-
const
|
|
193
|
-
|
|
314
|
+
const inherited = tree ? inheritedLabelsFor(row.id, tree) : [];
|
|
315
|
+
const details = [
|
|
316
|
+
config.rowMeta(row, theme),
|
|
317
|
+
inherited.length > 0 ? `inherits ${inherited.join(", ")}` : "",
|
|
318
|
+
tree && searchActive ? artifactBinderPath(row, tree) : "",
|
|
319
|
+
].filter(Boolean);
|
|
320
|
+
lines.push(
|
|
321
|
+
truncateToWidth(
|
|
322
|
+
`${cursor} ${glyph} ${title}${details.length ? `${theme.fg("dim", " · ")}${details.join(theme.fg("dim", " · "))}` : ""}`,
|
|
323
|
+
width,
|
|
324
|
+
"",
|
|
325
|
+
),
|
|
326
|
+
);
|
|
194
327
|
}
|
|
195
|
-
lines.push(theme.fg("muted", ` ${selectedIndex + 1}/${filtered.length} ${
|
|
328
|
+
lines.push(theme.fg("muted", ` ${selectedIndex + 1}/${filtered.length} item${filtered.length === 1 ? "" : "s"}`));
|
|
196
329
|
return lines;
|
|
197
330
|
},
|
|
198
331
|
};
|
|
@@ -212,10 +345,10 @@ function renderPanel(
|
|
|
212
345
|
invalidate: () => container.invalidate(),
|
|
213
346
|
handleInput(data: string) {
|
|
214
347
|
if (searchActive) {
|
|
215
|
-
if (data
|
|
348
|
+
if (matchesKey(data, "escape")) {
|
|
216
349
|
searchActive = false;
|
|
217
350
|
applyFilter();
|
|
218
|
-
} else if (data
|
|
351
|
+
} else if (matchesKey(data, "enter")) searchActive = false;
|
|
219
352
|
else {
|
|
220
353
|
input.handleInput(data);
|
|
221
354
|
applyFilter();
|
|
@@ -223,30 +356,33 @@ function renderPanel(
|
|
|
223
356
|
tui.requestRender();
|
|
224
357
|
return;
|
|
225
358
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
359
|
+
if (matchesKey(data, "up")) selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(filtered.length, 1);
|
|
360
|
+
else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
|
|
361
|
+
else if (data === "/") searchActive = true;
|
|
362
|
+
else if (tree && data === "n") {
|
|
363
|
+
done({ type: "create-binder" });
|
|
364
|
+
return;
|
|
365
|
+
} else if (tree && (matchesKey(data, "left") || data === "\x7f")) {
|
|
366
|
+
const parentId = currentBinderId ? tree.nodes.find((node) => node.binder.id === currentBinderId)?.parentId : undefined;
|
|
367
|
+
done({ type: "navigate", ...(parentId ? { binderId: parentId } : {}) });
|
|
368
|
+
return;
|
|
369
|
+
} else if (data === "r") {
|
|
370
|
+
done({ type: "refresh" });
|
|
371
|
+
return;
|
|
372
|
+
} else if (tree && data === "a") {
|
|
373
|
+
const entry = filtered[selectedIndex];
|
|
374
|
+
if (entry?.type === "binder") done({ type: "binder-action", node: entry.node });
|
|
375
|
+
else if (entry?.type === "artifact") done({ type: "artifact", row: entry.row });
|
|
376
|
+
return;
|
|
377
|
+
} else if (matchesKey(data, "enter")) {
|
|
378
|
+
const entry = filtered[selectedIndex];
|
|
379
|
+
if (entry?.type === "binder") done({ type: "navigate", binderId: entry.node.binder.id });
|
|
380
|
+
else if (entry?.type === "artifact") done({ type: "artifact", row: entry.row });
|
|
381
|
+
return;
|
|
382
|
+
} else if (matchesKey(data, "escape")) {
|
|
383
|
+
done(undefined);
|
|
384
|
+
return;
|
|
385
|
+
} else return;
|
|
250
386
|
tui.requestRender();
|
|
251
387
|
},
|
|
252
388
|
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { Artifact, BinderArtifactPlacement, BinderNode, BinderTree } from "@danypops/papyrus";
|
|
2
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { callService } from "../service-client.ts";
|
|
4
|
+
|
|
5
|
+
export async function loadBinderTree(projectRoot: string, artifactIds: readonly string[]): Promise<BinderTree> {
|
|
6
|
+
return callService<Record<string, unknown>, BinderTree>("binders.tree", {
|
|
7
|
+
project_root: projectRoot,
|
|
8
|
+
artifact_ids: [...new Set(artifactIds)],
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseLabelInput(value: string): string[] {
|
|
13
|
+
const result: string[] = [];
|
|
14
|
+
for (const label of value.split(",").map((entry) => entry.trim())) {
|
|
15
|
+
if (label && !result.includes(label)) result.push(label);
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function binderNodeById(tree: BinderTree): Map<string, BinderNode> {
|
|
21
|
+
return new Map(tree.nodes.map((node) => [node.binder.id, node]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function binderPlacementByArtifactId(tree: BinderTree): Map<string, BinderArtifactPlacement> {
|
|
25
|
+
return new Map(tree.artifacts.map((placement) => [placement.artifactId, placement]));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function currentBinderPath(tree: BinderTree, binderId: string | undefined): string {
|
|
29
|
+
if (binderId === undefined) return "/";
|
|
30
|
+
return binderNodeById(tree).get(binderId)?.path ?? "/";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function childBinders(tree: BinderTree, parentId: string | undefined): BinderNode[] {
|
|
34
|
+
return tree.nodes
|
|
35
|
+
.filter((node) => node.parentId === parentId)
|
|
36
|
+
.sort((left, right) => left.binder.title.localeCompare(right.binder.title) || left.binder.id.localeCompare(right.binder.id));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function artifactsInBinder<T extends Pick<Artifact, "id" | "title">>(
|
|
40
|
+
rows: readonly T[],
|
|
41
|
+
tree: BinderTree,
|
|
42
|
+
binderId: string | undefined,
|
|
43
|
+
): T[] {
|
|
44
|
+
const placements = binderPlacementByArtifactId(tree);
|
|
45
|
+
return rows
|
|
46
|
+
.filter((row) => placements.get(row.id)?.binderId === binderId)
|
|
47
|
+
.sort((left, right) => left.title.localeCompare(right.title) || left.id.localeCompare(right.id));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function artifactBinderPath(row: Pick<Artifact, "id" | "title">, tree: BinderTree): string {
|
|
51
|
+
const placement = binderPlacementByArtifactId(tree).get(row.id);
|
|
52
|
+
const binderPath = placement?.binderId ? binderNodeById(tree).get(placement.binderId)?.path : undefined;
|
|
53
|
+
return binderPath ? `${binderPath}/${row.title}` : `/${row.title}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function inheritedLabelsFor(rowId: string, tree: BinderTree): string[] {
|
|
57
|
+
return binderPlacementByArtifactId(tree).get(rowId)?.inheritedLabels ?? [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function effectiveLabelsFor(row: Pick<Artifact, "id" | "labels">, tree: BinderTree): string[] {
|
|
61
|
+
return binderPlacementByArtifactId(tree).get(row.id)?.effectiveLabels ?? [...row.labels];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function binderSearchText(node: BinderNode): string {
|
|
65
|
+
return [node.path, node.binder.title, node.binder.alias, ...node.effectiveLabels].join(" ").toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function artifactSearchText(row: Artifact, tree: BinderTree): string {
|
|
69
|
+
return [artifactBinderPath(row, tree), row.id, row.alias, row.title, row.body ?? "", row.subtype ?? "", ...effectiveLabelsFor(row, tree)]
|
|
70
|
+
.join(" ")
|
|
71
|
+
.toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function createBinderInteractive(ctx: ExtensionCommandContext, parentId: string | undefined): Promise<boolean> {
|
|
75
|
+
const title = await ctx.ui.input("Binder name:", "");
|
|
76
|
+
if (!title) return false;
|
|
77
|
+
const labelsText = await ctx.ui.input("Inherited labels (comma-separated):", "");
|
|
78
|
+
if (labelsText === undefined) return false;
|
|
79
|
+
try {
|
|
80
|
+
await callService("binders.create", {
|
|
81
|
+
title,
|
|
82
|
+
labels: parseLabelInput(labelsText),
|
|
83
|
+
...(parentId ? { parent_id: parentId } : {}),
|
|
84
|
+
project_root: ctx.cwd,
|
|
85
|
+
actor: "user",
|
|
86
|
+
source: "artifact-navigator",
|
|
87
|
+
});
|
|
88
|
+
ctx.ui.notify(`Created Binder "${title.trim()}"`, "info");
|
|
89
|
+
return true;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
ctx.ui.notify(`Binder creation failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function editBinderInteractive(ctx: ExtensionCommandContext, node: BinderNode): Promise<boolean> {
|
|
97
|
+
const title = await ctx.ui.input("Binder name:", node.binder.title);
|
|
98
|
+
if (title === undefined) return false;
|
|
99
|
+
const labelsText = await ctx.ui.input("Inherited labels (comma-separated):", node.binder.labels.join(", "));
|
|
100
|
+
if (labelsText === undefined) return false;
|
|
101
|
+
try {
|
|
102
|
+
await callService("binders.update", {
|
|
103
|
+
id: node.binder.id,
|
|
104
|
+
title,
|
|
105
|
+
labels: parseLabelInput(labelsText),
|
|
106
|
+
project_root: ctx.cwd,
|
|
107
|
+
actor: "user",
|
|
108
|
+
source: "artifact-navigator",
|
|
109
|
+
});
|
|
110
|
+
ctx.ui.notify(`Updated Binder "${title.trim()}"`, "info");
|
|
111
|
+
return true;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
ctx.ui.notify(`Binder update failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function descendantIds(tree: BinderTree, rootId: string): Set<string> {
|
|
119
|
+
const byId = binderNodeById(tree);
|
|
120
|
+
const result = new Set<string>([rootId]);
|
|
121
|
+
const pending = [...(byId.get(rootId)?.childIds ?? [])];
|
|
122
|
+
while (pending.length > 0) {
|
|
123
|
+
const id = pending.pop()!;
|
|
124
|
+
if (result.has(id)) continue;
|
|
125
|
+
result.add(id);
|
|
126
|
+
pending.push(...(byId.get(id)?.childIds ?? []));
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function selectBinderDestination(
|
|
132
|
+
ctx: ExtensionCommandContext,
|
|
133
|
+
tree: BinderTree,
|
|
134
|
+
excludedIds: ReadonlySet<string> = new Set(),
|
|
135
|
+
): Promise<string | null | undefined> {
|
|
136
|
+
const candidates = tree.nodes
|
|
137
|
+
.filter((node) => !excludedIds.has(node.binder.id))
|
|
138
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
139
|
+
const labels = ["/ (root)", ...candidates.map((node) => node.path)];
|
|
140
|
+
const selected = await ctx.ui.select("Destination Binder", labels);
|
|
141
|
+
if (!selected) return undefined;
|
|
142
|
+
if (selected === labels[0]) return null;
|
|
143
|
+
return candidates[labels.indexOf(selected) - 1]?.binder.id;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function moveBinderInteractive(ctx: ExtensionCommandContext, tree: BinderTree, node: BinderNode): Promise<boolean> {
|
|
147
|
+
const destination = await selectBinderDestination(ctx, tree, descendantIds(tree, node.binder.id));
|
|
148
|
+
if (destination === undefined) return false;
|
|
149
|
+
try {
|
|
150
|
+
await callService("binders.move", {
|
|
151
|
+
id: node.binder.id,
|
|
152
|
+
...(destination ? { parent_id: destination } : {}),
|
|
153
|
+
project_root: ctx.cwd,
|
|
154
|
+
actor: "user",
|
|
155
|
+
source: "artifact-navigator",
|
|
156
|
+
});
|
|
157
|
+
ctx.ui.notify(`Moved "${node.binder.title}"`, "info");
|
|
158
|
+
return true;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
ctx.ui.notify(`Binder move failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function moveArtifactInteractive(ctx: ExtensionCommandContext, tree: BinderTree, artifact: Artifact): Promise<boolean> {
|
|
166
|
+
const destination = await selectBinderDestination(ctx, tree);
|
|
167
|
+
if (destination === undefined) return false;
|
|
168
|
+
try {
|
|
169
|
+
if (destination === null) {
|
|
170
|
+
await callService("binders.unfile", {
|
|
171
|
+
artifact_id: artifact.id,
|
|
172
|
+
project_root: ctx.cwd,
|
|
173
|
+
actor: "user",
|
|
174
|
+
source: "artifact-navigator",
|
|
175
|
+
});
|
|
176
|
+
} else {
|
|
177
|
+
await callService("binders.file", {
|
|
178
|
+
artifact_id: artifact.id,
|
|
179
|
+
binder_id: destination,
|
|
180
|
+
project_root: ctx.cwd,
|
|
181
|
+
actor: "user",
|
|
182
|
+
source: "artifact-navigator",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
ctx.ui.notify(`Moved "${artifact.title}"`, "info");
|
|
186
|
+
return true;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
ctx.ui.notify(`Artifact move failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function removeBinderInteractive(ctx: ExtensionCommandContext, node: BinderNode): Promise<boolean> {
|
|
194
|
+
try {
|
|
195
|
+
await callService("binders.remove", {
|
|
196
|
+
id: node.binder.id,
|
|
197
|
+
project_root: ctx.cwd,
|
|
198
|
+
reason: "Removed from filesystem-style TUI navigator",
|
|
199
|
+
actor: "user",
|
|
200
|
+
source: "artifact-navigator",
|
|
201
|
+
});
|
|
202
|
+
ctx.ui.notify(`Removed Binder "${node.binder.title}"`, "info");
|
|
203
|
+
return true;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
ctx.ui.notify(`Binder removal failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { Artifact } from "@danypops/papyrus";
|
|
|
2
2
|
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { showArtifactBrowser, showArtifactDetails } from "../artifact/artifact-browser.ts";
|
|
4
4
|
import { DOC_STATUS_PRESENTATION } from "../artifact/artifact-status-presentation.ts";
|
|
5
|
+
import { parseLabelInput } from "../artifact/binder-navigation.ts";
|
|
5
6
|
import { callService } from "../service-client.ts";
|
|
6
7
|
|
|
7
8
|
const DOC_ACTIONS: Record<string, string[]> = {
|
|
@@ -23,6 +24,7 @@ export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
23
24
|
listOperation: "docs.list",
|
|
24
25
|
statusOrder: ["draft", "active", "archived"],
|
|
25
26
|
presentation: DOC_STATUS_PRESENTATION,
|
|
27
|
+
hierarchical: true,
|
|
26
28
|
rowMeta: documentRowMeta,
|
|
27
29
|
actions: (document) => ["Show details", "Edit", "Link artifact", ...(DOC_ACTIONS[document.status] ?? [])],
|
|
28
30
|
handleAction: async (choice, document, commandCtx) => {
|
|
@@ -31,11 +33,19 @@ export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
31
33
|
return;
|
|
32
34
|
}
|
|
33
35
|
if (choice === "Edit") {
|
|
34
|
-
const
|
|
36
|
+
const current = await callService<Record<string, unknown>, Artifact>("docs.show", { id: document.id });
|
|
37
|
+
const title = await commandCtx.ui.input("Title:", current.title);
|
|
35
38
|
if (title === undefined) return; // canceled
|
|
36
|
-
const body = await commandCtx.ui.input("Body:",
|
|
39
|
+
const body = await commandCtx.ui.input("Body:", current.body);
|
|
37
40
|
if (body === undefined) return; // canceled
|
|
38
|
-
const
|
|
41
|
+
const labels = await commandCtx.ui.input("Direct labels (comma-separated):", current.labels.join(", "));
|
|
42
|
+
if (labels === undefined) return; // canceled
|
|
43
|
+
const updated = await callService<Record<string, unknown>, Artifact>("docs.update", {
|
|
44
|
+
id: document.id,
|
|
45
|
+
title,
|
|
46
|
+
body,
|
|
47
|
+
labels: parseLabelInput(labels),
|
|
48
|
+
});
|
|
39
49
|
commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
|
|
40
50
|
return;
|
|
41
51
|
}
|
|
@@ -3,6 +3,7 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
4
4
|
import { showArtifactBrowser, showArtifactDetails } from "../artifact/artifact-browser.ts";
|
|
5
5
|
import { PLAYBOOK_STATUS_PRESENTATION } from "../artifact/artifact-status-presentation.ts";
|
|
6
|
+
import { parseLabelInput } from "../artifact/binder-navigation.ts";
|
|
6
7
|
import { matchArtifactByName } from "../domain-tools.ts";
|
|
7
8
|
import { callService } from "../service-client.ts";
|
|
8
9
|
|
|
@@ -70,8 +71,8 @@ function strings(value: unknown): string[] {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
export function playbookRowMeta(playbook: Artifact): string {
|
|
73
|
-
const trigger = typeof playbook.extra
|
|
74
|
-
const tools = strings(playbook.extra
|
|
74
|
+
const trigger = typeof playbook.extra?.trigger === "string" ? `when ${playbook.extra.trigger}` : "manual invocation";
|
|
75
|
+
const tools = strings(playbook.extra?.tools);
|
|
75
76
|
return [trigger, tools.join(", ")].filter(Boolean).join(" \u00b7 ");
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -82,6 +83,7 @@ export async function showPlaybooks(ctx: ExtensionCommandContext): Promise<void>
|
|
|
82
83
|
listOperation: "playbooks.list",
|
|
83
84
|
statusOrder: ["active", "deprecated"],
|
|
84
85
|
presentation: PLAYBOOK_STATUS_PRESENTATION,
|
|
86
|
+
hierarchical: true,
|
|
85
87
|
rowMeta: playbookRowMeta,
|
|
86
88
|
actions: (playbook) => ["Show details", "Edit", "Invoke", "Link artifact", playbook.status === "active" ? "Disable" : "Enable"],
|
|
87
89
|
handleAction: async (choice, playbook, commandCtx) => {
|
|
@@ -90,11 +92,19 @@ export async function showPlaybooks(ctx: ExtensionCommandContext): Promise<void>
|
|
|
90
92
|
return;
|
|
91
93
|
}
|
|
92
94
|
if (choice === "Edit") {
|
|
93
|
-
const
|
|
95
|
+
const current = await callService<Record<string, unknown>, Artifact>("playbooks.show", { id: playbook.id });
|
|
96
|
+
const title = await commandCtx.ui.input("Title:", current.title);
|
|
94
97
|
if (title === undefined) return; // canceled
|
|
95
|
-
const body = await commandCtx.ui.input("Body:",
|
|
98
|
+
const body = await commandCtx.ui.input("Body:", current.body);
|
|
96
99
|
if (body === undefined) return; // canceled
|
|
97
|
-
const
|
|
100
|
+
const labels = await commandCtx.ui.input("Direct labels (comma-separated):", current.labels.join(", "));
|
|
101
|
+
if (labels === undefined) return; // canceled
|
|
102
|
+
const updated = await callService<Record<string, unknown>, Artifact>("playbooks.update", {
|
|
103
|
+
id: playbook.id,
|
|
104
|
+
title,
|
|
105
|
+
body,
|
|
106
|
+
labels: parseLabelInput(labels),
|
|
107
|
+
});
|
|
98
108
|
commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
|
|
99
109
|
return;
|
|
100
110
|
}
|
|
@@ -2,12 +2,13 @@ import type { Artifact } from "@danypops/papyrus";
|
|
|
2
2
|
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { showArtifactBrowser, showArtifactDetails } from "../artifact/artifact-browser.ts";
|
|
4
4
|
import { RULE_STATUS_PRESENTATION, severityColor } from "../artifact/artifact-status-presentation.ts";
|
|
5
|
+
import { parseLabelInput } from "../artifact/binder-navigation.ts";
|
|
5
6
|
import { callService } from "../service-client.ts";
|
|
6
7
|
|
|
7
8
|
export function ruleRowMeta(rule: Artifact, theme: Theme): string {
|
|
8
|
-
const severity = typeof rule.extra
|
|
9
|
+
const severity = typeof rule.extra?.severity === "string" ? rule.extra.severity : "info";
|
|
9
10
|
const severityText = theme.fg(severityColor(severity), severity.toUpperCase());
|
|
10
|
-
const condition = typeof rule.extra
|
|
11
|
+
const condition = typeof rule.extra?.condition === "string" ? `when ${rule.extra.condition}` : "always";
|
|
11
12
|
return `${severityText} · ${condition}`;
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -24,16 +25,25 @@ export async function showRules(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
24
25
|
listOperation: "rules.list",
|
|
25
26
|
statusOrder: ["active", "deprecated"],
|
|
26
27
|
presentation: RULE_STATUS_PRESENTATION,
|
|
28
|
+
hierarchical: true,
|
|
27
29
|
rowMeta: ruleRowMeta,
|
|
28
30
|
actions: (rule) => ["Show details", "Edit", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
|
|
29
31
|
handleAction: async (choice, rule, commandCtx) => {
|
|
30
32
|
if (choice === "Show details") await showArtifactDetails(commandCtx, rule.id, "rules.show");
|
|
31
33
|
else if (choice === "Edit") {
|
|
32
|
-
const
|
|
34
|
+
const current = await callService<Record<string, unknown>, Artifact>("rules.show", { id: rule.id });
|
|
35
|
+
const title = await commandCtx.ui.input("Title:", current.title);
|
|
33
36
|
if (title === undefined) return; // canceled
|
|
34
|
-
const body = await commandCtx.ui.input("Body:",
|
|
37
|
+
const body = await commandCtx.ui.input("Body:", current.body);
|
|
35
38
|
if (body === undefined) return; // canceled
|
|
36
|
-
const
|
|
39
|
+
const labels = await commandCtx.ui.input("Direct labels (comma-separated):", current.labels.join(", "));
|
|
40
|
+
if (labels === undefined) return; // canceled
|
|
41
|
+
const updated = await callService<Record<string, unknown>, Artifact>("rules.update", {
|
|
42
|
+
id: rule.id,
|
|
43
|
+
title,
|
|
44
|
+
body,
|
|
45
|
+
labels: parseLabelInput(labels),
|
|
46
|
+
});
|
|
37
47
|
commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
|
|
38
48
|
} else if (choice === "Preview injection") {
|
|
39
49
|
const result = await callService<Record<string, unknown>, { preview: string; combinedLength: number; warning?: string }>(
|
|
@@ -6,6 +6,22 @@
|
|
|
6
6
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Container, Input, matchesKey, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import {
|
|
10
|
+
artifactBinderPath,
|
|
11
|
+
artifactSearchText,
|
|
12
|
+
artifactsInBinder,
|
|
13
|
+
binderSearchText,
|
|
14
|
+
childBinders,
|
|
15
|
+
createBinderInteractive,
|
|
16
|
+
currentBinderPath,
|
|
17
|
+
editBinderInteractive,
|
|
18
|
+
inheritedLabelsFor,
|
|
19
|
+
loadBinderTree,
|
|
20
|
+
moveArtifactInteractive,
|
|
21
|
+
moveBinderInteractive,
|
|
22
|
+
parseLabelInput,
|
|
23
|
+
removeBinderInteractive,
|
|
24
|
+
} from "../artifact/binder-navigation.ts";
|
|
9
25
|
import { callService } from "../service-client.ts";
|
|
10
26
|
import { sessionSecretField } from "../session-identity.ts";
|
|
11
27
|
import { showTaskDetails } from "./task-detail-view.ts";
|
|
@@ -17,6 +33,8 @@ export { showTaskDetails } from "./task-detail-view.ts";
|
|
|
17
33
|
|
|
18
34
|
import {
|
|
19
35
|
type Artifact,
|
|
36
|
+
type BinderNode,
|
|
37
|
+
type BinderTree,
|
|
20
38
|
type GateResult,
|
|
21
39
|
projectTaskExecution,
|
|
22
40
|
type TaskCompletion,
|
|
@@ -51,8 +69,10 @@ export interface TaskHierarchyRow {
|
|
|
51
69
|
active: boolean;
|
|
52
70
|
}
|
|
53
71
|
|
|
54
|
-
export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
|
|
55
|
-
const byId = new Map(
|
|
72
|
+
export function buildTaskHierarchy(graph: TaskGraph, includedIds?: ReadonlySet<string>): TaskHierarchyRow[] {
|
|
73
|
+
const byId = new Map(
|
|
74
|
+
graph.nodes.filter((node) => includedIds === undefined || includedIds.has(node.task.id)).map((node) => [node.task.id, node]),
|
|
75
|
+
);
|
|
56
76
|
const result: TaskHierarchyRow[] = [];
|
|
57
77
|
const visited = new Set<string>();
|
|
58
78
|
const visit = (id: string, depth: number): void => {
|
|
@@ -71,7 +91,9 @@ export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
|
|
|
71
91
|
for (const childId of children) visit(childId, depth + 1);
|
|
72
92
|
};
|
|
73
93
|
for (const rootId of graph.rootIds) visit(rootId, 0);
|
|
74
|
-
for (const node of graph.nodes)
|
|
94
|
+
for (const node of graph.nodes) {
|
|
95
|
+
if (byId.has(node.task.id)) visit(node.task.id, 0);
|
|
96
|
+
}
|
|
75
97
|
return result;
|
|
76
98
|
}
|
|
77
99
|
|
|
@@ -99,23 +121,66 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
99
121
|
// concurrent agent working the same project never appears as (or is overridden by) this one.
|
|
100
122
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
101
123
|
let graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
102
|
-
|
|
103
|
-
|
|
124
|
+
let binders = await loadBinderTree(
|
|
125
|
+
ctx.cwd,
|
|
126
|
+
graph.nodes.map((node) => node.task.id),
|
|
127
|
+
);
|
|
128
|
+
let currentBinderId: string | undefined;
|
|
129
|
+
const refresh = async (): Promise<void> => {
|
|
130
|
+
graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
131
|
+
binders = await loadBinderTree(
|
|
132
|
+
ctx.cwd,
|
|
133
|
+
graph.nodes.map((node) => node.task.id),
|
|
134
|
+
);
|
|
135
|
+
if (currentBinderId && !binders.nodes.some((node) => node.binder.id === currentBinderId)) currentBinderId = undefined;
|
|
136
|
+
};
|
|
137
|
+
if (graph.nodes.length === 0 && binders.nodes.length === 0) {
|
|
138
|
+
const create = await ctx.ui.select("No tasks or Binders yet", ["Create a task", "Create a Binder", "Cancel"]);
|
|
104
139
|
if (create === "Create a task") {
|
|
105
140
|
const title = await ctx.ui.input("Task title:", "");
|
|
106
141
|
if (title) {
|
|
107
142
|
await callService("tasks.create", { title, project_root: ctx.cwd, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
108
|
-
|
|
143
|
+
await refresh();
|
|
109
144
|
}
|
|
110
|
-
}
|
|
111
|
-
|
|
145
|
+
} else if (create === "Create a Binder") {
|
|
146
|
+
if (await createBinderInteractive(ctx, undefined)) await refresh();
|
|
147
|
+
} else return;
|
|
112
148
|
}
|
|
113
149
|
|
|
114
150
|
for (;;) {
|
|
115
|
-
const action = await renderPanel(ctx, graph);
|
|
151
|
+
const action = await renderPanel(ctx, graph, binders, currentBinderId);
|
|
116
152
|
if (!action) return;
|
|
117
153
|
if (action.type === "refresh") {
|
|
118
|
-
|
|
154
|
+
await refresh();
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (action.type === "navigate") {
|
|
158
|
+
currentBinderId = action.binderId;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (action.type === "create-binder") {
|
|
162
|
+
if (await createBinderInteractive(ctx, currentBinderId)) await refresh();
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (action.type === "binder-action" && action.binder) {
|
|
166
|
+
const choice = await ctx.ui.select(action.binder.path, [
|
|
167
|
+
"Open",
|
|
168
|
+
"Create nested Binder",
|
|
169
|
+
"Rename / edit inherited labels",
|
|
170
|
+
"Move Binder",
|
|
171
|
+
"Remove empty Binder",
|
|
172
|
+
]);
|
|
173
|
+
if (!choice) continue;
|
|
174
|
+
if (choice === "Open") {
|
|
175
|
+
currentBinderId = action.binder.binder.id;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
let changed = false;
|
|
179
|
+
if (choice === "Create nested Binder") changed = await createBinderInteractive(ctx, action.binder.binder.id);
|
|
180
|
+
else if (choice === "Rename / edit inherited labels") changed = await editBinderInteractive(ctx, action.binder);
|
|
181
|
+
else if (choice === "Move Binder") changed = await moveBinderInteractive(ctx, binders, action.binder);
|
|
182
|
+
else if (choice === "Remove empty Binder") changed = await removeBinderInteractive(ctx, action.binder);
|
|
183
|
+
if (changed) await refresh();
|
|
119
184
|
continue;
|
|
120
185
|
}
|
|
121
186
|
if (action.type === "scope") {
|
|
@@ -135,7 +200,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
135
200
|
if (!rootTaskId) continue;
|
|
136
201
|
}
|
|
137
202
|
await callService("tasks.set_scope", { project_root: ctx.cwd, scope, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) });
|
|
138
|
-
|
|
203
|
+
await refresh();
|
|
139
204
|
continue;
|
|
140
205
|
}
|
|
141
206
|
if (action.type === "graph") {
|
|
@@ -151,6 +216,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
151
216
|
const choices = [
|
|
152
217
|
"Show details",
|
|
153
218
|
"Edit task",
|
|
219
|
+
"Move to Binder",
|
|
154
220
|
...(!active && action.row.status !== "done" && action.row.status !== "canceled" ? ["Make active"] : []),
|
|
155
221
|
...(active ? [focusStatus === "paused" ? "Resume focus" : "Pause focus", "Clear focus"] : []),
|
|
156
222
|
...(action.row.status === "review" ? ["Run gates"] : []),
|
|
@@ -160,6 +226,11 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
160
226
|
];
|
|
161
227
|
const choice = await ctx.ui.select(action.row.title, choices);
|
|
162
228
|
if (!choice) continue;
|
|
229
|
+
if (choice === "Move to Binder") {
|
|
230
|
+
await moveArtifactInteractive(ctx, binders, action.row);
|
|
231
|
+
await refresh();
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
163
234
|
|
|
164
235
|
if ((choice === "Remove dependency" || choice === "Remove from parent") && node) {
|
|
165
236
|
const relatedIds = choice === "Remove dependency" ? node.dependencyIds : node.parentIds;
|
|
@@ -198,7 +269,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
198
269
|
} catch (error) {
|
|
199
270
|
ctx.ui.notify(`Relationship removal failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
200
271
|
}
|
|
201
|
-
|
|
272
|
+
await refresh();
|
|
202
273
|
continue;
|
|
203
274
|
}
|
|
204
275
|
|
|
@@ -215,16 +286,20 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
215
286
|
if (title === undefined) continue;
|
|
216
287
|
const body = await ctx.ui.input("Task body:", action.row.body);
|
|
217
288
|
if (body === undefined) continue;
|
|
289
|
+
const labels = await ctx.ui.input("Direct labels (comma-separated):", action.row.labels.join(", "));
|
|
290
|
+
if (labels === undefined) continue;
|
|
218
291
|
try {
|
|
219
292
|
const updated = await callService<Record<string, unknown>, Artifact>("tasks.update", {
|
|
220
293
|
id: action.row.id,
|
|
221
294
|
title,
|
|
222
295
|
body,
|
|
296
|
+
labels: parseLabelInput(labels),
|
|
223
297
|
actor: "user",
|
|
224
298
|
source: "tasks-tui",
|
|
225
299
|
});
|
|
226
300
|
action.row.title = updated.title;
|
|
227
301
|
action.row.body = updated.body;
|
|
302
|
+
action.row.labels = updated.labels;
|
|
228
303
|
ctx.ui.notify(`Updated: ${updated.title}`, "info");
|
|
229
304
|
} catch (error) {
|
|
230
305
|
ctx.ui.notify(`Task update failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
@@ -331,39 +406,65 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
331
406
|
ctx.ui.notify(`Task action failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
332
407
|
}
|
|
333
408
|
}
|
|
334
|
-
|
|
409
|
+
await refresh();
|
|
335
410
|
}
|
|
336
411
|
}
|
|
337
412
|
|
|
413
|
+
type TaskPanelEntry = { type: "binder"; node: BinderNode } | { type: "task"; entry: TaskHierarchyRow };
|
|
414
|
+
|
|
338
415
|
interface PanelAction {
|
|
339
|
-
type: "action" | "refresh" | "graph" | "scope";
|
|
416
|
+
type: "action" | "binder-action" | "navigate" | "create-binder" | "refresh" | "graph" | "scope";
|
|
340
417
|
row?: TaskRow;
|
|
418
|
+
binder?: BinderNode;
|
|
419
|
+
binderId?: string;
|
|
341
420
|
}
|
|
342
421
|
|
|
343
|
-
function renderPanel(
|
|
422
|
+
function renderPanel(
|
|
423
|
+
ctx: ExtensionCommandContext,
|
|
424
|
+
graph: TaskGraph,
|
|
425
|
+
binders: BinderTree,
|
|
426
|
+
currentBinderId: string | undefined,
|
|
427
|
+
): Promise<PanelAction | undefined> {
|
|
344
428
|
return ctx.ui.custom<PanelAction | undefined>((tui, theme, _kb, done) => {
|
|
345
429
|
const rows = graph.nodes.map((node) => node.task);
|
|
346
430
|
const searchInput = new Input();
|
|
347
|
-
const
|
|
431
|
+
const allHierarchy = buildTaskHierarchy(graph);
|
|
432
|
+
const directoryIds = new Set(artifactsInBinder(rows, binders, currentBinderId).map((task) => task.id));
|
|
433
|
+
const directoryHierarchy = buildTaskHierarchy(graph, directoryIds);
|
|
434
|
+
const currentEntries = (): TaskPanelEntry[] => [
|
|
435
|
+
...childBinders(binders, currentBinderId).map((node): TaskPanelEntry => ({ type: "binder", node })),
|
|
436
|
+
...directoryHierarchy.map((entry): TaskPanelEntry => ({ type: "task", entry })),
|
|
437
|
+
];
|
|
348
438
|
const taskById = new Map(rows.map((task) => [task.id, task]));
|
|
349
439
|
const executionById = new Map(projectTaskExecution(graph).nodes.map((node) => [node.id, node]));
|
|
350
440
|
let searchActive = false;
|
|
351
|
-
let filtered =
|
|
441
|
+
let filtered = currentEntries();
|
|
352
442
|
let selectedIndex = 0;
|
|
353
443
|
const maxVisible = 20;
|
|
354
444
|
|
|
355
445
|
function applyFilter(): void {
|
|
356
|
-
const
|
|
357
|
-
filtered =
|
|
358
|
-
?
|
|
359
|
-
|
|
446
|
+
const query = searchInput.getValue().trim().toLowerCase();
|
|
447
|
+
filtered = query
|
|
448
|
+
? [
|
|
449
|
+
...binders.nodes
|
|
450
|
+
.filter((node) => binderSearchText(node).includes(query))
|
|
451
|
+
.map((node): TaskPanelEntry => ({ type: "binder", node })),
|
|
452
|
+
...allHierarchy
|
|
453
|
+
.filter(({ task }) => artifactSearchText(task, binders).includes(query))
|
|
454
|
+
.map((entry): TaskPanelEntry => ({ type: "task", entry: { ...entry, depth: 0 } })),
|
|
455
|
+
].sort((left, right) => {
|
|
456
|
+
const leftPath = left.type === "binder" ? left.node.path : artifactBinderPath(left.entry.task, binders);
|
|
457
|
+
const rightPath = right.type === "binder" ? right.node.path : artifactBinderPath(right.entry.task, binders);
|
|
458
|
+
return leftPath.localeCompare(rightPath);
|
|
459
|
+
})
|
|
460
|
+
: currentEntries();
|
|
360
461
|
selectedIndex = 0;
|
|
361
462
|
}
|
|
362
463
|
|
|
363
464
|
function statusLine(): string {
|
|
364
465
|
const counts: Record<string, number> = {};
|
|
365
|
-
for (const entry of
|
|
366
|
-
const parts =
|
|
466
|
+
for (const entry of allHierarchy) counts[entry.task.status] = (counts[entry.task.status] ?? 0) + 1;
|
|
467
|
+
const parts = allHierarchy.some((entry) => entry.active) ? ["▶ 1 active"] : [];
|
|
367
468
|
for (const status of ["todo", "in-progress", "review", "rejected", "done", "canceled"] as TaskStatus[]) {
|
|
368
469
|
if ((counts[status] ?? 0) > 0) {
|
|
369
470
|
const presentation = TASK_STATUS_PRESENTATION[status];
|
|
@@ -376,22 +477,21 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
376
477
|
const header = {
|
|
377
478
|
invalidate() {},
|
|
378
479
|
render(width: number): string[] {
|
|
379
|
-
const title = theme.bold(`Tasks · ${graph.scope?.label ?? "scope unavailable"}`);
|
|
480
|
+
const title = theme.bold(`Tasks · ${graph.scope?.label ?? "scope unavailable"} · ${currentBinderPath(binders, currentBinderId)}`);
|
|
380
481
|
const hint = searchActive
|
|
381
482
|
? rawKeyHint("esc", "clear")
|
|
382
|
-
:
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
theme.fg("muted", " · ")
|
|
394
|
-
rawKeyHint("esc", "close");
|
|
483
|
+
: [
|
|
484
|
+
rawKeyHint("↑/↓", "navigate"),
|
|
485
|
+
rawKeyHint("enter", "open/actions"),
|
|
486
|
+
rawKeyHint("a", "actions"),
|
|
487
|
+
rawKeyHint("←", "up"),
|
|
488
|
+
rawKeyHint("n", "new Binder"),
|
|
489
|
+
rawKeyHint("/", "filter"),
|
|
490
|
+
rawKeyHint("g", "graph"),
|
|
491
|
+
rawKeyHint("s", "scope"),
|
|
492
|
+
rawKeyHint("r", "refresh"),
|
|
493
|
+
rawKeyHint("esc", "close"),
|
|
494
|
+
].join(theme.fg("muted", " · "));
|
|
395
495
|
const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
|
|
396
496
|
const line1 = truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, "");
|
|
397
497
|
const line2 = truncateToWidth(theme.fg("muted", statusLine()), width, "");
|
|
@@ -406,16 +506,35 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
406
506
|
if (searchActive) lines.push(...searchInput.render(width));
|
|
407
507
|
lines.push("");
|
|
408
508
|
if (filtered.length === 0) {
|
|
409
|
-
lines.push(theme.fg("muted", " No tasks"));
|
|
509
|
+
lines.push(theme.fg("muted", " No tasks or Binders here"));
|
|
410
510
|
return lines;
|
|
411
511
|
}
|
|
412
512
|
const start = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), filtered.length - maxVisible));
|
|
413
513
|
const end = Math.min(start + maxVisible, filtered.length);
|
|
414
514
|
for (let i = start; i < end; i++) {
|
|
415
|
-
const
|
|
416
|
-
const row = entry.task;
|
|
515
|
+
const panelEntry = filtered[i]!;
|
|
417
516
|
const selected = i === selectedIndex;
|
|
418
517
|
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
518
|
+
if (panelEntry.type === "binder") {
|
|
519
|
+
const title = selected ? theme.bold(panelEntry.node.binder.title) : panelEntry.node.binder.title;
|
|
520
|
+
const metadata = [
|
|
521
|
+
panelEntry.node.childIds.length > 0
|
|
522
|
+
? `${panelEntry.node.childIds.length} Binder${panelEntry.node.childIds.length === 1 ? "" : "s"}`
|
|
523
|
+
: "",
|
|
524
|
+
panelEntry.node.effectiveLabels.length > 0 ? panelEntry.node.effectiveLabels.join(", ") : "",
|
|
525
|
+
searchActive ? panelEntry.node.path : "",
|
|
526
|
+
].filter(Boolean);
|
|
527
|
+
lines.push(
|
|
528
|
+
truncateToWidth(
|
|
529
|
+
`${cursor} ${theme.fg("accent", "▸")} ${title}${metadata.length ? theme.fg("dim", ` · ${metadata.join(" · ")}`) : ""}`,
|
|
530
|
+
width,
|
|
531
|
+
"",
|
|
532
|
+
),
|
|
533
|
+
);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const entry = panelEntry.entry;
|
|
537
|
+
const row = entry.task;
|
|
419
538
|
const focus = entry.active ? theme.fg("accent", "▶") : " ";
|
|
420
539
|
const execution = executionById.get(row.id);
|
|
421
540
|
const state = execution?.state ?? row.status;
|
|
@@ -429,8 +548,10 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
429
548
|
const title = selected ? theme.bold(row.title) : row.title;
|
|
430
549
|
let laterSibling = false;
|
|
431
550
|
for (let candidate = i + 1; candidate < filtered.length; candidate++) {
|
|
432
|
-
|
|
433
|
-
if (
|
|
551
|
+
const candidateEntry = filtered[candidate];
|
|
552
|
+
if (candidateEntry?.type !== "task") continue;
|
|
553
|
+
if (candidateEntry.entry.depth < entry.depth) break;
|
|
554
|
+
if (candidateEntry.entry.depth === entry.depth) {
|
|
434
555
|
laterSibling = true;
|
|
435
556
|
break;
|
|
436
557
|
}
|
|
@@ -441,7 +562,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
441
562
|
hasLaterSibling: laterSibling,
|
|
442
563
|
});
|
|
443
564
|
const node = entry.depth === 0 && entry.childCount > 0 ? theme.fg("accent", connector) : theme.fg("dim", connector);
|
|
444
|
-
const gates = (row.extra?.gates
|
|
565
|
+
const gates = Array.isArray(row.extra?.gates) ? row.extra.gates.length : 0;
|
|
445
566
|
const relationParts: string[] = [];
|
|
446
567
|
if (execution) relationParts.push(execution.layer === null ? state : `layer ${execution.layer + 1} · ${state}`);
|
|
447
568
|
if (entry.childCount > 0) relationParts.push(`${entry.childCount} subtask${entry.childCount === 1 ? "" : "s"}`);
|
|
@@ -449,12 +570,18 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
449
570
|
const names = entry.dependencies.map((id) => taskById.get(id)?.title ?? id);
|
|
450
571
|
relationParts.push(`needs ${names.join(", ")}`);
|
|
451
572
|
}
|
|
452
|
-
if (gates) relationParts.push(`${gates} gate${gates === 1 ? "" : "s"}`);
|
|
573
|
+
if (gates > 0) relationParts.push(`${gates} gate${gates === 1 ? "" : "s"}`);
|
|
574
|
+
if (row.labels.length > 0) relationParts.push(row.labels.join(", "));
|
|
575
|
+
const inheritedLabels = inheritedLabelsFor(row.id, binders);
|
|
576
|
+
if (inheritedLabels.length > 0) relationParts.push(`inherits ${inheritedLabels.join(", ")}`);
|
|
577
|
+
if (searchActive) relationParts.push(artifactBinderPath(row, binders));
|
|
453
578
|
const relationText = relationParts.length > 0 ? theme.fg("dim", ` · ${relationParts.join(" · ")}`) : "";
|
|
454
579
|
lines.push(truncateToWidth(`${cursor}${focus} ${node} ${glyphStyled} ${title}${relationText}`, width, ""));
|
|
455
580
|
}
|
|
456
581
|
const hasScroll = start > 0 || end < filtered.length;
|
|
457
|
-
lines.push(
|
|
582
|
+
lines.push(
|
|
583
|
+
theme.fg("muted", ` ${hasScroll ? `${selectedIndex + 1}/${filtered.length} · ` : ""}↑/↓ navigate · Enter open/actions`),
|
|
584
|
+
);
|
|
458
585
|
return lines;
|
|
459
586
|
},
|
|
460
587
|
};
|
|
@@ -489,7 +616,14 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
489
616
|
if (matchesKey(data, "up")) selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(filtered.length, 1);
|
|
490
617
|
else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
|
|
491
618
|
else if (data === "/") searchActive = true;
|
|
492
|
-
else if (data === "
|
|
619
|
+
else if (data === "n") {
|
|
620
|
+
done({ type: "create-binder" });
|
|
621
|
+
return;
|
|
622
|
+
} else if (matchesKey(data, "left") || data === "\x7f") {
|
|
623
|
+
const parentId = currentBinderId ? binders.nodes.find((node) => node.binder.id === currentBinderId)?.parentId : undefined;
|
|
624
|
+
done({ type: "navigate", ...(parentId ? { binderId: parentId } : {}) });
|
|
625
|
+
return;
|
|
626
|
+
} else if (data === "g") {
|
|
493
627
|
done({ type: "graph" });
|
|
494
628
|
return;
|
|
495
629
|
} else if (data === "s") {
|
|
@@ -498,9 +632,15 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
498
632
|
} else if (data === "r") {
|
|
499
633
|
done({ type: "refresh" });
|
|
500
634
|
return;
|
|
635
|
+
} else if (data === "a") {
|
|
636
|
+
const panelEntry = filtered[selectedIndex];
|
|
637
|
+
if (panelEntry?.type === "binder") done({ type: "binder-action", binder: panelEntry.node });
|
|
638
|
+
else if (panelEntry?.type === "task") done({ type: "action", row: panelEntry.entry.task });
|
|
639
|
+
return;
|
|
501
640
|
} else if (matchesKey(data, "enter")) {
|
|
502
|
-
const
|
|
503
|
-
if (
|
|
641
|
+
const panelEntry = filtered[selectedIndex];
|
|
642
|
+
if (panelEntry?.type === "binder") done({ type: "navigate", binderId: panelEntry.node.binder.id });
|
|
643
|
+
else if (panelEntry?.type === "task") done({ type: "action", row: panelEntry.entry.task });
|
|
504
644
|
return;
|
|
505
645
|
} else if (matchesKey(data, "escape")) {
|
|
506
646
|
done(undefined);
|
|
@@ -39,7 +39,7 @@ import { emitTaskFocusEvent } from "../task/task-focus-events.ts";
|
|
|
39
39
|
import { recordRenderDiagnostic, shapeFingerprint } from "./render-diagnostics.ts";
|
|
40
40
|
import { papyrusVehiclePresentations, papyrusVehicleRenderers } from "./vehicle-artifact-renderers.ts";
|
|
41
41
|
|
|
42
|
-
const
|
|
42
|
+
export const PAPYRUS_VEHICLE_PERMISSIONS = [
|
|
43
43
|
"notes:read",
|
|
44
44
|
"notes:write",
|
|
45
45
|
"rules:read",
|
|
@@ -54,7 +54,9 @@ const REGISTERED_PERMISSIONS = [
|
|
|
54
54
|
"discuss:write",
|
|
55
55
|
"artifact:read",
|
|
56
56
|
"artifact:write",
|
|
57
|
-
|
|
57
|
+
"binders:read",
|
|
58
|
+
"binders:write",
|
|
59
|
+
] as const;
|
|
58
60
|
|
|
59
61
|
/** Task Focus's own internal write needs a real, per-session secret -- see below. Every other tasks.* operation reads session_id purely for read-scoping and needs no secret. */
|
|
60
62
|
const FOCUS_MUTATION_OPERATIONS = new Set(["tasks.focus", "tasks.pause", "tasks.unpause", "tasks.clear_focus"]);
|
|
@@ -148,7 +150,7 @@ export function registerNotesVehicle(pi: ExtensionAPI): Promise<RegisteredPiVehi
|
|
|
148
150
|
recordRenderDiagnostic({ event: "vehicle-ready", kind: event.kind, ...("attempt" in event ? { attempt: event.attempt } : {}) });
|
|
149
151
|
notifyReadyEvent(event);
|
|
150
152
|
},
|
|
151
|
-
permissions:
|
|
153
|
+
permissions: PAPYRUS_VEHICLE_PERMISSIONS,
|
|
152
154
|
principal: { id: "pi-papyrus" },
|
|
153
155
|
renderers: papyrusVehicleRenderers,
|
|
154
156
|
// papyrusVehiclePresentations projects a bounded, versioned PapyrusToolDetails DTO
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-papyrus",
|
|
3
|
-
"version": "0.57.
|
|
3
|
+
"version": "0.57.5",
|
|
4
4
|
"description": "Pi host extension for Papyrus: native tools, TUI panels, and context injection over the daemon-backed graph store",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["pi-package"],
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@danypops/jittor": "^0.19.2",
|
|
22
|
-
"@danypops/papyrus": "^0.60.
|
|
22
|
+
"@danypops/papyrus": "^0.60.2",
|
|
23
23
|
"@danypops/vehicle-client": "^0.10.3",
|
|
24
24
|
"@danypops/vehicle-core": "^0.18.5",
|
|
25
25
|
"@danypops/vehicle-server": "^0.25.2",
|