@danypops/pi-papyrus 0.57.2 → 0.57.4

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.
@@ -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 loadArtifacts(config);
118
- if (rows.length === 0) {
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
- const selected = await renderPanel(ctx, rows, config);
125
- if (selected === undefined) return;
126
- if (selected === "refresh") {
127
- rows = await loadArtifacts(config);
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
- const choices = config.actions(selected);
131
- const choice = await ctx.ui.select(selected.title, choices);
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 config.handleAction(choice, selected, ctx);
134
- rows = await loadArtifacts(config);
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
- ): Promise<Artifact | "refresh" | undefined> {
143
- return ctx.ui.custom<Artifact | "refresh" | undefined>((tui, theme, _keybindings, done) => {
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 = [...rows];
245
+ let filtered = browserEntries(rows, tree, currentBinderId, "");
147
246
  let selectedIndex = 0;
148
247
 
149
248
  function applyFilter(): void {
150
- filtered = filterArtifactRows(rows, input.getValue());
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 title = theme.bold(config.title);
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
- : [rawKeyHint("enter", "actions"), rawKeyHint("/", "filter"), rawKeyHint("r", "refresh"), rawKeyHint("esc", "close")].join(
161
- theme.fg("muted", " · "),
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 row = filtered[index]!;
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 meta = config.rowMeta(row, theme);
193
- lines.push(truncateToWidth(`${cursor} ${glyph} ${title}${meta ? `${theme.fg("dim", " · ")}${meta}` : ""}`, width, ""));
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} ${config.kind}`));
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 === "\x1b") {
348
+ if (matchesKey(data, "escape")) {
216
349
  searchActive = false;
217
350
  applyFilter();
218
- } else if (data === "\r") searchActive = false;
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
- switch (data) {
227
- case "\x1b[A":
228
- selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(filtered.length, 1);
229
- break;
230
- case "\x1b[B":
231
- selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
232
- break;
233
- case "/":
234
- searchActive = true;
235
- break;
236
- case "r":
237
- done("refresh");
238
- return;
239
- case "\r": {
240
- const row = filtered[selectedIndex];
241
- if (row) done(row);
242
- return;
243
- }
244
- case "\x1b":
245
- done(undefined);
246
- return;
247
- default:
248
- return;
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 title = await commandCtx.ui.input("Title:", document.title);
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:", document.body);
39
+ const body = await commandCtx.ui.input("Body:", current.body);
37
40
  if (body === undefined) return; // canceled
38
- const updated = await callService<Record<string, unknown>, Artifact>("docs.update", { id: document.id, title, body });
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.trigger === "string" ? `when ${playbook.extra.trigger}` : "manual invocation";
74
- const tools = strings(playbook.extra.tools);
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 title = await commandCtx.ui.input("Title:", playbook.title);
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:", playbook.body);
98
+ const body = await commandCtx.ui.input("Body:", current.body);
96
99
  if (body === undefined) return; // canceled
97
- const updated = await callService<Record<string, unknown>, Artifact>("playbooks.update", { id: playbook.id, title, body });
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.severity === "string" ? rule.extra.severity : "info";
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.condition === "string" ? `when ${rule.extra.condition}` : "always";
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 title = await commandCtx.ui.input("Title:", rule.title);
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:", rule.body);
37
+ const body = await commandCtx.ui.input("Body:", current.body);
35
38
  if (body === undefined) return; // canceled
36
- const updated = await callService<Record<string, unknown>, Artifact>("rules.update", { id: rule.id, title, body });
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(graph.nodes.map((node) => [node.task.id, node]));
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) visit(node.task.id, 0);
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
- if (graph.nodes.length === 0) {
103
- const create = await ctx.ui.select("No tasks yet", ["Create a task", "Cancel"]);
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
- graph = await loadTaskGraph(ctx.cwd, sessionId);
143
+ await refresh();
109
144
  }
110
- }
111
- if (graph.nodes.length === 0) return;
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
- graph = await loadTaskGraph(ctx.cwd, sessionId);
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
- graph = await loadTaskGraph(ctx.cwd, sessionId);
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
- graph = await loadTaskGraph(ctx.cwd, sessionId);
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
- graph = await loadTaskGraph(ctx.cwd, sessionId);
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(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<PanelAction | undefined> {
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 hierarchy = buildTaskHierarchy(graph);
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 = [...hierarchy];
441
+ let filtered = currentEntries();
352
442
  let selectedIndex = 0;
353
443
  const maxVisible = 20;
354
444
 
355
445
  function applyFilter(): void {
356
- const q = searchInput.getValue().trim().toLowerCase();
357
- filtered = q
358
- ? hierarchy.filter(({ task }) => task.title.toLowerCase().includes(q) || task.id.toLowerCase().includes(q))
359
- : [...hierarchy];
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 hierarchy) counts[entry.task.status] = (counts[entry.task.status] ?? 0) + 1;
366
- const parts = hierarchy.some((entry) => entry.active) ? ["▶ 1 active"] : [];
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
- : rawKeyHint("↑/↓", "navigate") +
383
- theme.fg("muted", " · ") +
384
- rawKeyHint("enter", "actions") +
385
- theme.fg("muted", " · ") +
386
- rawKeyHint("/", "filter") +
387
- theme.fg("muted", " · ") +
388
- rawKeyHint("g", "graph") +
389
- theme.fg("muted", " · ") +
390
- rawKeyHint("s", "scope") +
391
- theme.fg("muted", " · ") +
392
- rawKeyHint("r", "refresh") +
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 entry = filtered[i]!;
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
- if (filtered[candidate]!.depth < entry.depth) break;
433
- if (filtered[candidate]!.depth === entry.depth) {
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 as any[])?.length;
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(theme.fg("muted", ` ${hasScroll ? `${selectedIndex + 1}/${filtered.length} · ` : ""}↑/↓ navigate · Enter actions`));
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 === "g") {
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 entry = filtered[selectedIndex];
503
- if (entry) done({ type: "action", row: entry.task });
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.57.2",
3
+ "version": "0.57.4",
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"],
@@ -12,23 +12,23 @@
12
12
  "typecheck": "tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "peerDependencies": {
15
- "@danypops/vehicle-client-pi": "^0.45.0",
15
+ "@danypops/vehicle-client-pi": "^0.45.1",
16
16
  "@earendil-works/pi-coding-agent": "*",
17
17
  "@earendil-works/pi-tui": "*",
18
18
  "typebox": "*"
19
19
  },
20
20
  "dependencies": {
21
21
  "@danypops/jittor": "^0.19.2",
22
- "@danypops/papyrus": "^0.60.0",
22
+ "@danypops/papyrus": "^0.60.2",
23
23
  "@danypops/vehicle-client": "^0.10.3",
24
- "@danypops/vehicle-core": "^0.17.1",
24
+ "@danypops/vehicle-core": "^0.18.5",
25
25
  "@danypops/vehicle-server": "^0.25.2",
26
26
  "beautiful-mermaid": "1.1.3",
27
27
  "malevich-tui-components": "^0.32.1"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@danypops/pi-tui-harness": "^0.0.2",
31
- "@danypops/vehicle-client-pi": "^0.45.0",
31
+ "@danypops/vehicle-client-pi": "^0.45.1",
32
32
  "@danypops/vehicle-conformance": "^0.3.0",
33
33
  "@earendil-works/pi-coding-agent": "^0.80.10",
34
34
  "bun-types": "latest",