@danypops/papyrus 0.34.2 → 0.35.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.
- package/README.md +5 -189
- package/package.json +8 -16
- package/src/artifact-relationship-view.ts +23 -0
- package/src/cli.ts +0 -0
- package/src/index.ts +32 -0
- package/src/task-relationship-view.ts +2 -1
- package/extension/src/active-task-continuation.ts +0 -131
- package/extension/src/artifact-browser.ts +0 -229
- package/extension/src/artifact-detail-format.ts +0 -31
- package/extension/src/artifact-detail-view.ts +0 -112
- package/extension/src/artifact-format.ts +0 -84
- package/extension/src/artifact-status-presentation.ts +0 -71
- package/extension/src/base-prompt-breakdown.ts +0 -55
- package/extension/src/beautiful-mermaid-renderer.ts +0 -68
- package/extension/src/bounded-poll.ts +0 -20
- package/extension/src/context-budget.ts +0 -503
- package/extension/src/context-injection-telemetry.ts +0 -88
- package/extension/src/context-view.ts +0 -222
- package/extension/src/discuss-ask-layout.ts +0 -193
- package/extension/src/discuss-ask-view.ts +0 -1301
- package/extension/src/discuss.ts +0 -134
- package/extension/src/discussion-detail-view.ts +0 -136
- package/extension/src/docs.ts +0 -58
- package/extension/src/domain-tools.ts +0 -886
- package/extension/src/index.ts +0 -776
- package/extension/src/markdown.ts +0 -60
- package/extension/src/note-widget.ts +0 -8
- package/extension/src/notes.ts +0 -102
- package/extension/src/playbook-bridge.ts +0 -91
- package/extension/src/playbooks.ts +0 -97
- package/extension/src/rules.ts +0 -51
- package/extension/src/service-client.ts +0 -29
- package/extension/src/session-identity.ts +0 -22
- package/extension/src/skill-catalog-footprint.ts +0 -183
- package/extension/src/skills.ts +0 -127
- package/extension/src/task-context.ts +0 -1
- package/extension/src/task-detail-format.ts +0 -110
- package/extension/src/task-detail-view.ts +0 -139
- package/extension/src/task-focus-events.ts +0 -57
- package/extension/src/task-graph.ts +0 -116
- package/extension/src/task-presentation.ts +0 -26
- package/extension/src/task-widget.ts +0 -70
- package/extension/src/tasks.ts +0 -418
- package/extension/src/tool-rendering/artifact-card.ts +0 -117
- package/extension/src/tool-rendering/artifact-list.ts +0 -179
- package/extension/src/tool-rendering/index.ts +0 -109
- package/extension/src/tool-rendering/render-model.ts +0 -410
package/extension/src/skills.ts
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
3
|
-
import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
|
|
4
|
-
import type { TaskGraph } from "../../src/task-service.ts";
|
|
5
|
-
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
6
|
-
import { SKILL_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
7
|
-
import { callService } from "./service-client.ts";
|
|
8
|
-
import { showTaskGraph } from "./task-graph.ts";
|
|
9
|
-
|
|
10
|
-
function strings(value: unknown): string[] {
|
|
11
|
-
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function skillRowMeta(skill: Artifact): string {
|
|
15
|
-
if (skill.subtype === "artifact-template") {
|
|
16
|
-
const target = typeof skill.extra["targetKind"] === "string" ? skill.extra["targetKind"] : "artifact";
|
|
17
|
-
return `template → ${target}`;
|
|
18
|
-
}
|
|
19
|
-
if (skill.subtype === "workflow") {
|
|
20
|
-
const definition = skill.extra["definition"] as Record<string, unknown> | undefined;
|
|
21
|
-
const inputs = definition?.["inputs"] && typeof definition["inputs"] === "object"
|
|
22
|
-
? Object.keys(definition["inputs"] as Record<string, unknown>).length
|
|
23
|
-
: 0;
|
|
24
|
-
const blueprints = definition?.["blueprints"] as Record<string, unknown> | undefined;
|
|
25
|
-
const tasks = Array.isArray(blueprints?.["tasks"]) ? blueprints["tasks"].length : 0;
|
|
26
|
-
return `workflow · ${inputs} inputs · ${tasks} tasks`;
|
|
27
|
-
}
|
|
28
|
-
const trigger = typeof skill.extra["trigger"] === "string" ? `when ${skill.extra["trigger"]}` : "manual";
|
|
29
|
-
const tools = strings(skill.extra["tools"]);
|
|
30
|
-
return [trigger, tools.join(", ")].filter(Boolean).join(" · ");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function skillInvocationPrompt(skill: Artifact): string {
|
|
34
|
-
if (skill.subtype === "artifact-template") {
|
|
35
|
-
return [`Create an artifact using Papyrus template \"${skill.title}\".`, `template_name: ${skill.title}`, "Ask for or infer the title and all required template fields, then call the skills domain tool with action=instantiate."].join("\n");
|
|
36
|
-
}
|
|
37
|
-
if (skill.subtype === "workflow") {
|
|
38
|
-
return [
|
|
39
|
-
`Run Papyrus workflow Skill \"${skill.title}\".`,
|
|
40
|
-
"Collect its required arguments, then call the skills domain tool with action=run.",
|
|
41
|
-
].join("\n");
|
|
42
|
-
}
|
|
43
|
-
const trigger = typeof skill.extra["trigger"] === "string" ? skill.extra["trigger"] : "manual invocation";
|
|
44
|
-
const steps = strings(skill.extra["steps"]);
|
|
45
|
-
const tools = strings(skill.extra["tools"]);
|
|
46
|
-
return [
|
|
47
|
-
`Apply Papyrus skill \"${skill.title}\".`,
|
|
48
|
-
`Trigger: ${trigger}`,
|
|
49
|
-
...(skill.body ? [`Context: ${skill.body}`] : []),
|
|
50
|
-
...(steps.length > 0 ? ["Steps:", ...steps.map((step, index) => `${index + 1}. ${step}`)] : []),
|
|
51
|
-
...(tools.length > 0 ? [`Tools: ${tools.join(", ")}`] : []),
|
|
52
|
-
].join("\n");
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function skillRunTaskGraph(run: SkillWorkflowRunResult, taskArtifacts: Artifact[]): TaskGraph {
|
|
56
|
-
const executionById = new Map(run.execution.nodes.map((node) => [node.id, node]));
|
|
57
|
-
return {
|
|
58
|
-
nodes: taskArtifacts.map((task) => ({
|
|
59
|
-
task,
|
|
60
|
-
active: executionById.get(task.id)?.active === true,
|
|
61
|
-
parentIds: [],
|
|
62
|
-
childIds: [],
|
|
63
|
-
dependencyIds: executionById.get(task.id)?.prerequisiteIds ?? [],
|
|
64
|
-
})),
|
|
65
|
-
rootIds: run.rootTaskIds,
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
|
|
70
|
-
await showArtifactBrowser(ctx, {
|
|
71
|
-
kind: "skill",
|
|
72
|
-
title: "Skills",
|
|
73
|
-
listOperation: "skills.list",
|
|
74
|
-
statusOrder: ["active", "deprecated"],
|
|
75
|
-
presentation: SKILL_STATUS_PRESENTATION,
|
|
76
|
-
rowMeta: skillRowMeta,
|
|
77
|
-
actions: (skill) => [
|
|
78
|
-
"Show details",
|
|
79
|
-
"Edit",
|
|
80
|
-
skill.subtype === "artifact-template" ? "Use template" : skill.subtype === "workflow" ? "Run workflow" : "Invoke skill",
|
|
81
|
-
skill.status === "active" ? "Disable" : "Enable",
|
|
82
|
-
],
|
|
83
|
-
handleAction: async (choice, skill, commandCtx) => {
|
|
84
|
-
if (choice === "Show details") await showArtifactDetails(commandCtx, skill.id, "skills.show");
|
|
85
|
-
else if (choice === "Edit") {
|
|
86
|
-
const title = await commandCtx.ui.input("Title:", skill.title);
|
|
87
|
-
if (title === undefined) return; // canceled
|
|
88
|
-
const body = await commandCtx.ui.input("Body:", skill.body);
|
|
89
|
-
if (body === undefined) return; // canceled
|
|
90
|
-
const updated = await callService<Record<string, unknown>, Artifact>("skills.update", { id: skill.id, title, body });
|
|
91
|
-
commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
|
|
92
|
-
} else if (choice === "Run workflow") {
|
|
93
|
-
const source = await commandCtx.ui.input("Workflow arguments JSON:", "{}");
|
|
94
|
-
if (source === undefined) return;
|
|
95
|
-
try {
|
|
96
|
-
const arguments_ = JSON.parse(source) as unknown;
|
|
97
|
-
if (typeof arguments_ !== "object" || arguments_ === null || Array.isArray(arguments_)) {
|
|
98
|
-
throw new Error("arguments must be a JSON object");
|
|
99
|
-
}
|
|
100
|
-
const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", {
|
|
101
|
-
id: skill.id,
|
|
102
|
-
arguments: arguments_ as Record<string, unknown>,
|
|
103
|
-
project_root: commandCtx.cwd,
|
|
104
|
-
});
|
|
105
|
-
commandCtx.ui.notify([
|
|
106
|
-
`Created ${run.runId} · ${run.created.tasks.length} tasks · ${run.rootTaskIds.length} ready roots`,
|
|
107
|
-
`Context docs: ${run.created.docs.join(", ") || "none"}`,
|
|
108
|
-
`Scoped rules: ${run.created.rules.join(", ") || "none"}`,
|
|
109
|
-
].join("\n"), "info");
|
|
110
|
-
const taskArtifacts = await Promise.all(run.execution.nodes.map((node) =>
|
|
111
|
-
callService<Record<string, unknown>, Artifact>("tasks.show", { id: node.id })));
|
|
112
|
-
await showTaskGraph(commandCtx, skillRunTaskGraph(run, taskArtifacts));
|
|
113
|
-
} catch (error) {
|
|
114
|
-
commandCtx.ui.notify(`Workflow run failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
115
|
-
}
|
|
116
|
-
} else if (choice === "Invoke skill" || choice === "Use template") {
|
|
117
|
-
const invocation = await callService<Record<string, unknown>, string>("skills.invoke", { id: skill.id });
|
|
118
|
-
commandCtx.ui.setEditorText(invocation);
|
|
119
|
-
commandCtx.ui.notify("Invocation placed in the editor", "info");
|
|
120
|
-
} else {
|
|
121
|
-
const operation = choice === "Disable" ? "skills.disable" : "skills.enable";
|
|
122
|
-
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: skill.id });
|
|
123
|
-
commandCtx.ui.notify(`${updated.title} → [${updated.status}]`, "info");
|
|
124
|
-
}
|
|
125
|
-
},
|
|
126
|
-
});
|
|
127
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { taskContext } from "../../src/task-context.ts";
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
2
|
-
import type { TaskEvent } from "../../src/domain/task-event.ts";
|
|
3
|
-
import { checklistEntries, type ProofReference } from "../../src/domain/checklist.ts";
|
|
4
|
-
import { formatMetadata } from "./artifact-format.ts";
|
|
5
|
-
|
|
6
|
-
const TASK_STATUS_GLYPHS: Record<string, string> = {
|
|
7
|
-
todo: "○",
|
|
8
|
-
"in-progress": "●",
|
|
9
|
-
review: "◆",
|
|
10
|
-
rejected: "▲",
|
|
11
|
-
done: "■",
|
|
12
|
-
canceled: "×",
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
function proofLine(proof: ProofReference): string {
|
|
16
|
-
return `${proof.type} · ${proof.target}${proof.expect ? ` · ${proof.expect}` : ""}`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function checklistLines(value: unknown): string[] {
|
|
20
|
-
const entries = checklistEntries(value);
|
|
21
|
-
if (entries.length === 0) return [];
|
|
22
|
-
const lines = ["Checklist:"];
|
|
23
|
-
for (const entry of entries) {
|
|
24
|
-
lines.push(` • ${entry.item}`);
|
|
25
|
-
if (entry.proof.length === 0) {
|
|
26
|
-
lines.push(` proof: missing${entry.legacy ? " (legacy item)" : ""}`);
|
|
27
|
-
continue;
|
|
28
|
-
}
|
|
29
|
-
lines.push(" proof:");
|
|
30
|
-
for (const proof of entry.proof) lines.push(` - ${proofLine(proof)}`);
|
|
31
|
-
}
|
|
32
|
-
return lines;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function gateLines(value: unknown): string[] {
|
|
36
|
-
if (!Array.isArray(value) || value.length === 0) return [];
|
|
37
|
-
const lines = ["Validation gates:"];
|
|
38
|
-
for (const gate of value) {
|
|
39
|
-
if (typeof gate !== "object" || gate === null || Array.isArray(gate)) {
|
|
40
|
-
lines.push(" ? invalid gate configuration");
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
const record = gate as Record<string, unknown>;
|
|
44
|
-
const type = typeof record["type"] === "string" ? record["type"] : "unknown";
|
|
45
|
-
const target = typeof record["target"] === "string" ? record["target"] : "missing target";
|
|
46
|
-
const expect = typeof record["expect"] === "string" ? ` · ${record["expect"]}` : "";
|
|
47
|
-
lines.push(` ○ ${type} · ${target}${expect}`);
|
|
48
|
-
}
|
|
49
|
-
return lines;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function historyLines(history: TaskEvent[]): string[] {
|
|
53
|
-
if (history.length === 0) return ["History:", " (no post-migration events recorded)"];
|
|
54
|
-
const lines = ["History:"];
|
|
55
|
-
for (const event of history) {
|
|
56
|
-
const transition = event.fromStatus || event.toStatus ? ` · ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"}` : "";
|
|
57
|
-
const reason = event.reason ? ` · ${event.reason}` : "";
|
|
58
|
-
lines.push(` ${event.occurredAt} · ${event.type}${transition} · ${event.actor}/${event.source}${reason}`);
|
|
59
|
-
if (event.evidence?.result) lines.push(` result: ${event.evidence.result}`);
|
|
60
|
-
if (Array.isArray(event.evidence?.gates)) {
|
|
61
|
-
for (const value of event.evidence.gates) {
|
|
62
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
63
|
-
const result = value as Record<string, unknown>;
|
|
64
|
-
const gate = typeof result["gate"] === "object" && result["gate"] !== null ? result["gate"] as Record<string, unknown> : {};
|
|
65
|
-
const passed = result["passed"] === true;
|
|
66
|
-
lines.push(` ${passed ? "✓" : "✗"} ${String(gate["type"] ?? "gate")} · ${String(gate["target"] ?? "unknown")}`);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return lines;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface TaskDetailContent {
|
|
74
|
-
headline: string;
|
|
75
|
-
identity: string;
|
|
76
|
-
labels: string[];
|
|
77
|
-
body: string;
|
|
78
|
-
sections: string[][];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function taskDetailContent(task: Artifact, history: TaskEvent[] = []): TaskDetailContent {
|
|
82
|
-
const sections: string[][] = [];
|
|
83
|
-
const checklist = checklistLines(task.extra["checklist"]);
|
|
84
|
-
if (checklist.length > 0) sections.push(checklist);
|
|
85
|
-
const gates = gateLines(task.extra["gates"]);
|
|
86
|
-
if (gates.length > 0) sections.push(gates);
|
|
87
|
-
const metadata = Object.fromEntries(Object.entries(task.extra).filter(([key]) => key !== "checklist" && key !== "gates"));
|
|
88
|
-
if (Object.keys(metadata).length > 0) sections.push(["Metadata:", ...formatMetadata(metadata).map((line) => ` ${line}`)]);
|
|
89
|
-
sections.push(historyLines(history));
|
|
90
|
-
return {
|
|
91
|
-
headline: `${TASK_STATUS_GLYPHS[task.status] ?? "?"} ${task.title}`,
|
|
92
|
-
identity: `${task.id} [task|${task.status}]`,
|
|
93
|
-
labels: [...task.labels],
|
|
94
|
-
body: task.body || "(no body)",
|
|
95
|
-
sections,
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function taskDetailsText(task: Artifact, relationshipGraphLines: string[] = [], history: TaskEvent[] = []): string {
|
|
100
|
-
const content = taskDetailContent(task, history);
|
|
101
|
-
let output = `${content.headline}\n${content.identity}`;
|
|
102
|
-
if (content.labels.length > 0) output += `\nLabels: ${content.labels.join(", ")}`;
|
|
103
|
-
output += `\n\n${content.body}`;
|
|
104
|
-
for (const section of content.sections) output += `\n\n${section.join("\n")}`;
|
|
105
|
-
if (task.edges?.length) {
|
|
106
|
-
const graph = relationshipGraphLines.length > 0 ? relationshipGraphLines.join("\n") : " (graph unavailable)";
|
|
107
|
-
output += `\n\nRelationships:\n Dependencies point prerequisite → dependent.\n${graph}`;
|
|
108
|
-
}
|
|
109
|
-
return output;
|
|
110
|
-
}
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
-
import {
|
|
4
|
-
TASK_DETAIL_HORIZONTAL_PAN_COLUMNS,
|
|
5
|
-
TASK_DETAIL_MAX_VISIBLE_LINES,
|
|
6
|
-
TASK_DETAIL_MIN_VISIBLE_LINES,
|
|
7
|
-
TASK_DETAIL_RESERVED_ROWS,
|
|
8
|
-
} from "../../src/constants.ts";
|
|
9
|
-
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
10
|
-
import type { TaskEvent } from "../../src/domain/task-event.ts";
|
|
11
|
-
import type { GraphRenderer } from "../../src/ports/graph-renderer.ts";
|
|
12
|
-
import { projectTaskRelationships } from "../../src/task-relationship-view.ts";
|
|
13
|
-
import type { TaskGraph } from "../../src/task-service.ts";
|
|
14
|
-
import { BeautifulMermaidRenderer } from "./beautiful-mermaid-renderer.ts";
|
|
15
|
-
import { taskDetailContent, taskDetailsText, type TaskDetailContent } from "./task-detail-format.ts";
|
|
16
|
-
import { renderMarkdownBody, type ActiveTheme } from "./markdown.ts";
|
|
17
|
-
import { TASK_STATUS_PRESENTATION } from "./task-presentation.ts";
|
|
18
|
-
|
|
19
|
-
interface DetailLine {
|
|
20
|
-
text: string;
|
|
21
|
-
graph: boolean;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
class TaskDetailViewport {
|
|
25
|
-
private offsetX = 0;
|
|
26
|
-
private offsetY = 0;
|
|
27
|
-
private renderedWidth = 0;
|
|
28
|
-
private detailLines: DetailLine[] = [];
|
|
29
|
-
private readonly visibleLines: number;
|
|
30
|
-
private readonly content: TaskDetailContent;
|
|
31
|
-
private readonly status: Artifact["status"];
|
|
32
|
-
|
|
33
|
-
constructor(
|
|
34
|
-
private readonly tui: TUI,
|
|
35
|
-
private readonly activeTheme: ActiveTheme,
|
|
36
|
-
task: Artifact,
|
|
37
|
-
private readonly graphLines: string[],
|
|
38
|
-
history: TaskEvent[],
|
|
39
|
-
private readonly close: () => void,
|
|
40
|
-
) {
|
|
41
|
-
this.visibleLines = Math.max(
|
|
42
|
-
TASK_DETAIL_MIN_VISIBLE_LINES,
|
|
43
|
-
Math.min(TASK_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - TASK_DETAIL_RESERVED_ROWS),
|
|
44
|
-
);
|
|
45
|
-
this.content = taskDetailContent(task, history);
|
|
46
|
-
this.status = task.status;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
invalidate(): void { this.renderedWidth = 0; }
|
|
50
|
-
|
|
51
|
-
render(width: number): string[] {
|
|
52
|
-
const contentWidth = Math.max(1, width - 2);
|
|
53
|
-
this.buildLines(contentWidth);
|
|
54
|
-
const graphWidth = this.graphLines.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0);
|
|
55
|
-
this.offsetX = Math.min(this.offsetX, Math.max(0, graphWidth - contentWidth));
|
|
56
|
-
this.offsetY = Math.min(this.offsetY, Math.max(0, this.detailLines.length - this.visibleLines));
|
|
57
|
-
const end = Math.min(this.detailLines.length, this.offsetY + this.visibleLines);
|
|
58
|
-
const theme = this.activeTheme();
|
|
59
|
-
const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
|
|
60
|
-
const footer = [
|
|
61
|
-
graphWidth > contentWidth ? `←/→ graph · column ${this.offsetX + 1}/${graphWidth}` : "",
|
|
62
|
-
this.detailLines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.detailLines.length}` : "",
|
|
63
|
-
"Esc back",
|
|
64
|
-
].filter(Boolean).join(" · ");
|
|
65
|
-
return [
|
|
66
|
-
border,
|
|
67
|
-
truncateToWidth(theme.fg("accent", theme.bold("Task details")), width, ""),
|
|
68
|
-
border,
|
|
69
|
-
...this.detailLines.slice(this.offsetY, end).map((line) => line.graph
|
|
70
|
-
? ` ${sliceByColumn(line.text, this.offsetX, contentWidth, true)}`
|
|
71
|
-
: truncateToWidth(` ${line.text}`, width, "")),
|
|
72
|
-
truncateToWidth(theme.fg("dim", footer), width, ""),
|
|
73
|
-
border,
|
|
74
|
-
];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
handleInput(data: string): void {
|
|
78
|
-
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
|
|
79
|
-
if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
80
|
-
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.detailLines.length - this.visibleLines), this.offsetY + 1);
|
|
81
|
-
else if (matchesKey(data, "left")) this.offsetX = Math.max(0, this.offsetX - TASK_DETAIL_HORIZONTAL_PAN_COLUMNS);
|
|
82
|
-
else if (matchesKey(data, "right")) this.offsetX += TASK_DETAIL_HORIZONTAL_PAN_COLUMNS;
|
|
83
|
-
else return;
|
|
84
|
-
this.tui.requestRender();
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
private buildLines(width: number): void {
|
|
88
|
-
if (this.renderedWidth === width) return;
|
|
89
|
-
this.renderedWidth = width;
|
|
90
|
-
const theme = this.activeTheme();
|
|
91
|
-
const wrap = (text: string, color: "text" | "muted" | "dim" = "text"): DetailLine[] =>
|
|
92
|
-
(text.length === 0 ? [""] : wrapTextWithAnsi(theme.fg(color, text), width)).map((line) => ({ text: line, graph: false }));
|
|
93
|
-
const status = TASK_STATUS_PRESENTATION[this.status as keyof typeof TASK_STATUS_PRESENTATION];
|
|
94
|
-
const headline = status ? theme.fg(status.color, theme.bold(this.content.headline)) : theme.bold(this.content.headline);
|
|
95
|
-
const identity = [
|
|
96
|
-
...wrapTextWithAnsi(headline, width).map((text) => ({ text, graph: false })),
|
|
97
|
-
...wrap(this.content.identity, "muted"),
|
|
98
|
-
...(this.content.labels.length > 0 ? wrap(`Labels: ${this.content.labels.join(", ")}`, "muted") : []),
|
|
99
|
-
{ text: "", graph: false },
|
|
100
|
-
];
|
|
101
|
-
const body = renderMarkdownBody(this.content.body, width, this.activeTheme).map((text) => ({ text, graph: false }));
|
|
102
|
-
const sections = this.content.sections.flatMap((section) => [
|
|
103
|
-
{ text: "", graph: false },
|
|
104
|
-
...section.flatMap((line, index) => wrap(line, index === 0 ? "muted" : "dim")),
|
|
105
|
-
]);
|
|
106
|
-
const relationshipHeader = this.graphLines.length > 0
|
|
107
|
-
? [
|
|
108
|
-
{ text: "", graph: false },
|
|
109
|
-
...wrap("Relationships:", "muted"),
|
|
110
|
-
...wrap(" Dependencies point prerequisite → dependent.", "dim"),
|
|
111
|
-
]
|
|
112
|
-
: [];
|
|
113
|
-
this.detailLines = [
|
|
114
|
-
...identity,
|
|
115
|
-
...body,
|
|
116
|
-
...sections,
|
|
117
|
-
...relationshipHeader,
|
|
118
|
-
...this.graphLines.map((text) => ({ text: theme.fg("text", text), graph: true })),
|
|
119
|
-
];
|
|
120
|
-
this.offsetY = Math.min(this.offsetY, Math.max(0, this.detailLines.length - this.visibleLines));
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export async function showTaskDetails(
|
|
125
|
-
ctx: ExtensionCommandContext,
|
|
126
|
-
task: Artifact,
|
|
127
|
-
graph?: TaskGraph,
|
|
128
|
-
renderer: GraphRenderer = new BeautifulMermaidRenderer(),
|
|
129
|
-
history: TaskEvent[] = [],
|
|
130
|
-
): Promise<void> {
|
|
131
|
-
const relationshipGraph = renderer.render(projectTaskRelationships(task, graph)).lines;
|
|
132
|
-
const content = taskDetailsText(task, relationshipGraph, history);
|
|
133
|
-
if (ctx.mode !== "tui") {
|
|
134
|
-
ctx.ui.notify(content, "info");
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
|
|
138
|
-
new TaskDetailViewport(tui, () => ctx.ui.theme ?? theme, task, relationshipGraph, history, done));
|
|
139
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { PAPYRUS_TASK_FOCUS_CHANNEL, PAPYRUS_TASK_FOCUS_SCHEMA } from "../../src/constants.ts";
|
|
3
|
-
|
|
4
|
-
export type TaskFocusStatus = "focused" | "paused" | "unpaused" | "cleared";
|
|
5
|
-
|
|
6
|
-
export interface TaskFocusEvent {
|
|
7
|
-
schema: typeof PAPYRUS_TASK_FOCUS_SCHEMA;
|
|
8
|
-
taskId: string | null;
|
|
9
|
-
sessionId?: string;
|
|
10
|
-
status: TaskFocusStatus;
|
|
11
|
-
observedAt: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface TaskFocusEventInput {
|
|
15
|
-
taskId: string | null;
|
|
16
|
-
sessionId?: string;
|
|
17
|
-
status: TaskFocusStatus;
|
|
18
|
-
observedAt?: number;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Pure event builder, mirroring buildContextInjection's shape: no task title, body, or any other
|
|
23
|
-
* artifact content -- only the id, session, lifecycle status, and timestamp, which are already
|
|
24
|
-
* public metadata a caller with the id could look up directly. This is the payload emitted on
|
|
25
|
-
* papyrus.task-focus.v1, the analogue of papyrus.context-injection.v1, so extensions such as a
|
|
26
|
-
* token-cost router can correlate their own telemetry with the currently focused task without
|
|
27
|
-
* Papyrus depending on them.
|
|
28
|
-
*/
|
|
29
|
-
export function buildTaskFocusEvent(input: TaskFocusEventInput): TaskFocusEvent {
|
|
30
|
-
if (input.status !== "cleared" && input.taskId === null) throw new Error(`task-focus event of status "${input.status}" requires a taskId`);
|
|
31
|
-
return {
|
|
32
|
-
schema: PAPYRUS_TASK_FOCUS_SCHEMA,
|
|
33
|
-
taskId: input.taskId,
|
|
34
|
-
status: input.status,
|
|
35
|
-
observedAt: input.observedAt ?? Date.now(),
|
|
36
|
-
...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }),
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
type EventBusHost = Pick<ExtensionAPI, "events">;
|
|
41
|
-
|
|
42
|
-
let bus: EventBusHost | undefined;
|
|
43
|
-
|
|
44
|
-
/** Call once from the extension entry point so call sites that only receive `ctx` (not `pi`) can still emit. */
|
|
45
|
-
export function setTaskFocusEventBus(host: EventBusHost): void {
|
|
46
|
-
bus = host;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function resetTaskFocusEventBusForTests(): void {
|
|
50
|
-
bus = undefined;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Best-effort broadcast: never throws, since a missing bus (e.g. an uninitialized test harness) must not break the focus operation it accompanies. */
|
|
54
|
-
export function emitTaskFocusEvent(input: TaskFocusEventInput): void {
|
|
55
|
-
if (!bus) return;
|
|
56
|
-
bus.events.emit(PAPYRUS_TASK_FOCUS_CHANNEL, buildTaskFocusEvent(input));
|
|
57
|
-
}
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
-
import {
|
|
4
|
-
TASK_GRAPH_HORIZONTAL_PAN_COLUMNS,
|
|
5
|
-
TASK_GRAPH_MAX_VISIBLE_LINES,
|
|
6
|
-
TASK_GRAPH_MIN_VISIBLE_LINES,
|
|
7
|
-
TASK_GRAPH_RESERVED_ROWS,
|
|
8
|
-
} from "../../src/constants.ts";
|
|
9
|
-
import type { GraphRenderer } from "../../src/ports/graph-renderer.ts";
|
|
10
|
-
import { projectTaskGraph, type TaskGraphView } from "../../src/task-graph-view.ts";
|
|
11
|
-
import type { TaskGraph } from "../../src/task-service.ts";
|
|
12
|
-
import { BeautifulMermaidRenderer } from "./beautiful-mermaid-renderer.ts";
|
|
13
|
-
import { TASK_STATUS_PRESENTATION } from "./task-presentation.ts";
|
|
14
|
-
|
|
15
|
-
const GRAPH_VIEWS: TaskGraphView[] = ["execution", "dependencies", "composition"];
|
|
16
|
-
|
|
17
|
-
export function colorizeTaskGraphLine(theme: Theme, line: string): string {
|
|
18
|
-
let colored = line;
|
|
19
|
-
for (const presentation of Object.values(TASK_STATUS_PRESENTATION)) {
|
|
20
|
-
colored = colored.replaceAll(presentation.glyph, theme.fg(presentation.color, presentation.glyph));
|
|
21
|
-
}
|
|
22
|
-
return colored.replaceAll("▶", theme.fg("accent", "▶"));
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export class TaskGraphViewport {
|
|
26
|
-
private viewIndex = 0;
|
|
27
|
-
private offsetX = 0;
|
|
28
|
-
private offsetY = 0;
|
|
29
|
-
private graphLines: string[] = [];
|
|
30
|
-
private readonly viewportHeight: number;
|
|
31
|
-
|
|
32
|
-
constructor(
|
|
33
|
-
private readonly tui: TUI,
|
|
34
|
-
private readonly theme: Theme,
|
|
35
|
-
private readonly graph: TaskGraph,
|
|
36
|
-
private readonly renderer: GraphRenderer,
|
|
37
|
-
private readonly close: () => void,
|
|
38
|
-
) {
|
|
39
|
-
this.viewportHeight = Math.max(
|
|
40
|
-
TASK_GRAPH_MIN_VISIBLE_LINES,
|
|
41
|
-
Math.min(TASK_GRAPH_MAX_VISIBLE_LINES, tui.terminal.rows - TASK_GRAPH_RESERVED_ROWS),
|
|
42
|
-
);
|
|
43
|
-
this.rebuild();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
invalidate(): void {}
|
|
47
|
-
|
|
48
|
-
render(width: number): string[] {
|
|
49
|
-
const contentWidth = Math.max(1, width);
|
|
50
|
-
const graphWidth = this.graphLines.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0);
|
|
51
|
-
this.offsetX = Math.min(this.offsetX, Math.max(0, graphWidth - contentWidth));
|
|
52
|
-
this.offsetY = Math.min(this.offsetY, Math.max(0, this.graphLines.length - this.viewportHeight));
|
|
53
|
-
const end = Math.min(this.graphLines.length, this.offsetY + this.viewportHeight);
|
|
54
|
-
const border = this.theme.fg("borderMuted", "─".repeat(contentWidth));
|
|
55
|
-
const position = graphWidth > contentWidth || this.graphLines.length > this.viewportHeight
|
|
56
|
-
? ` · column ${this.offsetX + 1}/${Math.max(contentWidth, graphWidth)} · row ${this.offsetY + 1}/${this.graphLines.length}`
|
|
57
|
-
: "";
|
|
58
|
-
return [
|
|
59
|
-
border,
|
|
60
|
-
truncateToWidth(this.theme.bold(`Task graph · ${GRAPH_VIEWS[this.viewIndex]}`), contentWidth, ""),
|
|
61
|
-
truncateToWidth(this.theme.fg("dim", `Tab switch · arrows pan · Esc back${position}`), contentWidth, ""),
|
|
62
|
-
border,
|
|
63
|
-
...this.graphLines.slice(this.offsetY, end).map((line) =>
|
|
64
|
-
colorizeTaskGraphLine(this.theme, sliceByColumn(line, this.offsetX, contentWidth, true))),
|
|
65
|
-
border,
|
|
66
|
-
];
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
handleInput(data: string): void {
|
|
70
|
-
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
|
|
71
|
-
if (matchesKey(data, "tab")) this.switchView();
|
|
72
|
-
else if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
73
|
-
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.graphLines.length - this.viewportHeight), this.offsetY + 1);
|
|
74
|
-
else if (matchesKey(data, "left")) this.offsetX = Math.max(0, this.offsetX - TASK_GRAPH_HORIZONTAL_PAN_COLUMNS);
|
|
75
|
-
else if (matchesKey(data, "right")) this.offsetX += TASK_GRAPH_HORIZONTAL_PAN_COLUMNS;
|
|
76
|
-
else return;
|
|
77
|
-
this.tui.requestRender();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
private switchView(): void {
|
|
81
|
-
this.viewIndex = (this.viewIndex + 1) % GRAPH_VIEWS.length;
|
|
82
|
-
this.offsetX = 0;
|
|
83
|
-
this.offsetY = 0;
|
|
84
|
-
this.rebuild();
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
private rebuild(): void {
|
|
88
|
-
const view = GRAPH_VIEWS[this.viewIndex]!;
|
|
89
|
-
try {
|
|
90
|
-
this.graphLines = this.renderer.render(projectTaskGraph(this.graph, view)).lines;
|
|
91
|
-
if (this.graphLines.length === 0) this.graphLines = [`No task ${view} relationships`];
|
|
92
|
-
} catch {
|
|
93
|
-
this.graphLines = [
|
|
94
|
-
"┌─ Task graph ─",
|
|
95
|
-
`│ Graph rendering failed for ${view} view.`,
|
|
96
|
-
"│ Press Tab for another view or Esc to close.",
|
|
97
|
-
"└─",
|
|
98
|
-
];
|
|
99
|
-
}
|
|
100
|
-
this.offsetY = Math.min(this.offsetY, Math.max(0, this.graphLines.length - this.viewportHeight));
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export async function showTaskGraph(
|
|
105
|
-
ctx: ExtensionCommandContext,
|
|
106
|
-
graph: TaskGraph,
|
|
107
|
-
renderer: GraphRenderer = new BeautifulMermaidRenderer(),
|
|
108
|
-
): Promise<void> {
|
|
109
|
-
if (ctx.mode !== "tui") {
|
|
110
|
-
const rendered = renderer.render(projectTaskGraph(graph, "execution"));
|
|
111
|
-
ctx.ui.notify(rendered.lines.join("\n") || "No tasks in the execution graph", "info");
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
|
|
115
|
-
new TaskGraphViewport(tui, theme, graph, renderer, done));
|
|
116
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { TaskStatus } from "../../src/task-service.ts";
|
|
3
|
-
|
|
4
|
-
export interface TaskStatusPresentation {
|
|
5
|
-
label: string;
|
|
6
|
-
glyph: string;
|
|
7
|
-
color: ThemeColor;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export const TASK_STATUS_PRESENTATION: Record<TaskStatus, TaskStatusPresentation> = {
|
|
11
|
-
todo: { label: "To-Do", glyph: "○", color: "muted" },
|
|
12
|
-
"in-progress": { label: "in-progress", glyph: "●", color: "warning" },
|
|
13
|
-
review: { label: "review", glyph: "◆", color: "mdLink" },
|
|
14
|
-
rejected: { label: "rejected", glyph: "▲", color: "accent" },
|
|
15
|
-
done: { label: "done", glyph: "■", color: "success" },
|
|
16
|
-
canceled: { label: "canceled", glyph: "×", color: "error" },
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export function taskTreeConnector(options: {
|
|
20
|
-
depth: number;
|
|
21
|
-
hasChildren: boolean;
|
|
22
|
-
hasLaterSibling: boolean;
|
|
23
|
-
}): string {
|
|
24
|
-
if (options.depth === 0) return options.hasChildren ? "▾" : "·";
|
|
25
|
-
return `${"│ ".repeat(Math.max(0, options.depth - 1))}${options.hasLaterSibling ? "├─" : "└─"}`;
|
|
26
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { TASK_WIDGET_OPEN_LIMIT } from "../../src/constants.ts";
|
|
2
|
-
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
3
|
-
import type { TaskGraph } from "../../src/task-service.ts";
|
|
4
|
-
|
|
5
|
-
export interface TaskWidgetRow {
|
|
6
|
-
task: Artifact;
|
|
7
|
-
depth: number;
|
|
8
|
-
hasOpenChildren: boolean;
|
|
9
|
-
active: boolean;
|
|
10
|
-
focusStatus?: "active" | "paused";
|
|
11
|
-
/**
|
|
12
|
-
* Task containment is a DAG, not a tree: a task may have more than one parent (design
|
|
13
|
-
* decision -- see decide-and-execute... no single-parent enforcement was ever wanted).
|
|
14
|
-
* This bounded widget still renders one spanning tree (it only has room for one position
|
|
15
|
-
* per task), so a multi-parent task is only ever shown once, under whichever parent this
|
|
16
|
-
* walk reaches first -- exactly the git-log-graph / npm-ls-dedup pattern of picking one
|
|
17
|
-
* canonical position and flagging the rest, rather than silently dropping the information.
|
|
18
|
-
* parentCount > 1 means "this task also lives under other parents not shown here" --
|
|
19
|
-
* the full DAG (every parent edge, not just one) is always available via the task graph's
|
|
20
|
-
* composition view, which renders true multi-parent edges through Mermaid's flowchart layout.
|
|
21
|
-
*/
|
|
22
|
-
parentCount: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface TaskWidgetProjection {
|
|
26
|
-
rows: TaskWidgetRow[];
|
|
27
|
-
openTotal: number;
|
|
28
|
-
total: number;
|
|
29
|
-
scopeLabel: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function isOpen(task: Artifact): boolean {
|
|
33
|
-
return task.status !== "done" && task.status !== "canceled";
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Keep bounded actionable work in containment order while always retaining active focus. */
|
|
37
|
-
export function buildTaskWidgetProjection(
|
|
38
|
-
graph: TaskGraph,
|
|
39
|
-
openLimit = TASK_WIDGET_OPEN_LIMIT,
|
|
40
|
-
): TaskWidgetProjection {
|
|
41
|
-
const byId = new Map(graph.nodes.map((node) => [node.task.id, node]));
|
|
42
|
-
const visited = new Set<string>();
|
|
43
|
-
const ordered: TaskWidgetRow[] = [];
|
|
44
|
-
|
|
45
|
-
const visit = (id: string, openDepth: number): void => {
|
|
46
|
-
if (visited.has(id)) return;
|
|
47
|
-
const node = byId.get(id);
|
|
48
|
-
if (!node) return;
|
|
49
|
-
visited.add(id);
|
|
50
|
-
const open = isOpen(node.task);
|
|
51
|
-
if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true, focusStatus: node.focusStatus, parentCount: node.parentIds.length });
|
|
52
|
-
const childDepth = open ? openDepth + 1 : openDepth;
|
|
53
|
-
for (const childId of node.childIds) visit(childId, childDepth);
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
for (const rootId of graph.rootIds) visit(rootId, 0);
|
|
57
|
-
for (const node of graph.nodes) visit(node.task.id, 0);
|
|
58
|
-
for (let index = 0; index < ordered.length - 1; index++) {
|
|
59
|
-
ordered[index]!.hasOpenChildren = ordered[index + 1]!.depth > ordered[index]!.depth;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const limit = Math.max(0, openLimit);
|
|
63
|
-
let rows = ordered.slice(0, limit);
|
|
64
|
-
const active = ordered.find((row) => row.active);
|
|
65
|
-
if (active && !rows.some((row) => row.task.id === active.task.id) && limit > 0) {
|
|
66
|
-
rows = [...rows.slice(0, Math.max(0, limit - 1)), active]
|
|
67
|
-
.sort((left, right) => ordered.indexOf(left) - ordered.indexOf(right));
|
|
68
|
-
}
|
|
69
|
-
return { rows, openTotal: ordered.length, total: graph.nodes.length, scopeLabel: graph.scope?.label ?? "All projects" };
|
|
70
|
-
}
|