@danypops/pi-papyrus 0.59.6 → 0.60.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extension/src/artifact/artifact-browser.ts +98 -37
- package/extension/src/artifact/artifact-detail-view.ts +65 -16
- package/extension/src/artifact/artifact-navigation-state.ts +54 -0
- package/extension/src/discuss/discussion-detail-view.ts +65 -16
- package/extension/src/index.ts +84 -26
- package/extension/src/task/task-detail-view.ts +68 -16
- package/extension/src/task/tasks.ts +100 -45
- package/extension/src/tool-rendering/render-model/semantic-text.ts +9 -1
- package/extension/src/tool-rendering/semantic-text.ts +38 -0
- package/extension/src/tools/renderers/batch.ts +20 -0
- package/extension/src/tools/renderers/index.ts +11 -1
- package/extension/src/tools/vehicle-notes-client.ts +4 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
265
|
+
const navigation = new ArtifactNavigationState(filtered.length);
|
|
257
266
|
|
|
258
267
|
function applyFilter(): void {
|
|
259
|
-
filtered = browserEntries(rows, tree, currentBinderId,
|
|
260
|
-
|
|
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 =
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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 =
|
|
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
|
|
299
|
-
const
|
|
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
|
-
|
|
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 &&
|
|
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 (
|
|
379
|
+
if (navigation.mode === "filter") {
|
|
358
380
|
if (matchesKey(data, "escape")) {
|
|
359
|
-
|
|
381
|
+
navigation.leaveFilter(true);
|
|
382
|
+
input.setValue(navigation.query);
|
|
383
|
+
input.focused = false;
|
|
360
384
|
applyFilter();
|
|
361
|
-
} else if (matchesKey(data, "enter"))
|
|
362
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
57
|
-
const
|
|
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 >
|
|
63
|
-
"
|
|
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(
|
|
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 (
|
|
88
|
+
if (this.matchesBinding(data, "cancel") || data === "q") {
|
|
83
89
|
this.close();
|
|
84
90
|
return;
|
|
85
91
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
else if (
|
|
90
|
-
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
74
|
-
const
|
|
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 = [
|
|
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(
|
|
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 (
|
|
100
|
+
if (this.matchesBinding(data, "cancel") || data === "q") {
|
|
92
101
|
this.close();
|
|
93
102
|
return;
|
|
94
103
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
else
|
|
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,
|
|
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
|
}
|
package/extension/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
63
|
-
const
|
|
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 >
|
|
69
|
-
"
|
|
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(
|
|
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 (
|
|
94
|
+
if (this.matchesBinding(data, "cancel") || data === "q") {
|
|
89
95
|
this.close();
|
|
90
96
|
return;
|
|
91
97
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
else if (
|
|
96
|
-
|
|
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,
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
448
|
-
const
|
|
456
|
+
const navigation = new ArtifactNavigationState(filtered.length);
|
|
457
|
+
const compactVisible = 20;
|
|
449
458
|
|
|
450
459
|
function applyFilter(): void {
|
|
451
|
-
const query =
|
|
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
|
-
|
|
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 =
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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 (
|
|
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
|
|
518
|
-
const
|
|
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
|
-
|
|
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 (
|
|
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} · ` : ""}
|
|
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 (
|
|
630
|
+
if (navigation.mode === "filter") {
|
|
609
631
|
if (matchesKey(data, "escape")) {
|
|
610
|
-
|
|
632
|
+
navigation.leaveFilter(true);
|
|
633
|
+
searchInput.setValue(navigation.query);
|
|
634
|
+
searchInput.focused = false;
|
|
611
635
|
applyFilter();
|
|
612
636
|
} else if (matchesKey(data, "enter")) {
|
|
613
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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 (
|
|
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 === "
|
|
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
|
|
642
|
-
if (
|
|
643
|
-
else if (
|
|
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
|
-
|
|
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 (
|
|
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
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
1
2
|
import { TOOL_DETAILS_BODY_MAX_CHARACTERS } from "@danypops/papyrus";
|
|
2
3
|
import { boundedText, PAPYRUS_TOOL_DETAILS_SCHEMA, type ResultCompleteness, type ToolDetailsBase } from "./shared.ts";
|
|
3
4
|
|
|
@@ -8,8 +9,15 @@ export interface SemanticTextToolDetails extends ToolDetailsBase {
|
|
|
8
9
|
completeness: ResultCompleteness;
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
/** Preserves diagnostic text while removing terminal commands and normalizing line breaks and tabs. */
|
|
13
|
+
export function plainTerminalText(text: string): string {
|
|
14
|
+
return stripVTControlCharacters(text)
|
|
15
|
+
.replace(/\r\n?/g, "\n")
|
|
16
|
+
.replace(/\p{Cc}/gu, (character) => (character === "\n" ? "\n" : character === "\t" ? " " : ""));
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
export function createSemanticTextDetails(operation: string, text: string): SemanticTextToolDetails {
|
|
12
|
-
const bounded = boundedText(text, TOOL_DETAILS_BODY_MAX_CHARACTERS);
|
|
20
|
+
const bounded = boundedText(plainTerminalText(text), TOOL_DETAILS_BODY_MAX_CHARACTERS);
|
|
13
21
|
return {
|
|
14
22
|
schemaVersion: PAPYRUS_TOOL_DETAILS_SCHEMA,
|
|
15
23
|
kind: "semantic-text",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { TOOL_COLLAPSED_ROW_LIMIT } from "@danypops/papyrus";
|
|
2
|
+
import { expandHint } from "@danypops/vehicle-client-pi/expand-hint";
|
|
3
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { type Component, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { createSemanticTextDetails, type SemanticTextToolDetails } from "./render-model/semantic-text.ts";
|
|
6
|
+
|
|
7
|
+
const MAX_EXPANDED_TEXT_ROWS = 200;
|
|
8
|
+
|
|
9
|
+
/** Renders diagnostic text with host-owned styles and bounded collapsed or expanded rows. */
|
|
10
|
+
export class SemanticTextCard implements Component {
|
|
11
|
+
private readonly details: SemanticTextToolDetails;
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
details: SemanticTextToolDetails,
|
|
15
|
+
private readonly theme: Theme,
|
|
16
|
+
private readonly expanded: boolean,
|
|
17
|
+
) {
|
|
18
|
+
const normalized = createSemanticTextDetails(details.operation, details.text);
|
|
19
|
+
this.details = { ...normalized, completeness: details.completeness.truncated ? details.completeness : normalized.completeness };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
render(width: number): string[] {
|
|
23
|
+
if (width < 1) return [];
|
|
24
|
+
const lines = new Text(this.details.text, 0, 0).render(width);
|
|
25
|
+
const limit = this.expanded ? MAX_EXPANDED_TEXT_ROWS : TOOL_COLLAPSED_ROW_LIMIT;
|
|
26
|
+
const visible = lines.slice(0, limit).map((line) => truncateToWidth(this.theme.fg("toolOutput", line), width));
|
|
27
|
+
const omitted = lines.length - visible.length;
|
|
28
|
+
if (omitted > 0) {
|
|
29
|
+
const hint = this.expanded ? `${omitted} more lines omitted · display limit` : `${omitted} more lines · ${expandHint()}`;
|
|
30
|
+
visible.push(truncateToWidth(this.theme.fg("dim", hint), width));
|
|
31
|
+
} else if (this.details.completeness.truncated) {
|
|
32
|
+
visible.push(truncateToWidth(this.theme.fg("dim", "Output truncated"), width));
|
|
33
|
+
}
|
|
34
|
+
return visible;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
invalidate(): void {}
|
|
38
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -40,7 +40,9 @@ import {
|
|
|
40
40
|
type PapyrusToolDetails,
|
|
41
41
|
parsePapyrusToolDetails,
|
|
42
42
|
} from "../../tool-rendering/render-model.ts";
|
|
43
|
+
import { SemanticTextCard } from "../../tool-rendering/semantic-text.ts";
|
|
43
44
|
import { recordRenderDiagnostic, shapeFingerprint } from "../render-diagnostics.ts";
|
|
45
|
+
import { batchOutcomeSummary } from "./batch.ts";
|
|
44
46
|
import {
|
|
45
47
|
isDiscussionAndRounds,
|
|
46
48
|
isDiscussionListOutput,
|
|
@@ -133,6 +135,9 @@ export function papyrusVehicleRenderers(descriptor: VehicleOperationDescriptor):
|
|
|
133
135
|
if (isTaskCompletion(output)) {
|
|
134
136
|
return renderTaskCompletion(output, theme, options.expanded);
|
|
135
137
|
}
|
|
138
|
+
if (isSemanticTextOutput(output)) {
|
|
139
|
+
return new SemanticTextCard(createSemanticTextDetails(descriptor.name, semanticText(output)), theme, options.expanded);
|
|
140
|
+
}
|
|
136
141
|
recordRenderDiagnostic({ event: "render-result-fell-through-to-generic", operation: descriptor.name });
|
|
137
142
|
}
|
|
138
143
|
return renderVehicleResult(descriptor, result, options, theme, context);
|
|
@@ -150,6 +155,11 @@ export function papyrusVehicleRenderers(descriptor: VehicleOperationDescriptor):
|
|
|
150
155
|
* instead of silently persisting and rendering raw JSON.
|
|
151
156
|
*/
|
|
152
157
|
function projectPapyrusPresentation(descriptor: VehicleOperationDescriptor, output: unknown): PapyrusToolDetails {
|
|
158
|
+
if (descriptor.name === "batch.execute") {
|
|
159
|
+
const summary = batchOutcomeSummary(output);
|
|
160
|
+
if (summary !== undefined) return createSemanticTextDetails(descriptor.name, summary);
|
|
161
|
+
throw new Error(`${descriptor.name} produced no legal presentation variant`);
|
|
162
|
+
}
|
|
153
163
|
if (isArtifactArray(output)) return createArtifactListDetails(descriptor.name, output);
|
|
154
164
|
if (isArtifact(output)) return createArtifactDetails(descriptor.name, output);
|
|
155
165
|
if (isTaskFocus(output)) return createArtifactDetails(descriptor.name, output.artifact, focusAnnotation(output));
|
|
@@ -211,7 +221,7 @@ function renderFromPapyrusPresentation(
|
|
|
211
221
|
case "preview":
|
|
212
222
|
return new Text(theme.fg("toolOutput", presentation.content), 0, 0);
|
|
213
223
|
case "semantic-text":
|
|
214
|
-
return new
|
|
224
|
+
return new SemanticTextCard(presentation, theme, expanded);
|
|
215
225
|
case "transition":
|
|
216
226
|
case "graph":
|
|
217
227
|
case "gate-run":
|
|
@@ -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