@danypops/papyrus 0.1.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 +139 -0
- package/extension/src/artifact-browser.ts +213 -0
- package/extension/src/artifact-format.ts +82 -0
- package/extension/src/beautiful-mermaid-renderer.ts +45 -0
- package/extension/src/docs.ts +48 -0
- package/extension/src/facade-tools.ts +209 -0
- package/extension/src/index.ts +354 -0
- package/extension/src/rules.ts +44 -0
- package/extension/src/service-client.ts +45 -0
- package/extension/src/skills.ts +60 -0
- package/extension/src/task-context.ts +1 -0
- package/extension/src/task-detail-format.ts +66 -0
- package/extension/src/task-detail-view.ts +111 -0
- package/extension/src/task-graph.ts +97 -0
- package/extension/src/task-widget.ts +49 -0
- package/extension/src/tasks.ts +258 -0
- package/package.json +43 -0
- package/src/adapters/sqlite-artifact-store.ts +64 -0
- package/src/adapters/sqlite-gate-runner.ts +16 -0
- package/src/cli.ts +71 -0
- package/src/client.ts +59 -0
- package/src/constants.ts +113 -0
- package/src/daemon-state.ts +59 -0
- package/src/daemon.ts +41 -0
- package/src/db.ts +138 -0
- package/src/domain/artifact.ts +56 -0
- package/src/domain/checklist.ts +70 -0
- package/src/domain/display-graph.ts +23 -0
- package/src/domain/gate.ts +11 -0
- package/src/facades.ts +215 -0
- package/src/ops.ts +336 -0
- package/src/ports/artifact-store.ts +19 -0
- package/src/ports/gate-runner.ts +6 -0
- package/src/ports/graph-renderer.ts +5 -0
- package/src/service.ts +292 -0
- package/src/task-context.ts +52 -0
- package/src/task-graph-view.ts +34 -0
- package/src/task-relationship-view.ts +39 -0
- package/src/task-service.ts +176 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
3
|
+
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
4
|
+
import { callService } from "./service-client.ts";
|
|
5
|
+
|
|
6
|
+
const SKILL_GLYPHS: Record<string, string> = { active: "●", deprecated: "○" };
|
|
7
|
+
|
|
8
|
+
function strings(value: unknown): string[] {
|
|
9
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function skillRowMeta(skill: Artifact): string {
|
|
13
|
+
if (skill.subtype === "artifact-template") {
|
|
14
|
+
const target = typeof skill.extra["targetKind"] === "string" ? skill.extra["targetKind"] : "artifact";
|
|
15
|
+
return `template → ${target}`;
|
|
16
|
+
}
|
|
17
|
+
const trigger = typeof skill.extra["trigger"] === "string" ? `when ${skill.extra["trigger"]}` : "manual";
|
|
18
|
+
const tools = strings(skill.extra["tools"]);
|
|
19
|
+
return [trigger, tools.join(", ")].filter(Boolean).join(" · ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function skillInvocationPrompt(skill: Artifact): string {
|
|
23
|
+
if (skill.subtype === "artifact-template") {
|
|
24
|
+
return [`Create an artifact using Papyrus template \"${skill.title}\".`, `template_id: ${skill.id}`, "Ask for or infer the title and all required template fields, then call papyrus_create."].join("\n");
|
|
25
|
+
}
|
|
26
|
+
const trigger = typeof skill.extra["trigger"] === "string" ? skill.extra["trigger"] : "manual invocation";
|
|
27
|
+
const steps = strings(skill.extra["steps"]);
|
|
28
|
+
const tools = strings(skill.extra["tools"]);
|
|
29
|
+
return [
|
|
30
|
+
`Apply Papyrus skill \"${skill.title}\" (${skill.id}).`,
|
|
31
|
+
`Trigger: ${trigger}`,
|
|
32
|
+
...(skill.body ? [`Context: ${skill.body}`] : []),
|
|
33
|
+
...(steps.length > 0 ? ["Steps:", ...steps.map((step, index) => `${index + 1}. ${step}`)] : []),
|
|
34
|
+
...(tools.length > 0 ? [`Tools: ${tools.join(", ")}`] : []),
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
|
|
39
|
+
await showArtifactBrowser(ctx, {
|
|
40
|
+
kind: "skill",
|
|
41
|
+
title: "Skills",
|
|
42
|
+
listOperation: "skills.list",
|
|
43
|
+
statusOrder: ["active", "deprecated"],
|
|
44
|
+
glyphs: SKILL_GLYPHS,
|
|
45
|
+
rowMeta: skillRowMeta,
|
|
46
|
+
actions: (skill) => ["Show details", skill.subtype === "artifact-template" ? "Use template" : "Invoke skill", skill.status === "active" ? "Disable" : "Enable"],
|
|
47
|
+
handleAction: async (choice, skill, commandCtx) => {
|
|
48
|
+
if (choice === "Show details") await showArtifactDetails(commandCtx, skill.id, "skills.show");
|
|
49
|
+
else if (choice === "Invoke skill" || choice === "Use template") {
|
|
50
|
+
const invocation = await callService<Record<string, unknown>, string>("skills.invoke", { id: skill.id });
|
|
51
|
+
commandCtx.ui.setEditorText(invocation);
|
|
52
|
+
commandCtx.ui.notify("Invocation placed in the editor", "info");
|
|
53
|
+
} else {
|
|
54
|
+
const operation = choice === "Disable" ? "skills.disable" : "skills.enable";
|
|
55
|
+
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: skill.id });
|
|
56
|
+
commandCtx.ui.notify(`${updated.id} → [${updated.status}]`, "info");
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { taskContext } from "../../src/task-context.ts";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
2
|
+
import { checklistEntries, type ProofReference } from "../../src/domain/checklist.ts";
|
|
3
|
+
import { formatMetadata } from "./artifact-format.ts";
|
|
4
|
+
|
|
5
|
+
const TASK_STATUS_GLYPHS: Record<string, string> = {
|
|
6
|
+
pending: "○",
|
|
7
|
+
active: "●",
|
|
8
|
+
done: "■",
|
|
9
|
+
failed: "▲",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function proofLine(proof: ProofReference): string {
|
|
13
|
+
return `${proof.type} · ${proof.target}${proof.expect ? ` · ${proof.expect}` : ""}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function checklistLines(value: unknown): string[] {
|
|
17
|
+
const entries = checklistEntries(value);
|
|
18
|
+
if (entries.length === 0) return [];
|
|
19
|
+
const lines = ["Checklist:"];
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
lines.push(` • ${entry.item}`);
|
|
22
|
+
if (entry.proof.length === 0) {
|
|
23
|
+
lines.push(` proof: missing${entry.legacy ? " (legacy item)" : ""}`);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
lines.push(" proof:");
|
|
27
|
+
for (const proof of entry.proof) lines.push(` - ${proofLine(proof)}`);
|
|
28
|
+
}
|
|
29
|
+
return lines;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function gateLines(value: unknown): string[] {
|
|
33
|
+
if (!Array.isArray(value) || value.length === 0) return [];
|
|
34
|
+
const lines = ["Validation gates:"];
|
|
35
|
+
for (const gate of value) {
|
|
36
|
+
if (typeof gate !== "object" || gate === null || Array.isArray(gate)) {
|
|
37
|
+
lines.push(" ? invalid gate configuration");
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const record = gate as Record<string, unknown>;
|
|
41
|
+
const type = typeof record["type"] === "string" ? record["type"] : "unknown";
|
|
42
|
+
const target = typeof record["target"] === "string" ? record["target"] : "missing target";
|
|
43
|
+
const expect = typeof record["expect"] === "string" ? ` · ${record["expect"]}` : "";
|
|
44
|
+
lines.push(` ○ ${type} · ${target}${expect}`);
|
|
45
|
+
}
|
|
46
|
+
return lines;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function taskDetailsText(task: Artifact, relationshipGraphLines: string[] = []): string {
|
|
50
|
+
let output = `${TASK_STATUS_GLYPHS[task.status] ?? "?"} ${task.title}\n${task.id} [task|${task.status}]`;
|
|
51
|
+
if (task.labels.length > 0) output += `\nLabels: ${task.labels.join(", ")}`;
|
|
52
|
+
output += `\n\n${task.body || "(no body)"}`;
|
|
53
|
+
const checklist = checklistLines(task.extra["checklist"]);
|
|
54
|
+
if (checklist.length > 0) output += `\n\n${checklist.join("\n")}`;
|
|
55
|
+
const gates = gateLines(task.extra["gates"]);
|
|
56
|
+
if (gates.length > 0) output += `\n\n${gates.join("\n")}`;
|
|
57
|
+
const metadata = Object.fromEntries(Object.entries(task.extra).filter(([key]) => key !== "checklist" && key !== "gates"));
|
|
58
|
+
if (Object.keys(metadata).length > 0) {
|
|
59
|
+
output += `\n\nMetadata:\n${formatMetadata(metadata).map((line) => ` ${line}`).join("\n")}`;
|
|
60
|
+
}
|
|
61
|
+
if (task.edges?.length) {
|
|
62
|
+
const graph = relationshipGraphLines.length > 0 ? relationshipGraphLines.join("\n") : " (graph unavailable)";
|
|
63
|
+
output += `\n\nRelationships:\n Dependencies point prerequisite → dependent.\n${graph}`;
|
|
64
|
+
}
|
|
65
|
+
return output;
|
|
66
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme } 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 { GraphRenderer } from "../../src/ports/graph-renderer.ts";
|
|
11
|
+
import { projectTaskRelationships } from "../../src/task-relationship-view.ts";
|
|
12
|
+
import type { TaskGraph } from "../../src/task-service.ts";
|
|
13
|
+
import { BeautifulMermaidRenderer } from "./beautiful-mermaid-renderer.ts";
|
|
14
|
+
import { taskDetailsText } from "./task-detail-format.ts";
|
|
15
|
+
|
|
16
|
+
interface DetailLine {
|
|
17
|
+
text: string;
|
|
18
|
+
graph: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class TaskDetailViewport {
|
|
22
|
+
private offsetX = 0;
|
|
23
|
+
private offsetY = 0;
|
|
24
|
+
private renderedWidth = 0;
|
|
25
|
+
private detailLines: DetailLine[] = [];
|
|
26
|
+
private readonly visibleLines: number;
|
|
27
|
+
private readonly narrative: string;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly tui: TUI,
|
|
31
|
+
private readonly theme: Theme,
|
|
32
|
+
task: Artifact,
|
|
33
|
+
private readonly graphLines: string[],
|
|
34
|
+
private readonly close: () => void,
|
|
35
|
+
) {
|
|
36
|
+
this.visibleLines = Math.max(
|
|
37
|
+
TASK_DETAIL_MIN_VISIBLE_LINES,
|
|
38
|
+
Math.min(TASK_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - TASK_DETAIL_RESERVED_ROWS),
|
|
39
|
+
);
|
|
40
|
+
this.narrative = taskDetailsText({ ...task, edges: undefined });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
invalidate(): void { this.renderedWidth = 0; }
|
|
44
|
+
|
|
45
|
+
render(width: number): string[] {
|
|
46
|
+
const contentWidth = Math.max(1, width - 2);
|
|
47
|
+
this.buildLines(contentWidth);
|
|
48
|
+
const graphWidth = this.graphLines.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0);
|
|
49
|
+
this.offsetX = Math.min(this.offsetX, Math.max(0, graphWidth - contentWidth));
|
|
50
|
+
this.offsetY = Math.min(this.offsetY, Math.max(0, this.detailLines.length - this.visibleLines));
|
|
51
|
+
const end = Math.min(this.detailLines.length, this.offsetY + this.visibleLines);
|
|
52
|
+
const border = this.theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
|
|
53
|
+
const footer = [
|
|
54
|
+
graphWidth > contentWidth ? `←/→ graph · column ${this.offsetX + 1}/${graphWidth}` : "",
|
|
55
|
+
this.detailLines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.detailLines.length}` : "",
|
|
56
|
+
"Esc back",
|
|
57
|
+
].filter(Boolean).join(" · ");
|
|
58
|
+
return [
|
|
59
|
+
border,
|
|
60
|
+
truncateToWidth(this.theme.bold("Task details"), width, ""),
|
|
61
|
+
border,
|
|
62
|
+
...this.detailLines.slice(this.offsetY, end).map((line) => line.graph
|
|
63
|
+
? ` ${sliceByColumn(line.text, this.offsetX, contentWidth, true)}`
|
|
64
|
+
: truncateToWidth(` ${line.text}`, width, "")),
|
|
65
|
+
truncateToWidth(this.theme.fg("dim", footer), width, ""),
|
|
66
|
+
border,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
handleInput(data: string): void {
|
|
71
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
|
|
72
|
+
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.detailLines.length - this.visibleLines), this.offsetY + 1);
|
|
74
|
+
else if (matchesKey(data, "left")) this.offsetX = Math.max(0, this.offsetX - TASK_DETAIL_HORIZONTAL_PAN_COLUMNS);
|
|
75
|
+
else if (matchesKey(data, "right")) this.offsetX += TASK_DETAIL_HORIZONTAL_PAN_COLUMNS;
|
|
76
|
+
else return;
|
|
77
|
+
this.tui.requestRender();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private buildLines(width: number): void {
|
|
81
|
+
if (this.renderedWidth === width) return;
|
|
82
|
+
this.renderedWidth = width;
|
|
83
|
+
const narrative = this.narrative.split("\n").flatMap((line) =>
|
|
84
|
+
(line.length === 0 ? [""] : wrapTextWithAnsi(line, width)).map((text) => ({ text, graph: false })));
|
|
85
|
+
const relationshipHeader = this.graphLines.length > 0
|
|
86
|
+
? [
|
|
87
|
+
{ text: "", graph: false },
|
|
88
|
+
{ text: "Relationships:", graph: false },
|
|
89
|
+
{ text: " Dependencies point prerequisite → dependent.", graph: false },
|
|
90
|
+
]
|
|
91
|
+
: [];
|
|
92
|
+
this.detailLines = [...narrative, ...relationshipHeader, ...this.graphLines.map((text) => ({ text, graph: true }))];
|
|
93
|
+
this.offsetY = Math.min(this.offsetY, Math.max(0, this.detailLines.length - this.visibleLines));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function showTaskDetails(
|
|
98
|
+
ctx: ExtensionCommandContext,
|
|
99
|
+
task: Artifact,
|
|
100
|
+
graph?: TaskGraph,
|
|
101
|
+
renderer: GraphRenderer = new BeautifulMermaidRenderer(),
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
const relationshipGraph = renderer.render(projectTaskRelationships(task, graph)).lines;
|
|
104
|
+
const content = taskDetailsText(task, relationshipGraph);
|
|
105
|
+
if (ctx.mode !== "tui") {
|
|
106
|
+
ctx.ui.notify(content, "info");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
|
|
110
|
+
new TaskDetailViewport(tui, theme, task, relationshipGraph, done));
|
|
111
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
|
|
14
|
+
const GRAPH_VIEWS: TaskGraphView[] = ["dependencies", "composition"];
|
|
15
|
+
|
|
16
|
+
export class TaskGraphViewport {
|
|
17
|
+
private viewIndex = 0;
|
|
18
|
+
private offsetX = 0;
|
|
19
|
+
private offsetY = 0;
|
|
20
|
+
private graphLines: string[] = [];
|
|
21
|
+
private readonly viewportHeight: number;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly tui: TUI,
|
|
25
|
+
private readonly theme: Theme,
|
|
26
|
+
private readonly graph: TaskGraph,
|
|
27
|
+
private readonly renderer: GraphRenderer,
|
|
28
|
+
private readonly close: () => void,
|
|
29
|
+
) {
|
|
30
|
+
this.viewportHeight = Math.max(
|
|
31
|
+
TASK_GRAPH_MIN_VISIBLE_LINES,
|
|
32
|
+
Math.min(TASK_GRAPH_MAX_VISIBLE_LINES, tui.terminal.rows - TASK_GRAPH_RESERVED_ROWS),
|
|
33
|
+
);
|
|
34
|
+
this.rebuild();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
invalidate(): void {}
|
|
38
|
+
|
|
39
|
+
render(width: number): string[] {
|
|
40
|
+
const contentWidth = Math.max(1, width);
|
|
41
|
+
const graphWidth = this.graphLines.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0);
|
|
42
|
+
this.offsetX = Math.min(this.offsetX, Math.max(0, graphWidth - contentWidth));
|
|
43
|
+
this.offsetY = Math.min(this.offsetY, Math.max(0, this.graphLines.length - this.viewportHeight));
|
|
44
|
+
const end = Math.min(this.graphLines.length, this.offsetY + this.viewportHeight);
|
|
45
|
+
const border = this.theme.fg("borderMuted", "─".repeat(contentWidth));
|
|
46
|
+
const position = graphWidth > contentWidth || this.graphLines.length > this.viewportHeight
|
|
47
|
+
? ` · column ${this.offsetX + 1}/${Math.max(contentWidth, graphWidth)} · row ${this.offsetY + 1}/${this.graphLines.length}`
|
|
48
|
+
: "";
|
|
49
|
+
return [
|
|
50
|
+
border,
|
|
51
|
+
truncateToWidth(this.theme.bold(`Task graph · ${GRAPH_VIEWS[this.viewIndex]}`), contentWidth, ""),
|
|
52
|
+
truncateToWidth(this.theme.fg("dim", `Tab switch · arrows pan · Esc back${position}`), contentWidth, ""),
|
|
53
|
+
border,
|
|
54
|
+
...this.graphLines.slice(this.offsetY, end).map((line) => sliceByColumn(line, this.offsetX, contentWidth, true)),
|
|
55
|
+
border,
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
handleInput(data: string): void {
|
|
60
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
|
|
61
|
+
if (matchesKey(data, "tab")) this.switchView();
|
|
62
|
+
else if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
63
|
+
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.graphLines.length - this.viewportHeight), this.offsetY + 1);
|
|
64
|
+
else if (matchesKey(data, "left")) this.offsetX = Math.max(0, this.offsetX - TASK_GRAPH_HORIZONTAL_PAN_COLUMNS);
|
|
65
|
+
else if (matchesKey(data, "right")) this.offsetX += TASK_GRAPH_HORIZONTAL_PAN_COLUMNS;
|
|
66
|
+
else return;
|
|
67
|
+
this.tui.requestRender();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private switchView(): void {
|
|
71
|
+
this.viewIndex = (this.viewIndex + 1) % GRAPH_VIEWS.length;
|
|
72
|
+
this.offsetX = 0;
|
|
73
|
+
this.offsetY = 0;
|
|
74
|
+
this.rebuild();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private rebuild(): void {
|
|
78
|
+
const view = GRAPH_VIEWS[this.viewIndex]!;
|
|
79
|
+
this.graphLines = this.renderer.render(projectTaskGraph(this.graph, view)).lines;
|
|
80
|
+
if (this.graphLines.length === 0) this.graphLines = [`No task ${view} relationships`];
|
|
81
|
+
this.offsetY = Math.min(this.offsetY, Math.max(0, this.graphLines.length - this.viewportHeight));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function showTaskGraph(
|
|
86
|
+
ctx: ExtensionCommandContext,
|
|
87
|
+
graph: TaskGraph,
|
|
88
|
+
renderer: GraphRenderer = new BeautifulMermaidRenderer(),
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
if (ctx.mode !== "tui") {
|
|
91
|
+
const rendered = renderer.render(projectTaskGraph(graph, "dependencies"));
|
|
92
|
+
ctx.ui.notify(rendered.lines.join("\n") || "No task dependency relationships", "info");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
|
|
96
|
+
new TaskGraphViewport(tui, theme, graph, renderer, done));
|
|
97
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { TASK_WIDGET_ACTIVE_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
|
+
hasActiveChildren: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TaskWidgetProjection {
|
|
12
|
+
active: TaskWidgetRow[];
|
|
13
|
+
activeTotal: number;
|
|
14
|
+
total: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Keep active work in containment order; /tasks owns full graph navigation. */
|
|
18
|
+
export function buildTaskWidgetProjection(
|
|
19
|
+
graph: TaskGraph,
|
|
20
|
+
activeLimit = TASK_WIDGET_ACTIVE_LIMIT,
|
|
21
|
+
): TaskWidgetProjection {
|
|
22
|
+
const visibleNodes = graph.nodes.filter((node) => node.task.status !== "deleted");
|
|
23
|
+
const byId = new Map(visibleNodes.map((node) => [node.task.id, node]));
|
|
24
|
+
const visited = new Set<string>();
|
|
25
|
+
const ordered: TaskWidgetRow[] = [];
|
|
26
|
+
|
|
27
|
+
const visit = (id: string, activeDepth: number): void => {
|
|
28
|
+
if (visited.has(id)) return;
|
|
29
|
+
const node = byId.get(id);
|
|
30
|
+
if (!node) return;
|
|
31
|
+
visited.add(id);
|
|
32
|
+
const active = node.task.status === "active";
|
|
33
|
+
if (active) ordered.push({ task: node.task, depth: activeDepth, hasActiveChildren: false });
|
|
34
|
+
const childDepth = active ? activeDepth + 1 : activeDepth;
|
|
35
|
+
for (const childId of node.childIds) visit(childId, childDepth);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
for (const rootId of graph.rootIds) visit(rootId, 0);
|
|
39
|
+
for (const node of visibleNodes) visit(node.task.id, 0);
|
|
40
|
+
for (let index = 0; index < ordered.length - 1; index++) {
|
|
41
|
+
ordered[index]!.hasActiveChildren = ordered[index + 1]!.depth > ordered[index]!.depth;
|
|
42
|
+
}
|
|
43
|
+
const active = ordered.slice(0, Math.max(0, activeLimit));
|
|
44
|
+
return {
|
|
45
|
+
active,
|
|
46
|
+
activeTotal: ordered.length,
|
|
47
|
+
total: visibleNodes.length,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tasks.ts — /tasks interactive panel.
|
|
3
|
+
* Filterable list with status glyphs, advance status, run gates, show edges.
|
|
4
|
+
* Follows the pi-extension-manager / pi-packed TUI idiom.
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Container, Input, Spacer, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { callService } from "./service-client.ts";
|
|
10
|
+
import { showTaskDetails } from "./task-detail-view.ts";
|
|
11
|
+
import { showTaskGraph } from "./task-graph.ts";
|
|
12
|
+
|
|
13
|
+
export { taskDetailsText } from "./task-detail-format.ts";
|
|
14
|
+
export { showTaskDetails } from "./task-detail-view.ts";
|
|
15
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
16
|
+
import type { GateResult } from "../../src/domain/gate.ts";
|
|
17
|
+
import type { TaskGraph } from "../../src/task-service.ts";
|
|
18
|
+
|
|
19
|
+
const GLYPHS: Record<string, string> = {
|
|
20
|
+
pending: "○",
|
|
21
|
+
active: "●",
|
|
22
|
+
done: "■",
|
|
23
|
+
failed: "▲",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const STATUS_ACTIONS: Record<string, string[]> = {
|
|
27
|
+
pending: ["Start", "Fail"],
|
|
28
|
+
active: ["Complete (run gates)", "Fail"],
|
|
29
|
+
done: [],
|
|
30
|
+
failed: ["Retry"],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type TaskRow = Artifact;
|
|
34
|
+
|
|
35
|
+
export interface TaskHierarchyRow {
|
|
36
|
+
task: TaskRow;
|
|
37
|
+
depth: number;
|
|
38
|
+
childCount: number;
|
|
39
|
+
dependencies: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
|
|
43
|
+
const byId = new Map(graph.nodes.map((node) => [node.task.id, node]));
|
|
44
|
+
const result: TaskHierarchyRow[] = [];
|
|
45
|
+
const visited = new Set<string>();
|
|
46
|
+
const visit = (id: string, depth: number): void => {
|
|
47
|
+
if (visited.has(id)) return;
|
|
48
|
+
const node = byId.get(id);
|
|
49
|
+
if (!node) return;
|
|
50
|
+
visited.add(id);
|
|
51
|
+
const children = node.childIds.filter((childId) => byId.has(childId));
|
|
52
|
+
result.push({ task: node.task, depth, childCount: children.length, dependencies: [...node.dependencyIds] });
|
|
53
|
+
for (const childId of children) visit(childId, depth + 1);
|
|
54
|
+
};
|
|
55
|
+
for (const rootId of graph.rootIds) visit(rootId, 0);
|
|
56
|
+
for (const node of graph.nodes) visit(node.task.id, 0);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function loadTaskGraph(): Promise<TaskGraph> {
|
|
61
|
+
return callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 200 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
65
|
+
if (!ctx.hasUI) {
|
|
66
|
+
ctx.ui.notify("/tasks requires interactive mode", "warning");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
let graph = await loadTaskGraph();
|
|
70
|
+
if (graph.nodes.length === 0) {
|
|
71
|
+
const create = await ctx.ui.select("No tasks yet", ["Create a task", "Cancel"]);
|
|
72
|
+
if (create === "Create a task") {
|
|
73
|
+
const title = await ctx.ui.input("Task title:", "");
|
|
74
|
+
if (title) {
|
|
75
|
+
await callService("tasks.create", { title });
|
|
76
|
+
graph = await loadTaskGraph();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (graph.nodes.length === 0) return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (;;) {
|
|
83
|
+
const action = await renderPanel(ctx, graph);
|
|
84
|
+
if (!action) return;
|
|
85
|
+
if (action.type === "refresh") { graph = await loadTaskGraph(); continue; }
|
|
86
|
+
if (action.type === "graph") { await showTaskGraph(ctx, graph); continue; }
|
|
87
|
+
if (action.type !== "action" || !action.row) continue;
|
|
88
|
+
|
|
89
|
+
const choices = ["Show details", "Run gates", ...(STATUS_ACTIONS[action.row.status] ?? [])];
|
|
90
|
+
const choice = await ctx.ui.select(action.row.title, choices);
|
|
91
|
+
if (!choice) continue;
|
|
92
|
+
|
|
93
|
+
if (choice === "Show details") {
|
|
94
|
+
const art = await callService<Record<string, unknown>, Artifact | null>("tasks.show", { id: action.row.id });
|
|
95
|
+
if (!art) { ctx.ui.notify("Not found", "error"); continue; }
|
|
96
|
+
await showTaskDetails(ctx, art, graph);
|
|
97
|
+
} else if (choice === "Run gates") {
|
|
98
|
+
try {
|
|
99
|
+
const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id });
|
|
100
|
+
ctx.ui.notify(`Gates:\n${results.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n")}`, "info");
|
|
101
|
+
} catch (error) {
|
|
102
|
+
ctx.ui.notify(`Gates failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
try {
|
|
106
|
+
const operation = choice === "Start" ? "tasks.start" : choice === "Fail" ? "tasks.fail" : choice === "Retry" ? "tasks.retry" : "tasks.complete";
|
|
107
|
+
if (operation === "tasks.complete") {
|
|
108
|
+
const result = await callService<Record<string, unknown>, { artifact: Artifact; gates: GateResult[]; completed: boolean }>(operation, { id: action.row.id });
|
|
109
|
+
action.row.status = result.artifact.status;
|
|
110
|
+
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target}`).join("\n");
|
|
111
|
+
ctx.ui.notify(result.completed ? `Completed ${result.artifact.id}${gates ? `\n${gates}` : ""}` : `Not complete; gates failed\n${gates}`, result.completed ? "info" : "warning");
|
|
112
|
+
} else {
|
|
113
|
+
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id });
|
|
114
|
+
action.row.status = updated.status;
|
|
115
|
+
ctx.ui.notify(`${updated.id} → [${updated.status}]`, "info");
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
ctx.ui.notify(`Task action failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
graph = await loadTaskGraph();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface PanelAction {
|
|
126
|
+
type: "action" | "refresh" | "graph";
|
|
127
|
+
row?: TaskRow;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<PanelAction | undefined> {
|
|
131
|
+
return ctx.ui.custom<PanelAction | undefined>((tui, theme, _kb, done) => {
|
|
132
|
+
const rows = graph.nodes.map((node) => node.task);
|
|
133
|
+
const searchInput = new Input();
|
|
134
|
+
const hierarchy = buildTaskHierarchy(graph);
|
|
135
|
+
const taskById = new Map(rows.map((task) => [task.id, task]));
|
|
136
|
+
let searchActive = false;
|
|
137
|
+
let filtered = [...hierarchy];
|
|
138
|
+
let selectedIndex = 0;
|
|
139
|
+
const maxVisible = 20;
|
|
140
|
+
|
|
141
|
+
function applyFilter(): void {
|
|
142
|
+
const q = searchInput.getValue().trim().toLowerCase();
|
|
143
|
+
filtered = q ? hierarchy.filter(({ task }) =>
|
|
144
|
+
task.title.toLowerCase().includes(q) || task.id.toLowerCase().includes(q)
|
|
145
|
+
) : [...hierarchy];
|
|
146
|
+
selectedIndex = 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function statusLine(): string {
|
|
150
|
+
const counts: Record<string, number> = {};
|
|
151
|
+
for (const r of rows) counts[r.status] = (counts[r.status] ?? 0) + 1;
|
|
152
|
+
return ["pending", "active", "done", "failed"]
|
|
153
|
+
.filter((s) => (counts[s] ?? 0) > 0)
|
|
154
|
+
.map((s) => `${GLYPHS[s] ?? s} ${counts[s]} ${s}`)
|
|
155
|
+
.join(", ");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const header = {
|
|
159
|
+
invalidate() {},
|
|
160
|
+
render(width: number): string[] {
|
|
161
|
+
const title = theme.bold("Tasks");
|
|
162
|
+
const hint = searchActive
|
|
163
|
+
? rawKeyHint("esc", "clear")
|
|
164
|
+
: rawKeyHint("↑/↓", "navigate") +
|
|
165
|
+
theme.fg("muted", " · ") +
|
|
166
|
+
rawKeyHint("enter", "actions") +
|
|
167
|
+
theme.fg("muted", " · ") +
|
|
168
|
+
rawKeyHint("/", "filter") +
|
|
169
|
+
theme.fg("muted", " · ") +
|
|
170
|
+
rawKeyHint("g", "graph") +
|
|
171
|
+
theme.fg("muted", " · ") +
|
|
172
|
+
rawKeyHint("r", "refresh") +
|
|
173
|
+
theme.fg("muted", " · ") +
|
|
174
|
+
rawKeyHint("esc", "close");
|
|
175
|
+
const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
|
|
176
|
+
const line1 = truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, "");
|
|
177
|
+
const line2 = truncateToWidth(theme.fg("muted", statusLine()), width, "");
|
|
178
|
+
return [line1, line2];
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const list = {
|
|
183
|
+
invalidate() {},
|
|
184
|
+
render(width: number): string[] {
|
|
185
|
+
const lines: string[] = [];
|
|
186
|
+
if (searchActive) lines.push(...searchInput.render(width));
|
|
187
|
+
lines.push("");
|
|
188
|
+
if (filtered.length === 0) {
|
|
189
|
+
lines.push(theme.fg("muted", " No tasks"));
|
|
190
|
+
return lines;
|
|
191
|
+
}
|
|
192
|
+
const start = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), filtered.length - maxVisible));
|
|
193
|
+
const end = Math.min(start + maxVisible, filtered.length);
|
|
194
|
+
for (let i = start; i < end; i++) {
|
|
195
|
+
const entry = filtered[i]!;
|
|
196
|
+
const row = entry.task;
|
|
197
|
+
const selected = i === selectedIndex;
|
|
198
|
+
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
199
|
+
const glyph = GLYPHS[row.status] ?? "?";
|
|
200
|
+
const statusColor = row.status === "active" ? "accent" : row.status === "done" ? "dim" : row.status === "failed" ? "warning" : "muted";
|
|
201
|
+
const glyphStyled = theme.fg(statusColor, glyph);
|
|
202
|
+
const title = selected ? theme.bold(row.title) : row.title;
|
|
203
|
+
const indent = " ".repeat(entry.depth);
|
|
204
|
+
const node = entry.childCount > 0 ? theme.fg("accent", "▾") : theme.fg("dim", "·");
|
|
205
|
+
const gates = (row.extra?.["gates"] as any[])?.length;
|
|
206
|
+
const relationParts: string[] = [];
|
|
207
|
+
if (entry.childCount > 0) relationParts.push(`${entry.childCount} subtask${entry.childCount === 1 ? "" : "s"}`);
|
|
208
|
+
if (entry.dependencies.length > 0) {
|
|
209
|
+
const names = entry.dependencies.map((id) => taskById.get(id)?.title ?? id);
|
|
210
|
+
relationParts.push(`needs ${names.join(", ")}`);
|
|
211
|
+
}
|
|
212
|
+
if (gates) relationParts.push(`${gates} gate${gates === 1 ? "" : "s"}`);
|
|
213
|
+
const relationText = relationParts.length > 0 ? theme.fg("dim", ` · ${relationParts.join(" · ")}`) : "";
|
|
214
|
+
lines.push(truncateToWidth(`${cursor} ${indent}${node} ${glyphStyled} ${title}${relationText}`, width, ""));
|
|
215
|
+
}
|
|
216
|
+
const hasScroll = start > 0 || end < filtered.length;
|
|
217
|
+
lines.push(theme.fg("muted", ` ${hasScroll ? `${selectedIndex + 1}/${filtered.length} · ` : ""}↑/↓ navigate · Enter actions`));
|
|
218
|
+
return lines;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const container = new Container();
|
|
223
|
+
container.addChild(new Spacer(1));
|
|
224
|
+
container.addChild(new DynamicBorder());
|
|
225
|
+
container.addChild(new Spacer(1));
|
|
226
|
+
container.addChild(header);
|
|
227
|
+
container.addChild(new Spacer(1));
|
|
228
|
+
container.addChild(list);
|
|
229
|
+
container.addChild(new Spacer(1));
|
|
230
|
+
container.addChild(new DynamicBorder());
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
render: (width: number) => container.render(width),
|
|
234
|
+
invalidate: () => container.invalidate(),
|
|
235
|
+
handleInput(data: string) {
|
|
236
|
+
if (searchActive) {
|
|
237
|
+
if (matchesKey(data, "escape")) { searchActive = false; applyFilter(); }
|
|
238
|
+
else if (matchesKey(data, "enter")) { searchActive = false; }
|
|
239
|
+
else { searchInput.handleInput(data); applyFilter(); }
|
|
240
|
+
tui.requestRender();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (matchesKey(data, "up")) selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(filtered.length, 1);
|
|
244
|
+
else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
|
|
245
|
+
else if (data === "/") searchActive = true;
|
|
246
|
+
else if (data === "g") { done({ type: "graph" }); return; }
|
|
247
|
+
else if (data === "r") { done({ type: "refresh" }); return; }
|
|
248
|
+
else if (matchesKey(data, "enter")) {
|
|
249
|
+
const entry = filtered[selectedIndex];
|
|
250
|
+
if (entry) done({ type: "action", row: entry.task });
|
|
251
|
+
return;
|
|
252
|
+
} else if (matchesKey(data, "escape")) { done(undefined); return; }
|
|
253
|
+
else return;
|
|
254
|
+
tui.requestRender();
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
}
|