@danypops/pi-papyrus 0.59.6 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,19 @@
1
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, matchesKey, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import {
5
+ type Component,
6
+ Container,
7
+ type Focusable,
8
+ Input,
9
+ matchesKey,
10
+ Spacer,
11
+ truncateToWidth,
12
+ visibleWidth,
13
+ } from "@earendil-works/pi-tui";
5
14
  import { callService } from "../service-client.ts";
6
15
  import { showArtifactDetailView } from "./artifact-detail-view.ts";
16
+ import { ArtifactNavigationState } from "./artifact-navigation-state.ts";
7
17
  import type { StatusPresentation } from "./artifact-status-presentation.ts";
8
18
  import {
9
19
  artifactBinderPath,
@@ -249,15 +259,15 @@ function renderPanel(
249
259
  tree: BinderTree | undefined,
250
260
  currentBinderId: string | undefined,
251
261
  ): Promise<BrowserPanelAction | undefined> {
252
- return ctx.ui.custom<BrowserPanelAction | undefined>((tui, theme, _keybindings, done) => {
262
+ return ctx.ui.custom<BrowserPanelAction | undefined>((tui, theme, keybindings, done) => {
253
263
  const input = new Input();
254
- let searchActive = false;
255
264
  let filtered = browserEntries(rows, tree, currentBinderId, "");
256
- let selectedIndex = 0;
265
+ const navigation = new ArtifactNavigationState(filtered.length);
257
266
 
258
267
  function applyFilter(): void {
259
- filtered = browserEntries(rows, tree, currentBinderId, input.getValue());
260
- selectedIndex = 0;
268
+ filtered = browserEntries(rows, tree, currentBinderId, navigation.query);
269
+ navigation.setItemCount(filtered.length);
270
+ navigation.first();
261
271
  }
262
272
 
263
273
  const header = {
@@ -265,15 +275,18 @@ function renderPanel(
265
275
  render(width: number): string[] {
266
276
  const path = tree ? ` · ${currentBinderPath(tree, currentBinderId)}` : "";
267
277
  const title = theme.bold(`${config.title}${path}`);
268
- const hint = searchActive
269
- ? rawKeyHint("esc", "clear")
270
- : [
271
- rawKeyHint("enter", "open/actions"),
272
- ...(tree ? [rawKeyHint("a", "actions"), rawKeyHint("←", "up"), rawKeyHint("n", "new Binder")] : []),
273
- rawKeyHint("/", "filter"),
274
- rawKeyHint("r", "refresh"),
275
- rawKeyHint("esc", "close"),
276
- ].join(theme.fg("muted", " · "));
278
+ const hint =
279
+ navigation.mode === "filter"
280
+ ? `${theme.fg("accent", "FILTER")} · ${rawKeyHint("enter", "keep")} · ${rawKeyHint("esc", "clear")}`
281
+ : [
282
+ theme.fg("accent", "NORMAL"),
283
+ rawKeyHint("j/k", "move"),
284
+ rawKeyHint("enter/l", "open/actions"),
285
+ ...(tree ? [rawKeyHint("a", "actions"), rawKeyHint("h", "up"), rawKeyHint("n", "new Binder")] : []),
286
+ rawKeyHint("/", "filter"),
287
+ rawKeyHint("f", navigation.expanded ? "compact" : "expand"),
288
+ rawKeyHint("q", "close"),
289
+ ].join(theme.fg("muted", " · "));
277
290
  const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
278
291
  const summary = statusSummary(rows, config.statusOrder)
279
292
  .map(({ status, count }) => {
@@ -293,20 +306,21 @@ function renderPanel(
293
306
  const list = {
294
307
  invalidate() {},
295
308
  render(width: number): string[] {
296
- const lines = searchActive ? [...input.render(width), ""] : [""];
309
+ const lines = navigation.mode === "filter" ? [...input.render(width), ""] : [""];
297
310
  if (filtered.length === 0) return [...lines, theme.fg("muted", ` No ${tree ? "items" : `matching ${config.kind}s`}`)];
298
- const start = Math.max(0, Math.min(selectedIndex - Math.floor(BROWSER_VISIBLE_ROWS / 2), filtered.length - BROWSER_VISIBLE_ROWS));
299
- const end = Math.min(start + BROWSER_VISIBLE_ROWS, filtered.length);
311
+ const visibleRows = navigation.expanded ? Math.max(BROWSER_VISIBLE_ROWS, tui.terminal.rows - 10) : BROWSER_VISIBLE_ROWS;
312
+ const start = Math.max(0, Math.min(navigation.selectedIndex - Math.floor(visibleRows / 2), filtered.length - visibleRows));
313
+ const end = Math.min(start + visibleRows, filtered.length);
300
314
  for (let index = start; index < end; index++) {
301
315
  const entry = filtered[index]!;
302
- const selected = index === selectedIndex;
316
+ const selected = index === navigation.selectedIndex;
303
317
  const cursor = selected ? theme.fg("accent", "❯") : " ";
304
318
  if (entry.type === "binder") {
305
319
  const title = selected ? theme.bold(entry.node.binder.title) : entry.node.binder.title;
306
320
  const details = [
307
321
  entry.node.childIds.length > 0 ? `${entry.node.childIds.length} Binder${entry.node.childIds.length === 1 ? "" : "s"}` : "",
308
322
  entry.node.effectiveLabels.length > 0 ? entry.node.effectiveLabels.join(", ") : "",
309
- searchActive ? entry.node.path : "",
323
+ navigation.mode === "filter" ? entry.node.path : "",
310
324
  ].filter(Boolean);
311
325
  lines.push(
312
326
  truncateToWidth(
@@ -325,7 +339,7 @@ function renderPanel(
325
339
  const details = [
326
340
  config.rowMeta(row, theme),
327
341
  inherited.length > 0 ? `inherits ${inherited.join(", ")}` : "",
328
- tree && searchActive ? artifactBinderPath(row, tree) : "",
342
+ tree && navigation.mode === "filter" ? artifactBinderPath(row, tree) : "",
329
343
  ].filter(Boolean);
330
344
  lines.push(
331
345
  truncateToWidth(
@@ -335,7 +349,7 @@ function renderPanel(
335
349
  ),
336
350
  );
337
351
  }
338
- lines.push(theme.fg("muted", ` ${selectedIndex + 1}/${filtered.length} item${filtered.length === 1 ? "" : "s"}`));
352
+ lines.push(theme.fg("muted", ` ${navigation.selectedIndex + 1}/${filtered.length} item${filtered.length === 1 ? "" : "s"}`));
339
353
  return lines;
340
354
  },
341
355
  };
@@ -350,29 +364,73 @@ function renderPanel(
350
364
  container.addChild(new Spacer(1));
351
365
  container.addChild(new DynamicBorder());
352
366
 
367
+ let focused = false;
353
368
  return {
369
+ get focused(): boolean {
370
+ return focused;
371
+ },
372
+ set focused(value: boolean) {
373
+ focused = value;
374
+ input.focused = value && navigation.mode === "filter";
375
+ },
354
376
  render: (width: number) => container.render(width),
355
377
  invalidate: () => container.invalidate(),
356
378
  handleInput(data: string) {
357
- if (searchActive) {
379
+ if (navigation.mode === "filter") {
358
380
  if (matchesKey(data, "escape")) {
359
- searchActive = false;
381
+ navigation.leaveFilter(true);
382
+ input.setValue(navigation.query);
383
+ input.focused = false;
360
384
  applyFilter();
361
- } else if (matchesKey(data, "enter")) searchActive = false;
362
- else {
385
+ } else if (matchesKey(data, "enter")) {
386
+ navigation.leaveFilter();
387
+ input.focused = false;
388
+ } else {
363
389
  input.handleInput(data);
390
+ navigation.setQuery(input.getValue());
364
391
  applyFilter();
365
392
  }
366
393
  tui.requestRender();
367
394
  return;
368
395
  }
369
- if (matchesKey(data, "up")) selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(filtered.length, 1);
370
- else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
371
- else if (data === "/") searchActive = true;
396
+
397
+ const selectedEntry = () => filtered[navigation.selectedIndex];
398
+ const openSelected = (): void => {
399
+ const entry = selectedEntry();
400
+ if (entry?.type === "binder") done({ type: "navigate", binderId: entry.node.binder.id });
401
+ else if (entry?.type === "artifact") done({ type: "artifact", row: entry.row });
402
+ };
403
+ if (keybindings.matches?.(data, "tui.select.up") === true || matchesKey(data, "up") || data === "k") navigation.move(-1);
404
+ else if (keybindings.matches?.(data, "tui.select.down") === true || matchesKey(data, "down") || data === "j") navigation.move(1);
405
+ else if (keybindings.matches?.(data, "tui.select.pageUp") === true || matchesKey(data, "pageUp") || matchesKey(data, "ctrl+u"))
406
+ navigation.movePage(
407
+ -1,
408
+ Math.max(
409
+ 1,
410
+ Math.floor((navigation.expanded ? Math.max(BROWSER_VISIBLE_ROWS, tui.terminal.rows - 10) : BROWSER_VISIBLE_ROWS) / 2),
411
+ ),
412
+ );
413
+ else if (keybindings.matches?.(data, "tui.select.pageDown") === true || matchesKey(data, "pageDown") || matchesKey(data, "ctrl+d"))
414
+ navigation.movePage(
415
+ 1,
416
+ Math.max(
417
+ 1,
418
+ Math.floor((navigation.expanded ? Math.max(BROWSER_VISIBLE_ROWS, tui.terminal.rows - 10) : BROWSER_VISIBLE_ROWS) / 2),
419
+ ),
420
+ );
421
+ else if (data === "g") navigation.first();
422
+ else if (data === "G") navigation.last();
423
+ else if (data === "/") {
424
+ navigation.enterFilter();
425
+ input.focused = focused;
426
+ } else if (data === "f") navigation.toggleExpanded();
372
427
  else if (tree && data === "n") {
373
428
  done({ type: "create-binder" });
374
429
  return;
375
- } else if (tree && (matchesKey(data, "left") || data === "\x7f")) {
430
+ } else if (
431
+ tree &&
432
+ (keybindings.matches?.(data, "tui.editor.cursorLeft") === true || matchesKey(data, "left") || data === "h" || data === "\x7f")
433
+ ) {
376
434
  const parentId = currentBinderId ? tree.nodes.find((node) => node.binder.id === currentBinderId)?.parentId : undefined;
377
435
  done({ type: "navigate", ...(parentId ? { binderId: parentId } : {}) });
378
436
  return;
@@ -380,21 +438,24 @@ function renderPanel(
380
438
  done({ type: "refresh" });
381
439
  return;
382
440
  } else if (tree && data === "a") {
383
- const entry = filtered[selectedIndex];
441
+ const entry = selectedEntry();
384
442
  if (entry?.type === "binder") done({ type: "binder-action", node: entry.node });
385
443
  else if (entry?.type === "artifact") done({ type: "artifact", row: entry.row });
386
444
  return;
387
- } else if (matchesKey(data, "enter")) {
388
- const entry = filtered[selectedIndex];
389
- if (entry?.type === "binder") done({ type: "navigate", binderId: entry.node.binder.id });
390
- else if (entry?.type === "artifact") done({ type: "artifact", row: entry.row });
445
+ } else if (keybindings.matches?.(data, "tui.select.confirm") === true || matchesKey(data, "enter") || data === "l") {
446
+ openSelected();
391
447
  return;
392
- } else if (matchesKey(data, "escape")) {
448
+ } else if (
449
+ keybindings.matches?.(data, "tui.select.cancel") === true ||
450
+ matchesKey(data, "escape") ||
451
+ matchesKey(data, "ctrl+c") ||
452
+ data === "q"
453
+ ) {
393
454
  done(undefined);
394
455
  return;
395
456
  } else return;
396
457
  tui.requestRender();
397
458
  },
398
- };
459
+ } satisfies Component & Focusable;
399
460
  });
400
461
  }
@@ -24,11 +24,14 @@ interface ArtifactDetailLine {
24
24
 
25
25
  class ArtifactDetailViewport {
26
26
  private offsetX = 0;
27
- private offsetY = 0;
27
+ private compactOffsetY = 0;
28
+ private expandedOffsetY = 0;
29
+ private expandedOffsetInitialized = false;
28
30
  private renderedWidth = 0;
29
31
  private lines: ArtifactDetailLine[] = [];
30
- private readonly visibleLines: number;
32
+ private readonly compactVisibleLines: number;
31
33
  private readonly content: ArtifactDetailContent;
34
+ private expanded = false;
32
35
 
33
36
  constructor(
34
37
  private readonly tui: TUI,
@@ -36,8 +39,9 @@ class ArtifactDetailViewport {
36
39
  artifact: Artifact,
37
40
  relationshipLines: string[],
38
41
  private readonly close: () => void,
42
+ private readonly matchesBinding: (data: string, binding: "up" | "down" | "pageUp" | "pageDown" | "cancel") => boolean,
39
43
  ) {
40
- this.visibleLines = Math.max(
44
+ this.compactVisibleLines = Math.max(
41
45
  ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
42
46
  Math.min(ARTIFACT_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - ARTIFACT_DETAIL_RESERVED_ROWS),
43
47
  );
@@ -53,14 +57,16 @@ class ArtifactDetailViewport {
53
57
  this.buildLines(contentWidth);
54
58
  const wideWidth = this.content.relationships.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0);
55
59
  this.offsetX = Math.min(this.offsetX, Math.max(0, wideWidth - contentWidth));
56
- this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
57
- const end = Math.min(this.lines.length, this.offsetY + this.visibleLines);
60
+ const visibleLines = this.visibleLineCount();
61
+ const offsetY = Math.min(this.activeOffsetY(), Math.max(0, this.lines.length - visibleLines));
62
+ const end = Math.min(this.lines.length, offsetY + visibleLines);
58
63
  const theme = this.activeTheme();
59
64
  const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
60
65
  const footer = [
61
66
  wideWidth > contentWidth ? `←/→ relationships · column ${this.offsetX + 1}/${wideWidth}` : "",
62
- this.lines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.lines.length}` : "",
63
- "Esc back",
67
+ this.lines.length > visibleLines ? `j/k scroll · ${offsetY + 1}-${end}/${this.lines.length}` : "",
68
+ `f ${this.expanded ? "compact" : "expand"}`,
69
+ "q/Esc back",
64
70
  ]
65
71
  .filter(Boolean)
66
72
  .join(" · ");
@@ -69,7 +75,7 @@ class ArtifactDetailViewport {
69
75
  truncateToWidth(theme.fg("accent", theme.bold("Artifact details")), width, ""),
70
76
  border,
71
77
  ...this.lines
72
- .slice(this.offsetY, end)
78
+ .slice(offsetY, end)
73
79
  .map((line) =>
74
80
  line.wide ? ` ${sliceByColumn(line.text, this.offsetX, contentWidth, true)}` : truncateToWidth(` ${line.text}`, width, ""),
75
81
  ),
@@ -79,18 +85,48 @@ class ArtifactDetailViewport {
79
85
  }
80
86
 
81
87
  handleInput(data: string): void {
82
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
88
+ if (this.matchesBinding(data, "cancel") || data === "q") {
83
89
  this.close();
84
90
  return;
85
91
  }
86
- if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
87
- else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + 1);
88
- else if (matchesKey(data, "left")) this.offsetX = Math.max(0, this.offsetX - ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS);
89
- else if (matchesKey(data, "right")) this.offsetX += ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS;
90
- else return;
92
+ const visibleLines = this.visibleLineCount();
93
+ const offsetY = Math.min(this.activeOffsetY(), Math.max(0, this.lines.length - visibleLines));
94
+ if (this.matchesBinding(data, "up") || data === "k") this.setActiveOffsetY(Math.max(0, offsetY - 1));
95
+ else if (this.matchesBinding(data, "down") || data === "j")
96
+ this.setActiveOffsetY(Math.min(Math.max(0, this.lines.length - visibleLines), offsetY + 1));
97
+ else if (this.matchesBinding(data, "pageUp") || matchesKey(data, "ctrl+u"))
98
+ this.setActiveOffsetY(Math.max(0, offsetY - Math.max(1, Math.floor(visibleLines / 2))));
99
+ else if (this.matchesBinding(data, "pageDown") || matchesKey(data, "ctrl+d"))
100
+ this.setActiveOffsetY(Math.min(Math.max(0, this.lines.length - visibleLines), offsetY + Math.max(1, Math.floor(visibleLines / 2))));
101
+ else if (matchesKey(data, "left") || data === "h") this.offsetX = Math.max(0, this.offsetX - ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS);
102
+ else if (matchesKey(data, "right") || data === "l") this.offsetX += ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS;
103
+ else if (data === "g") this.setActiveOffsetY(0);
104
+ else if (data === "G") this.setActiveOffsetY(Math.max(0, this.lines.length - visibleLines));
105
+ else if (data === "f") {
106
+ if (!this.expanded && !this.expandedOffsetInitialized) {
107
+ this.expandedOffsetY = this.compactOffsetY;
108
+ this.expandedOffsetInitialized = true;
109
+ }
110
+ this.expanded = !this.expanded;
111
+ } else return;
91
112
  this.tui.requestRender();
92
113
  }
93
114
 
115
+ private activeOffsetY(): number {
116
+ return this.expanded ? this.expandedOffsetY : this.compactOffsetY;
117
+ }
118
+
119
+ private setActiveOffsetY(offsetY: number): void {
120
+ if (this.expanded) this.expandedOffsetY = offsetY;
121
+ else this.compactOffsetY = offsetY;
122
+ }
123
+
124
+ private visibleLineCount(): number {
125
+ return this.expanded
126
+ ? Math.max(this.compactVisibleLines, this.tui.terminal.rows - ARTIFACT_DETAIL_RESERVED_ROWS)
127
+ : this.compactVisibleLines;
128
+ }
129
+
94
130
  private buildLines(width: number): void {
95
131
  if (this.renderedWidth === width) return;
96
132
  this.renderedWidth = width;
@@ -137,7 +173,6 @@ class ArtifactDetailViewport {
137
173
  ]
138
174
  : [];
139
175
  this.lines = [...identity, ...body, ...labelsAndMetadataWithLeadingBlank, ...relationships];
140
- this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
141
176
  }
142
177
  }
143
178
 
@@ -153,6 +188,20 @@ export async function showArtifactDetailView(
153
188
  return;
154
189
  }
155
190
  await ctx.ui.custom<void>(
156
- (tui, theme, _keybindings, done) => new ArtifactDetailViewport(tui, () => ctx.ui.theme ?? theme, artifact, relationshipLines, done),
191
+ (tui, theme, keybindings, done) =>
192
+ new ArtifactDetailViewport(
193
+ tui,
194
+ () => ctx.ui.theme ?? theme,
195
+ artifact,
196
+ relationshipLines,
197
+ done,
198
+ (data, binding) => {
199
+ if (binding === "up") return keybindings.matches?.(data, "tui.select.up") === true || matchesKey(data, "up");
200
+ if (binding === "down") return keybindings.matches?.(data, "tui.select.down") === true || matchesKey(data, "down");
201
+ if (binding === "pageUp") return keybindings.matches?.(data, "tui.select.pageUp") === true || matchesKey(data, "pageUp");
202
+ if (binding === "pageDown") return keybindings.matches?.(data, "tui.select.pageDown") === true || matchesKey(data, "pageDown");
203
+ return keybindings.matches?.(data, "tui.select.cancel") === true || matchesKey(data, "escape") || matchesKey(data, "ctrl+c");
204
+ },
205
+ ),
157
206
  );
158
207
  }
@@ -0,0 +1,54 @@
1
+ export type ArtifactNavigationMode = "normal" | "filter";
2
+
3
+ /** Owns bounded selection, mode, and viewport-expansion state for artifact UIs. */
4
+ export class ArtifactNavigationState {
5
+ selectedIndex = 0;
6
+ mode: ArtifactNavigationMode = "normal";
7
+ query = "";
8
+ expanded = false;
9
+
10
+ constructor(private itemCount: number) {
11
+ this.setItemCount(itemCount);
12
+ }
13
+
14
+ setItemCount(itemCount: number): void {
15
+ this.itemCount = Math.max(0, itemCount);
16
+ this.selectedIndex = this.itemCount === 0 ? 0 : Math.min(this.selectedIndex, this.itemCount - 1);
17
+ }
18
+
19
+ move(delta: number): void {
20
+ if (this.itemCount === 0) return;
21
+ this.selectedIndex = (this.selectedIndex + delta + this.itemCount) % this.itemCount;
22
+ }
23
+
24
+ movePage(direction: -1 | 1, pageSize: number): void {
25
+ if (this.itemCount === 0) return;
26
+ const distance = Math.max(1, Math.floor(pageSize));
27
+ this.selectedIndex = Math.max(0, Math.min(this.itemCount - 1, this.selectedIndex + direction * distance));
28
+ }
29
+
30
+ first(): void {
31
+ this.selectedIndex = 0;
32
+ }
33
+
34
+ last(): void {
35
+ this.selectedIndex = Math.max(0, this.itemCount - 1);
36
+ }
37
+
38
+ enterFilter(): void {
39
+ this.mode = "filter";
40
+ }
41
+
42
+ setQuery(query: string): void {
43
+ this.query = query;
44
+ }
45
+
46
+ leaveFilter(clearQuery = false): void {
47
+ this.mode = "normal";
48
+ if (clearQuery) this.query = "";
49
+ }
50
+
51
+ toggleExpanded(): void {
52
+ this.expanded = !this.expanded;
53
+ }
54
+ }
@@ -45,10 +45,13 @@ export function discussionRoundCountOf(discussion: Artifact): number {
45
45
  }
46
46
 
47
47
  class DiscussionTranscriptViewport {
48
- private offsetY = 0;
48
+ private compactOffsetY = 0;
49
+ private expandedOffsetY = 0;
50
+ private expandedOffsetInitialized = false;
49
51
  private renderedWidth = 0;
50
52
  private lines: TranscriptLine[] = [];
51
- private readonly visibleLines: number;
53
+ private readonly compactVisibleLines: number;
54
+ private expanded = false;
52
55
 
53
56
  constructor(
54
57
  private readonly tui: TUI,
@@ -56,8 +59,9 @@ class DiscussionTranscriptViewport {
56
59
  private readonly discussion: Artifact,
57
60
  private readonly rounds: DiscussionRound[],
58
61
  private readonly close: () => void,
62
+ private readonly matchesBinding: (data: string, binding: "up" | "down" | "pageUp" | "pageDown" | "cancel") => boolean,
59
63
  ) {
60
- this.visibleLines = Math.max(
64
+ this.compactVisibleLines = Math.max(
61
65
  ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
62
66
  Math.min(ARTIFACT_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - ARTIFACT_DETAIL_RESERVED_ROWS),
63
67
  );
@@ -70,37 +74,69 @@ class DiscussionTranscriptViewport {
70
74
  render(width: number): string[] {
71
75
  const contentWidth = Math.max(1, width - 2);
72
76
  this.buildLines(contentWidth);
73
- this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
74
- const end = Math.min(this.lines.length, this.offsetY + this.visibleLines);
77
+ const visibleLines = this.visibleLineCount();
78
+ const offsetY = Math.min(this.activeOffsetY(), Math.max(0, this.lines.length - visibleLines));
79
+ const end = Math.min(this.lines.length, offsetY + visibleLines);
75
80
  const theme = this.activeTheme();
76
81
  const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
77
- const footer = [this.lines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.lines.length}` : "", "Esc back"]
82
+ const footer = [
83
+ this.lines.length > visibleLines ? `j/k scroll · ${offsetY + 1}-${end}/${this.lines.length}` : "",
84
+ `f ${this.expanded ? "compact" : "expand"}`,
85
+ "q/Esc back",
86
+ ]
78
87
  .filter(Boolean)
79
88
  .join(" · ");
80
89
  return [
81
90
  border,
82
91
  truncateToWidth(theme.fg("accent", theme.bold("Discussion transcript")), width, ""),
83
92
  border,
84
- ...this.lines.slice(this.offsetY, end).map((line) => truncateToWidth(` ${line.text}`, width, "")),
93
+ ...this.lines.slice(offsetY, end).map((line) => truncateToWidth(` ${line.text}`, width, "")),
85
94
  truncateToWidth(theme.fg("dim", footer), width, ""),
86
95
  border,
87
96
  ];
88
97
  }
89
98
 
90
99
  handleInput(data: string): void {
91
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
100
+ if (this.matchesBinding(data, "cancel") || data === "q") {
92
101
  this.close();
93
102
  return;
94
103
  }
95
- if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
96
- else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + 1);
97
- else if (matchesKey(data, "pageDown"))
98
- this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + this.visibleLines);
99
- else if (matchesKey(data, "pageUp")) this.offsetY = Math.max(0, this.offsetY - this.visibleLines);
100
- else return;
104
+ const visibleLines = this.visibleLineCount();
105
+ const offsetY = Math.min(this.activeOffsetY(), Math.max(0, this.lines.length - visibleLines));
106
+ if (this.matchesBinding(data, "up") || data === "k") this.setActiveOffsetY(Math.max(0, offsetY - 1));
107
+ else if (this.matchesBinding(data, "down") || data === "j")
108
+ this.setActiveOffsetY(Math.min(Math.max(0, this.lines.length - visibleLines), offsetY + 1));
109
+ else if (this.matchesBinding(data, "pageDown") || matchesKey(data, "ctrl+d"))
110
+ this.setActiveOffsetY(Math.min(Math.max(0, this.lines.length - visibleLines), offsetY + Math.max(1, Math.floor(visibleLines / 2))));
111
+ else if (this.matchesBinding(data, "pageUp") || matchesKey(data, "ctrl+u"))
112
+ this.setActiveOffsetY(Math.max(0, offsetY - Math.max(1, Math.floor(visibleLines / 2))));
113
+ else if (data === "g") this.setActiveOffsetY(0);
114
+ else if (data === "G") this.setActiveOffsetY(Math.max(0, this.lines.length - visibleLines));
115
+ else if (data === "f") {
116
+ if (!this.expanded && !this.expandedOffsetInitialized) {
117
+ this.expandedOffsetY = this.compactOffsetY;
118
+ this.expandedOffsetInitialized = true;
119
+ }
120
+ this.expanded = !this.expanded;
121
+ } else return;
101
122
  this.tui.requestRender();
102
123
  }
103
124
 
125
+ private activeOffsetY(): number {
126
+ return this.expanded ? this.expandedOffsetY : this.compactOffsetY;
127
+ }
128
+
129
+ private setActiveOffsetY(offsetY: number): void {
130
+ if (this.expanded) this.expandedOffsetY = offsetY;
131
+ else this.compactOffsetY = offsetY;
132
+ }
133
+
134
+ private visibleLineCount(): number {
135
+ return this.expanded
136
+ ? Math.max(this.compactVisibleLines, this.tui.terminal.rows - ARTIFACT_DETAIL_RESERVED_ROWS)
137
+ : this.compactVisibleLines;
138
+ }
139
+
104
140
  private buildLines(width: number): void {
105
141
  if (this.renderedWidth === width) return;
106
142
  this.renderedWidth = width;
@@ -171,7 +207,6 @@ class DiscussionTranscriptViewport {
171
207
  ];
172
208
  });
173
209
  this.lines = [...header, ...(transcript.length > 0 ? transcript : [{ text: theme.fg("muted", "No rounds recorded.") }])];
174
- this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
175
210
  }
176
211
  }
177
212
 
@@ -186,6 +221,20 @@ export async function showDiscussionDetailView(
186
221
  return;
187
222
  }
188
223
  await ctx.ui.custom<void>(
189
- (tui, theme, _keybindings, done) => new DiscussionTranscriptViewport(tui, () => ctx.ui.theme ?? theme, discussion, rounds, done),
224
+ (tui, theme, keybindings, done) =>
225
+ new DiscussionTranscriptViewport(
226
+ tui,
227
+ () => ctx.ui.theme ?? theme,
228
+ discussion,
229
+ rounds,
230
+ done,
231
+ (data, binding) => {
232
+ if (binding === "up") return keybindings.matches?.(data, "tui.select.up") === true || matchesKey(data, "up");
233
+ if (binding === "down") return keybindings.matches?.(data, "tui.select.down") === true || matchesKey(data, "down");
234
+ if (binding === "pageUp") return keybindings.matches?.(data, "tui.select.pageUp") === true || matchesKey(data, "pageUp");
235
+ if (binding === "pageDown") return keybindings.matches?.(data, "tui.select.pageDown") === true || matchesKey(data, "pageDown");
236
+ return keybindings.matches?.(data, "tui.select.cancel") === true || matchesKey(data, "escape") || matchesKey(data, "ctrl+c");
237
+ },
238
+ ),
190
239
  );
191
240
  }
@@ -64,6 +64,37 @@ import {
64
64
  } from "./tool-rendering/render-model.ts";
65
65
  import { registerNotesVehicle } from "./tools/vehicle-notes-client.ts";
66
66
 
67
+ /**
68
+ * Context enrichment is useful, but it sits before provider dispatch and therefore gets a much
69
+ * smaller deadline than an explicit daemon-backed tool call. A failed daemon probe may continue
70
+ * settling in the background; this deadline only guarantees the user's prompt is released.
71
+ */
72
+ export const PAPYRUS_CONTEXT_INJECTION_DEADLINE_MS = 500;
73
+
74
+ function withContextInjectionDeadline<T>(work: Promise<T>): Promise<T> {
75
+ let timer: ReturnType<typeof setTimeout>;
76
+ const deadline = new Promise<never>((_resolve, reject) => {
77
+ timer = setTimeout(
78
+ () => reject(new Error(`Papyrus context injection exceeded ${PAPYRUS_CONTEXT_INJECTION_DEADLINE_MS}ms`)),
79
+ PAPYRUS_CONTEXT_INJECTION_DEADLINE_MS,
80
+ );
81
+ timer.unref?.();
82
+ });
83
+ return Promise.race([work, deadline]).finally(() => clearTimeout(timer));
84
+ }
85
+
86
+ type InjectableRule = Pick<Artifact, "id" | "title" | "body" | "extra">;
87
+ type InjectablePlaybook = Pick<Artifact, "title" | "extra">;
88
+
89
+ interface ContextInjectionSnapshot {
90
+ projectRoot: string;
91
+ sessionId: string;
92
+ rules: InjectableRule[];
93
+ playbooks: InjectablePlaybook[];
94
+ taskSummary: string | null;
95
+ taskGraph: TaskGraph;
96
+ }
97
+
67
98
  function text(value: string, details: unknown = {}) {
68
99
  const modelContent = createModelContent(value);
69
100
  return { content: [{ type: "text" as const, text: modelContent.text }], details };
@@ -477,6 +508,9 @@ export default function (pi: ExtensionAPI) {
477
508
  let contextInjectionSequence = 0;
478
509
  const contextInjectionProducerId = randomUUID();
479
510
  let previousContextInjectionFingerprint: string | undefined;
511
+ // A single project/session-scoped snapshot bounds memory while preserving the latest complete
512
+ // context across transient daemon restarts and client/daemon version skew.
513
+ let latestContextInjection: ContextInjectionSnapshot | undefined;
480
514
  let logTurnSequence = 0;
481
515
  // Papyrus's own Context Hub contribution (rules/tasks/Pi's own skill catalog, bundled into one segment --
482
516
  // see context/context-hub-contribution.ts) re-emits every turn alongside the existing injection
@@ -969,40 +1003,64 @@ export default function (pi: ExtensionAPI) {
969
1003
  let result: { systemPrompt: string } | undefined;
970
1004
  try {
971
1005
  const sessionId = ctx.sessionManager.getSessionId();
972
- const activationContext = buildActivationContext(ctx.cwd, event.prompt, event.systemPromptOptions.selectedTools);
973
- const [rules, playbooks, summary, taskGraph] = await Promise.all([
974
- callService<Record<string, unknown>, Array<Pick<Artifact, "id" | "title" | "body" | "extra">>>("rules.injectable", {
975
- project_root: ctx.cwd,
976
- session_id: sessionId,
977
- activation_context: activationContext,
978
- }),
979
- callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "extra">>>("playbooks.list", {
980
- status: "active",
981
- project_root: ctx.cwd,
982
- applicable: true,
983
- activated: true,
984
- full: true,
985
- activation_context: activationContext,
986
- session_id: sessionId,
987
- limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS,
988
- }),
989
- callService<Record<string, unknown>, string | null>("tasks.context", {
990
- project_root: ctx.cwd,
991
- session_id: sessionId,
992
- verbosity: "summary",
993
- }),
994
- callService<Record<string, unknown>, TaskGraph>("tasks.graph", { project_root: ctx.cwd, session_id: sessionId }),
995
- ]);
1006
+ let snapshot =
1007
+ latestContextInjection?.projectRoot === ctx.cwd && latestContextInjection.sessionId === sessionId
1008
+ ? latestContextInjection
1009
+ : undefined;
1010
+ try {
1011
+ const activationContext = buildActivationContext(ctx.cwd, event.prompt, event.systemPromptOptions.selectedTools);
1012
+ // Pi awaits before_agent_start before it can dispatch the provider request. These are
1013
+ // opportunistic lifecycle reads, so use the no-retry client rather than spending the
1014
+ // explicit-tool client's ~5s daemon-restart backoff on every incompatible/unavailable
1015
+ // daemon. The outer deadline also bounds a connected daemon that stops responding.
1016
+ const [rules, playbooks, taskSummary, taskGraph] = await withContextInjectionDeadline(
1017
+ Promise.all([
1018
+ callServicePassive<Record<string, unknown>, InjectableRule[]>("rules.injectable", {
1019
+ project_root: ctx.cwd,
1020
+ session_id: sessionId,
1021
+ activation_context: activationContext,
1022
+ }),
1023
+ callServicePassive<Record<string, unknown>, InjectablePlaybook[]>("playbooks.list", {
1024
+ status: "active",
1025
+ project_root: ctx.cwd,
1026
+ applicable: true,
1027
+ activated: true,
1028
+ full: true,
1029
+ activation_context: activationContext,
1030
+ session_id: sessionId,
1031
+ limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS,
1032
+ }),
1033
+ callServicePassive<Record<string, unknown>, string | null>("tasks.context", {
1034
+ project_root: ctx.cwd,
1035
+ session_id: sessionId,
1036
+ verbosity: "summary",
1037
+ }),
1038
+ callServicePassive<Record<string, unknown>, TaskGraph>("tasks.graph", {
1039
+ project_root: ctx.cwd,
1040
+ session_id: sessionId,
1041
+ }),
1042
+ ]),
1043
+ );
1044
+ snapshot = { projectRoot: ctx.cwd, sessionId, rules, playbooks, taskSummary, taskGraph };
1045
+ } catch {
1046
+ // Preserve the latest complete snapshot for this project/session. Cold-start failures
1047
+ // still skip injection, but a transient restart cannot erase previously valid context.
1048
+ if (!snapshot) return undefined;
1049
+ }
1050
+ const { rules, playbooks, taskSummary, taskGraph } = snapshot;
996
1051
  const injection = buildContextInjection({
997
1052
  basePrompt: event.systemPrompt ?? "",
998
1053
  rules,
999
1054
  playbooks,
1000
- taskSummary: summary,
1055
+ taskSummary,
1001
1056
  observedAt: Date.now(),
1002
1057
  sequence: ++contextInjectionSequence,
1003
1058
  producerId: contextInjectionProducerId,
1004
1059
  previousFingerprint: previousContextInjectionFingerprint,
1005
1060
  });
1061
+ const taskItems = buildTaskItemTree(taskGraph);
1062
+ // Cache only a complete snapshot that both prompt and graph projection accepted.
1063
+ latestContextInjection = snapshot;
1006
1064
  previousContextInjectionFingerprint = injection.observation.fingerprint;
1007
1065
  pi.events.emit(PAPYRUS_CONTEXT_INJECTION_CHANNEL, injection.observation);
1008
1066
  if (injection.prompt !== (event.systemPrompt ?? "")) result = { systemPrompt: injection.prompt };
@@ -1015,7 +1073,7 @@ export default function (pi: ExtensionAPI) {
1015
1073
  observedAt: Date.now(),
1016
1074
  sequence: ++contextHubContributionSequence,
1017
1075
  producerName: PAPYRUS_CONTEXT_HUB_PRODUCER_NAME,
1018
- segment: papyrusContextSegment(ruleBudget, buildTaskItemTree(taskGraph), skills, playbookBudget),
1076
+ segment: papyrusContextSegment(ruleBudget, taskItems, skills, playbookBudget),
1019
1077
  });
1020
1078
  } catch {
1021
1079
  // Malformed/unreachable daemon data for this turn's contribution -- drop it silently.
@@ -27,11 +27,14 @@ interface DetailLine {
27
27
 
28
28
  class TaskDetailViewport {
29
29
  private offsetX = 0;
30
- private offsetY = 0;
30
+ private compactOffsetY = 0;
31
+ private expandedOffsetY = 0;
32
+ private expandedOffsetInitialized = false;
31
33
  private renderedWidth = 0;
32
34
  private detailLines: DetailLine[] = [];
33
- private readonly visibleLines: number;
35
+ private readonly compactVisibleLines: number;
34
36
  private readonly content: TaskDetailContent;
37
+ private expanded = false;
35
38
  private readonly status: Artifact["status"];
36
39
 
37
40
  constructor(
@@ -41,8 +44,9 @@ class TaskDetailViewport {
41
44
  private readonly graphLines: string[],
42
45
  history: TaskEvent[],
43
46
  private readonly close: () => void,
47
+ private readonly matchesBinding: (data: string, binding: "up" | "down" | "pageUp" | "pageDown" | "cancel") => boolean,
44
48
  ) {
45
- this.visibleLines = Math.max(
49
+ this.compactVisibleLines = Math.max(
46
50
  TASK_DETAIL_MIN_VISIBLE_LINES,
47
51
  Math.min(TASK_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - TASK_DETAIL_RESERVED_ROWS),
48
52
  );
@@ -59,14 +63,16 @@ class TaskDetailViewport {
59
63
  this.buildLines(contentWidth);
60
64
  const graphWidth = this.graphLines.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0);
61
65
  this.offsetX = Math.min(this.offsetX, Math.max(0, graphWidth - contentWidth));
62
- this.offsetY = Math.min(this.offsetY, Math.max(0, this.detailLines.length - this.visibleLines));
63
- const end = Math.min(this.detailLines.length, this.offsetY + this.visibleLines);
66
+ const visibleLines = this.visibleLineCount();
67
+ const offsetY = Math.min(this.activeOffsetY(), Math.max(0, this.detailLines.length - visibleLines));
68
+ const end = Math.min(this.detailLines.length, offsetY + visibleLines);
64
69
  const theme = this.activeTheme();
65
70
  const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
66
71
  const footer = [
67
72
  graphWidth > contentWidth ? `←/→ graph · column ${this.offsetX + 1}/${graphWidth}` : "",
68
- this.detailLines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.detailLines.length}` : "",
69
- "Esc back",
73
+ this.detailLines.length > visibleLines ? `j/k scroll · ${offsetY + 1}-${end}/${this.detailLines.length}` : "",
74
+ `f ${this.expanded ? "compact" : "expand"}`,
75
+ "q/Esc back",
70
76
  ]
71
77
  .filter(Boolean)
72
78
  .join(" · ");
@@ -75,7 +81,7 @@ class TaskDetailViewport {
75
81
  truncateToWidth(theme.fg("accent", theme.bold("Task details")), width, ""),
76
82
  border,
77
83
  ...this.detailLines
78
- .slice(this.offsetY, end)
84
+ .slice(offsetY, end)
79
85
  .map((line) =>
80
86
  line.graph ? ` ${sliceByColumn(line.text, this.offsetX, contentWidth, true)}` : truncateToWidth(` ${line.text}`, width, ""),
81
87
  ),
@@ -85,18 +91,50 @@ class TaskDetailViewport {
85
91
  }
86
92
 
87
93
  handleInput(data: string): void {
88
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
94
+ if (this.matchesBinding(data, "cancel") || data === "q") {
89
95
  this.close();
90
96
  return;
91
97
  }
92
- if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
93
- else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.detailLines.length - this.visibleLines), this.offsetY + 1);
94
- else if (matchesKey(data, "left")) this.offsetX = Math.max(0, this.offsetX - TASK_DETAIL_HORIZONTAL_PAN_COLUMNS);
95
- else if (matchesKey(data, "right")) this.offsetX += TASK_DETAIL_HORIZONTAL_PAN_COLUMNS;
96
- else return;
98
+ const visibleLines = this.visibleLineCount();
99
+ const offsetY = Math.min(this.activeOffsetY(), Math.max(0, this.detailLines.length - visibleLines));
100
+ if (this.matchesBinding(data, "up") || data === "k") this.setActiveOffsetY(Math.max(0, offsetY - 1));
101
+ else if (this.matchesBinding(data, "down") || data === "j")
102
+ this.setActiveOffsetY(Math.min(Math.max(0, this.detailLines.length - visibleLines), offsetY + 1));
103
+ else if (this.matchesBinding(data, "pageUp") || matchesKey(data, "ctrl+u"))
104
+ this.setActiveOffsetY(Math.max(0, offsetY - Math.max(1, Math.floor(visibleLines / 2))));
105
+ else if (this.matchesBinding(data, "pageDown") || matchesKey(data, "ctrl+d"))
106
+ this.setActiveOffsetY(
107
+ Math.min(Math.max(0, this.detailLines.length - visibleLines), offsetY + Math.max(1, Math.floor(visibleLines / 2))),
108
+ );
109
+ else if (matchesKey(data, "left") || data === "h") this.offsetX = Math.max(0, this.offsetX - TASK_DETAIL_HORIZONTAL_PAN_COLUMNS);
110
+ else if (matchesKey(data, "right") || data === "l") this.offsetX += TASK_DETAIL_HORIZONTAL_PAN_COLUMNS;
111
+ else if (data === "g") this.setActiveOffsetY(0);
112
+ else if (data === "G") this.setActiveOffsetY(Math.max(0, this.detailLines.length - visibleLines));
113
+ else if (data === "f") {
114
+ if (!this.expanded && !this.expandedOffsetInitialized) {
115
+ this.expandedOffsetY = this.compactOffsetY;
116
+ this.expandedOffsetInitialized = true;
117
+ }
118
+ this.expanded = !this.expanded;
119
+ } else return;
97
120
  this.tui.requestRender();
98
121
  }
99
122
 
123
+ private activeOffsetY(): number {
124
+ return this.expanded ? this.expandedOffsetY : this.compactOffsetY;
125
+ }
126
+
127
+ private setActiveOffsetY(offsetY: number): void {
128
+ if (this.expanded) this.expandedOffsetY = offsetY;
129
+ else this.compactOffsetY = offsetY;
130
+ }
131
+
132
+ private visibleLineCount(): number {
133
+ return this.expanded
134
+ ? Math.max(this.compactVisibleLines, this.tui.terminal.rows - TASK_DETAIL_RESERVED_ROWS)
135
+ : this.compactVisibleLines;
136
+ }
137
+
100
138
  private buildLines(width: number): void {
101
139
  if (this.renderedWidth === width) return;
102
140
  this.renderedWidth = width;
@@ -146,7 +184,6 @@ class TaskDetailViewport {
146
184
  ...relationshipHeader,
147
185
  ...this.graphLines.map((text) => ({ text: theme.fg("text", text), graph: true })),
148
186
  ];
149
- this.offsetY = Math.min(this.offsetY, Math.max(0, this.detailLines.length - this.visibleLines));
150
187
  }
151
188
  }
152
189
 
@@ -164,6 +201,21 @@ export async function showTaskDetails(
164
201
  return;
165
202
  }
166
203
  await ctx.ui.custom<void>(
167
- (tui, theme, _keybindings, done) => new TaskDetailViewport(tui, () => ctx.ui.theme ?? theme, task, relationshipGraph, history, done),
204
+ (tui, theme, keybindings, done) =>
205
+ new TaskDetailViewport(
206
+ tui,
207
+ () => ctx.ui.theme ?? theme,
208
+ task,
209
+ relationshipGraph,
210
+ history,
211
+ done,
212
+ (data, binding) => {
213
+ if (binding === "up") return keybindings.matches?.(data, "tui.select.up") === true || matchesKey(data, "up");
214
+ if (binding === "down") return keybindings.matches?.(data, "tui.select.down") === true || matchesKey(data, "down");
215
+ if (binding === "pageUp") return keybindings.matches?.(data, "tui.select.pageUp") === true || matchesKey(data, "pageUp");
216
+ if (binding === "pageDown") return keybindings.matches?.(data, "tui.select.pageDown") === true || matchesKey(data, "pageDown");
217
+ return keybindings.matches?.(data, "tui.select.cancel") === true || matchesKey(data, "escape") || matchesKey(data, "ctrl+c");
218
+ },
219
+ ),
168
220
  );
169
221
  }
@@ -5,7 +5,17 @@
5
5
  */
6
6
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7
7
  import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
8
- import { Container, Input, matchesKey, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
8
+ import {
9
+ type Component,
10
+ Container,
11
+ type Focusable,
12
+ Input,
13
+ matchesKey,
14
+ Spacer,
15
+ truncateToWidth,
16
+ visibleWidth,
17
+ } from "@earendil-works/pi-tui";
18
+ import { ArtifactNavigationState } from "../artifact/artifact-navigation-state.ts";
9
19
  import {
10
20
  artifactBinderPath,
11
21
  artifactSearchText,
@@ -430,7 +440,7 @@ function renderPanel(
430
440
  binders: BinderTree,
431
441
  currentBinderId: string | undefined,
432
442
  ): Promise<PanelAction | undefined> {
433
- return ctx.ui.custom<PanelAction | undefined>((tui, theme, _kb, done) => {
443
+ return ctx.ui.custom<PanelAction | undefined>((tui, theme, keybindings, done) => {
434
444
  const rows = graph.nodes.map((node) => node.task);
435
445
  const searchInput = new Input();
436
446
  const allHierarchy = buildTaskHierarchy(graph);
@@ -442,13 +452,12 @@ function renderPanel(
442
452
  ];
443
453
  const taskById = new Map(rows.map((task) => [task.id, task]));
444
454
  const executionById = new Map(projectTaskExecution(graph).nodes.map((node) => [node.id, node]));
445
- let searchActive = false;
446
455
  let filtered = currentEntries();
447
- let selectedIndex = 0;
448
- const maxVisible = 20;
456
+ const navigation = new ArtifactNavigationState(filtered.length);
457
+ const compactVisible = 20;
449
458
 
450
459
  function applyFilter(): void {
451
- const query = searchInput.getValue().trim().toLowerCase();
460
+ const query = navigation.query.trim().toLowerCase();
452
461
  filtered = query
453
462
  ? [
454
463
  ...binders.nodes
@@ -463,7 +472,8 @@ function renderPanel(
463
472
  return leftPath.localeCompare(rightPath);
464
473
  })
465
474
  : currentEntries();
466
- selectedIndex = 0;
475
+ navigation.setItemCount(filtered.length);
476
+ navigation.first();
467
477
  }
468
478
 
469
479
  function statusLine(): string {
@@ -483,20 +493,23 @@ function renderPanel(
483
493
  invalidate() {},
484
494
  render(width: number): string[] {
485
495
  const title = theme.bold(`Tasks · ${graph.scope?.label ?? "scope unavailable"} · ${currentBinderPath(binders, currentBinderId)}`);
486
- const hint = searchActive
487
- ? rawKeyHint("esc", "clear")
488
- : [
489
- rawKeyHint("↑/↓", "navigate"),
490
- rawKeyHint("enter", "open/actions"),
491
- rawKeyHint("a", "actions"),
492
- rawKeyHint("", "up"),
493
- rawKeyHint("n", "new Binder"),
494
- rawKeyHint("/", "filter"),
495
- rawKeyHint("g", "graph"),
496
- rawKeyHint("s", "scope"),
497
- rawKeyHint("r", "refresh"),
498
- rawKeyHint("esc", "close"),
499
- ].join(theme.fg("muted", " · "));
496
+ const hint =
497
+ navigation.mode === "filter"
498
+ ? `${theme.fg("accent", "FILTER")} · ${rawKeyHint("enter", "keep")} · ${rawKeyHint("esc", "clear")}`
499
+ : [
500
+ theme.fg("accent", "NORMAL"),
501
+ rawKeyHint("j/k", "navigate"),
502
+ rawKeyHint("enter/l", "open/actions"),
503
+ rawKeyHint("a", "actions"),
504
+ rawKeyHint("h", "up"),
505
+ rawKeyHint("n", "new Binder"),
506
+ rawKeyHint("/", "filter"),
507
+ rawKeyHint("v", "graph"),
508
+ rawKeyHint("s", "scope"),
509
+ rawKeyHint("f", navigation.expanded ? "compact" : "expand"),
510
+ rawKeyHint("r", "refresh"),
511
+ rawKeyHint("q", "close"),
512
+ ].join(theme.fg("muted", " · "));
500
513
  const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
501
514
  const line1 = truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, "");
502
515
  const line2 = truncateToWidth(theme.fg("muted", statusLine()), width, "");
@@ -508,17 +521,18 @@ function renderPanel(
508
521
  invalidate() {},
509
522
  render(width: number): string[] {
510
523
  const lines: string[] = [];
511
- if (searchActive) lines.push(...searchInput.render(width));
524
+ if (navigation.mode === "filter") lines.push(...searchInput.render(width));
512
525
  lines.push("");
513
526
  if (filtered.length === 0) {
514
527
  lines.push(theme.fg("muted", " No tasks or Binders here"));
515
528
  return lines;
516
529
  }
517
- const start = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), filtered.length - maxVisible));
518
- const end = Math.min(start + maxVisible, filtered.length);
530
+ const visibleRows = navigation.expanded ? Math.max(compactVisible, tui.terminal.rows - 10) : compactVisible;
531
+ const start = Math.max(0, Math.min(navigation.selectedIndex - Math.floor(visibleRows / 2), filtered.length - visibleRows));
532
+ const end = Math.min(start + visibleRows, filtered.length);
519
533
  for (let i = start; i < end; i++) {
520
534
  const panelEntry = filtered[i]!;
521
- const selected = i === selectedIndex;
535
+ const selected = i === navigation.selectedIndex;
522
536
  const cursor = selected ? theme.fg("accent", "❯") : " ";
523
537
  if (panelEntry.type === "binder") {
524
538
  const title = selected ? theme.bold(panelEntry.node.binder.title) : panelEntry.node.binder.title;
@@ -527,7 +541,7 @@ function renderPanel(
527
541
  ? `${panelEntry.node.childIds.length} Binder${panelEntry.node.childIds.length === 1 ? "" : "s"}`
528
542
  : "",
529
543
  panelEntry.node.effectiveLabels.length > 0 ? panelEntry.node.effectiveLabels.join(", ") : "",
530
- searchActive ? panelEntry.node.path : "",
544
+ navigation.mode === "filter" ? panelEntry.node.path : "",
531
545
  ].filter(Boolean);
532
546
  lines.push(
533
547
  truncateToWidth(
@@ -579,13 +593,13 @@ function renderPanel(
579
593
  if (row.labels.length > 0) relationParts.push(row.labels.join(", "));
580
594
  const inheritedLabels = inheritedLabelsFor(row.id, binders);
581
595
  if (inheritedLabels.length > 0) relationParts.push(`inherits ${inheritedLabels.join(", ")}`);
582
- if (searchActive) relationParts.push(artifactBinderPath(row, binders));
596
+ if (navigation.mode === "filter") relationParts.push(artifactBinderPath(row, binders));
583
597
  const relationText = relationParts.length > 0 ? theme.fg("dim", ` · ${relationParts.join(" · ")}`) : "";
584
598
  lines.push(truncateToWidth(`${cursor}${focus} ${node} ${glyphStyled} ${title}${relationText}`, width, ""));
585
599
  }
586
600
  const hasScroll = start > 0 || end < filtered.length;
587
601
  lines.push(
588
- theme.fg("muted", ` ${hasScroll ? `${selectedIndex + 1}/${filtered.length} · ` : ""}↑/↓ navigate · Enter open/actions`),
602
+ theme.fg("muted", ` ${hasScroll ? `${navigation.selectedIndex + 1}/${filtered.length} · ` : ""}j/k navigate · Enter/l open`),
589
603
  );
590
604
  return lines;
591
605
  },
@@ -601,34 +615,72 @@ function renderPanel(
601
615
  container.addChild(new Spacer(1));
602
616
  container.addChild(new DynamicBorder());
603
617
 
618
+ let focused = false;
604
619
  return {
620
+ get focused(): boolean {
621
+ return focused;
622
+ },
623
+ set focused(value: boolean) {
624
+ focused = value;
625
+ searchInput.focused = value && navigation.mode === "filter";
626
+ },
605
627
  render: (width: number) => container.render(width),
606
628
  invalidate: () => container.invalidate(),
607
629
  handleInput(data: string) {
608
- if (searchActive) {
630
+ if (navigation.mode === "filter") {
609
631
  if (matchesKey(data, "escape")) {
610
- searchActive = false;
632
+ navigation.leaveFilter(true);
633
+ searchInput.setValue(navigation.query);
634
+ searchInput.focused = false;
611
635
  applyFilter();
612
636
  } else if (matchesKey(data, "enter")) {
613
- searchActive = false;
637
+ navigation.leaveFilter();
638
+ searchInput.focused = false;
614
639
  } else {
615
640
  searchInput.handleInput(data);
641
+ navigation.setQuery(searchInput.getValue());
616
642
  applyFilter();
617
643
  }
618
644
  tui.requestRender();
619
645
  return;
620
646
  }
621
- if (matchesKey(data, "up")) selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(filtered.length, 1);
622
- else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
623
- else if (data === "/") searchActive = true;
647
+ const selectedEntry = () => filtered[navigation.selectedIndex];
648
+ const openSelected = (): void => {
649
+ const entry = selectedEntry();
650
+ if (entry?.type === "binder") done({ type: "navigate", binderId: entry.node.binder.id });
651
+ else if (entry?.type === "task") done({ type: "action", row: entry.entry.task });
652
+ };
653
+ if (keybindings.matches?.(data, "tui.select.up") === true || matchesKey(data, "up") || data === "k") navigation.move(-1);
654
+ else if (keybindings.matches?.(data, "tui.select.down") === true || matchesKey(data, "down") || data === "j") navigation.move(1);
655
+ else if (keybindings.matches?.(data, "tui.select.pageUp") === true || matchesKey(data, "pageUp") || matchesKey(data, "ctrl+u"))
656
+ navigation.movePage(
657
+ -1,
658
+ Math.max(1, Math.floor((navigation.expanded ? Math.max(compactVisible, tui.terminal.rows - 10) : compactVisible) / 2)),
659
+ );
660
+ else if (keybindings.matches?.(data, "tui.select.pageDown") === true || matchesKey(data, "pageDown") || matchesKey(data, "ctrl+d"))
661
+ navigation.movePage(
662
+ 1,
663
+ Math.max(1, Math.floor((navigation.expanded ? Math.max(compactVisible, tui.terminal.rows - 10) : compactVisible) / 2)),
664
+ );
665
+ else if (data === "g") navigation.first();
666
+ else if (data === "G") navigation.last();
667
+ else if (data === "/") {
668
+ navigation.enterFilter();
669
+ searchInput.focused = focused;
670
+ } else if (data === "f") navigation.toggleExpanded();
624
671
  else if (data === "n") {
625
672
  done({ type: "create-binder" });
626
673
  return;
627
- } else if (matchesKey(data, "left") || data === "\x7f") {
674
+ } else if (
675
+ keybindings.matches?.(data, "tui.editor.cursorLeft") === true ||
676
+ matchesKey(data, "left") ||
677
+ data === "h" ||
678
+ data === "\x7f"
679
+ ) {
628
680
  const parentId = currentBinderId ? binders.nodes.find((node) => node.binder.id === currentBinderId)?.parentId : undefined;
629
681
  done({ type: "navigate", ...(parentId ? { binderId: parentId } : {}) });
630
682
  return;
631
- } else if (data === "g") {
683
+ } else if (data === "v") {
632
684
  done({ type: "graph" });
633
685
  return;
634
686
  } else if (data === "s") {
@@ -638,21 +690,24 @@ function renderPanel(
638
690
  done({ type: "refresh" });
639
691
  return;
640
692
  } else if (data === "a") {
641
- const panelEntry = filtered[selectedIndex];
642
- if (panelEntry?.type === "binder") done({ type: "binder-action", binder: panelEntry.node });
643
- else if (panelEntry?.type === "task") done({ type: "action", row: panelEntry.entry.task });
693
+ const entry = selectedEntry();
694
+ if (entry?.type === "binder") done({ type: "binder-action", binder: entry.node });
695
+ else if (entry?.type === "task") done({ type: "action", row: entry.entry.task });
644
696
  return;
645
- } else if (matchesKey(data, "enter")) {
646
- const panelEntry = filtered[selectedIndex];
647
- if (panelEntry?.type === "binder") done({ type: "navigate", binderId: panelEntry.node.binder.id });
648
- else if (panelEntry?.type === "task") done({ type: "action", row: panelEntry.entry.task });
697
+ } else if (keybindings.matches?.(data, "tui.select.confirm") === true || matchesKey(data, "enter") || data === "l") {
698
+ openSelected();
649
699
  return;
650
- } else if (matchesKey(data, "escape")) {
700
+ } else if (
701
+ keybindings.matches?.(data, "tui.select.cancel") === true ||
702
+ matchesKey(data, "escape") ||
703
+ matchesKey(data, "ctrl+c") ||
704
+ data === "q"
705
+ ) {
651
706
  done(undefined);
652
707
  return;
653
708
  } else return;
654
709
  tui.requestRender();
655
710
  },
656
- };
711
+ } satisfies Component & Focusable;
657
712
  });
658
713
  }
@@ -0,0 +1,20 @@
1
+ import { BATCH_MAX_ITEMS } from "@danypops/papyrus";
2
+
3
+ /** Summarizes validated batch outcomes without copying nested results or error payloads. */
4
+ export function batchOutcomeSummary(output: unknown): string | undefined {
5
+ if (typeof output !== "object" || output === null || !("results" in output)) return undefined;
6
+ const results = output.results;
7
+ if (!Array.isArray(results) || results.length === 0 || results.length > BATCH_MAX_ITEMS) return undefined;
8
+ const statuses: boolean[] = [];
9
+ for (const item of results) {
10
+ if (typeof item !== "object" || item === null) return undefined;
11
+ if (item.ok === true && "result" in item) statuses.push(true);
12
+ else if (item.ok === false && typeof item.error === "string") statuses.push(false);
13
+ else return undefined;
14
+ }
15
+ const succeeded = statuses.filter(Boolean).length;
16
+ return [
17
+ `Batch: ${succeeded} succeeded, ${statuses.length - succeeded} failed`,
18
+ ...statuses.map((ok, index) => `${index + 1}: ${ok ? "succeeded" : "failed"}`),
19
+ ].join("\n");
20
+ }
@@ -41,6 +41,7 @@ import {
41
41
  parsePapyrusToolDetails,
42
42
  } from "../../tool-rendering/render-model.ts";
43
43
  import { recordRenderDiagnostic, shapeFingerprint } from "../render-diagnostics.ts";
44
+ import { batchOutcomeSummary } from "./batch.ts";
44
45
  import {
45
46
  isDiscussionAndRounds,
46
47
  isDiscussionListOutput,
@@ -150,6 +151,11 @@ export function papyrusVehicleRenderers(descriptor: VehicleOperationDescriptor):
150
151
  * instead of silently persisting and rendering raw JSON.
151
152
  */
152
153
  function projectPapyrusPresentation(descriptor: VehicleOperationDescriptor, output: unknown): PapyrusToolDetails {
154
+ if (descriptor.name === "batch.execute") {
155
+ const summary = batchOutcomeSummary(output);
156
+ if (summary !== undefined) return createSemanticTextDetails(descriptor.name, summary);
157
+ throw new Error(`${descriptor.name} produced no legal presentation variant`);
158
+ }
153
159
  if (isArtifactArray(output)) return createArtifactListDetails(descriptor.name, output);
154
160
  if (isArtifact(output)) return createArtifactDetails(descriptor.name, output);
155
161
  if (isTaskFocus(output)) return createArtifactDetails(descriptor.name, output.artifact, focusAnnotation(output));
@@ -58,6 +58,10 @@ export const PAPYRUS_VEHICLE_PERMISSIONS = [
58
58
  "artifact:write",
59
59
  "binders:read",
60
60
  "binders:write",
61
+ "projects:read",
62
+ "projects:write",
63
+ "scope_groups:read",
64
+ "scope_groups:write",
61
65
  ] as const;
62
66
 
63
67
  /** 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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.59.6",
3
+ "version": "0.60.0",
4
4
  "description": "Pi host extension for Papyrus: native tools, TUI panels, and context injection over the daemon-backed graph store",
5
5
  "license": "MIT",
6
6
  "type": "module",