@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/tasks.ts
DELETED
|
@@ -1,418 +0,0 @@
|
|
|
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 { emitTaskFocusEvent } from "./task-focus-events.ts";
|
|
11
|
-
import { sessionSecretField } from "./session-identity.ts";
|
|
12
|
-
import { showTaskDetails } from "./task-detail-view.ts";
|
|
13
|
-
import { showTaskGraph } from "./task-graph.ts";
|
|
14
|
-
|
|
15
|
-
export { taskDetailsText } from "./task-detail-format.ts";
|
|
16
|
-
export { showTaskDetails } from "./task-detail-view.ts";
|
|
17
|
-
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
18
|
-
import type { GateResult } from "../../src/domain/gate.ts";
|
|
19
|
-
import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
|
|
20
|
-
import { projectTaskExecution } from "../../src/task-execution.ts";
|
|
21
|
-
import type { TaskCompletion, TaskGraph, TaskStatus } from "../../src/task-service.ts";
|
|
22
|
-
import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
|
|
23
|
-
|
|
24
|
-
const STATUS_ACTIONS: Record<string, string[]> = {
|
|
25
|
-
todo: ["Start", "Cancel"],
|
|
26
|
-
"in-progress": ["Submit for review", "Cancel"],
|
|
27
|
-
review: ["Complete review", "Reject", "Cancel"],
|
|
28
|
-
rejected: ["Retry", "Cancel"],
|
|
29
|
-
done: [],
|
|
30
|
-
canceled: [],
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
type TaskRow = Artifact;
|
|
34
|
-
|
|
35
|
-
function taskChoiceLabels(tasks: readonly Artifact[]): string[] {
|
|
36
|
-
const titleCounts = new Map<string, number>();
|
|
37
|
-
for (const task of tasks) titleCounts.set(task.title, (titleCounts.get(task.title) ?? 0) + 1);
|
|
38
|
-
return tasks.map((task) => titleCounts.get(task.title)! > 1 ? `${task.title} (${task.id})` : task.title);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface TaskHierarchyRow {
|
|
42
|
-
task: TaskRow;
|
|
43
|
-
depth: number;
|
|
44
|
-
childCount: number;
|
|
45
|
-
dependencies: string[];
|
|
46
|
-
active: boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
|
|
50
|
-
const byId = new Map(graph.nodes.map((node) => [node.task.id, node]));
|
|
51
|
-
const result: TaskHierarchyRow[] = [];
|
|
52
|
-
const visited = new Set<string>();
|
|
53
|
-
const visit = (id: string, depth: number): void => {
|
|
54
|
-
if (visited.has(id)) return;
|
|
55
|
-
const node = byId.get(id);
|
|
56
|
-
if (!node) return;
|
|
57
|
-
visited.add(id);
|
|
58
|
-
const children = node.childIds.filter((childId) => byId.has(childId));
|
|
59
|
-
result.push({ task: node.task, depth, childCount: children.length, dependencies: [...node.dependencyIds], active: node.active === true });
|
|
60
|
-
for (const childId of children) visit(childId, depth + 1);
|
|
61
|
-
};
|
|
62
|
-
for (const rootId of graph.rootIds) visit(rootId, 0);
|
|
63
|
-
for (const node of graph.nodes) visit(node.task.id, 0);
|
|
64
|
-
return result;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function loadTaskGraph(projectRoot: string, sessionId: string, scope?: "project" | "graph" | "all", rootTaskId?: string): Promise<TaskGraph> {
|
|
68
|
-
return callService<Record<string, unknown>, TaskGraph>("tasks.graph", {
|
|
69
|
-
limit: 200,
|
|
70
|
-
project_root: projectRoot,
|
|
71
|
-
session_id: sessionId,
|
|
72
|
-
...(scope ? { scope } : {}),
|
|
73
|
-
...(rootTaskId ? { root_task_id: rootTaskId } : {}),
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
78
|
-
if (!ctx.hasUI) {
|
|
79
|
-
ctx.ui.notify("/tasks requires interactive mode", "warning");
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
// Scopes this panel's "active"/Focus reads and writes to this Pi session, so a second
|
|
83
|
-
// concurrent agent working the same project never appears as (or is overridden by) this one.
|
|
84
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
85
|
-
let graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
86
|
-
if (graph.nodes.length === 0) {
|
|
87
|
-
const create = await ctx.ui.select("No tasks yet", ["Create a task", "Cancel"]);
|
|
88
|
-
if (create === "Create a task") {
|
|
89
|
-
const title = await ctx.ui.input("Task title:", "");
|
|
90
|
-
if (title) {
|
|
91
|
-
await callService("tasks.create", { title, project_root: ctx.cwd, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
92
|
-
graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
if (graph.nodes.length === 0) return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
for (;;) {
|
|
99
|
-
const action = await renderPanel(ctx, graph);
|
|
100
|
-
if (!action) return;
|
|
101
|
-
if (action.type === "refresh") { graph = await loadTaskGraph(ctx.cwd, sessionId); continue; }
|
|
102
|
-
if (action.type === "scope") {
|
|
103
|
-
const choice = await ctx.ui.select("Task scope", ["Current project", "Focused graph", "All projects"]);
|
|
104
|
-
if (!choice) continue;
|
|
105
|
-
const scope: "project" | "graph" | "all" = choice === "Current project" ? "project" : choice === "All projects" ? "all" : "graph";
|
|
106
|
-
let rootTaskId: string | undefined;
|
|
107
|
-
if (scope === "graph") {
|
|
108
|
-
const projectGraph = await loadTaskGraph(ctx.cwd, sessionId, "project");
|
|
109
|
-
const roots = projectGraph.rootIds.map((id) => projectGraph.nodes.find((node) => node.task.id === id)?.task).filter((task): task is Artifact => task !== undefined);
|
|
110
|
-
const rootLabels = taskChoiceLabels(roots);
|
|
111
|
-
const selected = await ctx.ui.select("Focused root or epic", rootLabels);
|
|
112
|
-
if (!selected) continue;
|
|
113
|
-
rootTaskId = roots[rootLabels.indexOf(selected)]?.id;
|
|
114
|
-
if (!rootTaskId) continue;
|
|
115
|
-
}
|
|
116
|
-
await callService("tasks.set_scope", { project_root: ctx.cwd, scope, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) });
|
|
117
|
-
graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
if (action.type === "graph") { await showTaskGraph(ctx, graph); continue; }
|
|
121
|
-
if (action.type !== "action" || !action.row) continue;
|
|
122
|
-
|
|
123
|
-
const rowId = action.row.id;
|
|
124
|
-
const node = graph.nodes.find((entry) => entry.task.id === rowId);
|
|
125
|
-
const active = node?.active === true;
|
|
126
|
-
const focusStatus = node?.focusStatus;
|
|
127
|
-
const choices = [
|
|
128
|
-
"Show details",
|
|
129
|
-
"Edit task",
|
|
130
|
-
...(!active && action.row.status !== "done" && action.row.status !== "canceled" ? ["Make active"] : []),
|
|
131
|
-
...(active ? [focusStatus === "paused" ? "Resume focus" : "Pause focus", "Clear focus"] : []),
|
|
132
|
-
...(action.row.status === "review" ? ["Run gates"] : []),
|
|
133
|
-
...((node?.dependencyIds.length ?? 0) > 0 ? ["Remove dependency"] : []),
|
|
134
|
-
...((node?.parentIds.length ?? 0) > 0 ? ["Remove from parent"] : []),
|
|
135
|
-
...(STATUS_ACTIONS[action.row.status] ?? []),
|
|
136
|
-
];
|
|
137
|
-
const choice = await ctx.ui.select(action.row.title, choices);
|
|
138
|
-
if (!choice) continue;
|
|
139
|
-
|
|
140
|
-
if ((choice === "Remove dependency" || choice === "Remove from parent") && node) {
|
|
141
|
-
const relatedIds = choice === "Remove dependency" ? node.dependencyIds : node.parentIds;
|
|
142
|
-
const relatedTasks = relatedIds.map((relatedId) => graph.nodes.find((entry) => entry.task.id === relatedId)?.task).filter((task): task is Artifact => task !== undefined);
|
|
143
|
-
const relatedTitles = taskChoiceLabels(relatedTasks);
|
|
144
|
-
const selected = await ctx.ui.select(choice === "Remove dependency" ? "Remove which dependency?" : "Remove from which parent?", relatedTitles);
|
|
145
|
-
if (!selected) continue;
|
|
146
|
-
const relatedTask = relatedTasks[relatedTitles.indexOf(selected)];
|
|
147
|
-
if (!relatedTask) continue;
|
|
148
|
-
const relatedId = relatedTask.id;
|
|
149
|
-
try {
|
|
150
|
-
if (choice === "Remove dependency") {
|
|
151
|
-
await callService("tasks.undepend", { id: action.row.id, dependency_id: relatedId, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
152
|
-
ctx.ui.notify(`Removed dependency on ${relatedTask.title}`, "info");
|
|
153
|
-
} else {
|
|
154
|
-
await callService("tasks.uncontain", { parent_id: relatedId, child_id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
155
|
-
ctx.ui.notify(`Removed from parent ${relatedTask.title}`, "info");
|
|
156
|
-
}
|
|
157
|
-
} catch (error) {
|
|
158
|
-
ctx.ui.notify(`Relationship removal failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
159
|
-
}
|
|
160
|
-
graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (choice === "Show details") {
|
|
165
|
-
const art = await callService<Record<string, unknown>, Artifact | null>("tasks.show", { id: action.row.id });
|
|
166
|
-
if (!art) { ctx.ui.notify("Not found", "error"); continue; }
|
|
167
|
-
const history = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", { id: art.id, direction: "desc" });
|
|
168
|
-
await showTaskDetails(ctx, art, graph, undefined, [...history.events].reverse());
|
|
169
|
-
} else if (choice === "Edit task") {
|
|
170
|
-
const title = await ctx.ui.input("Task title:", action.row.title);
|
|
171
|
-
if (title === undefined) continue;
|
|
172
|
-
const body = await ctx.ui.input("Task body:", action.row.body);
|
|
173
|
-
if (body === undefined) continue;
|
|
174
|
-
try {
|
|
175
|
-
const updated = await callService<Record<string, unknown>, Artifact>("tasks.update", {
|
|
176
|
-
id: action.row.id,
|
|
177
|
-
title,
|
|
178
|
-
body,
|
|
179
|
-
actor: "user",
|
|
180
|
-
source: "tasks-tui",
|
|
181
|
-
});
|
|
182
|
-
action.row.title = updated.title;
|
|
183
|
-
action.row.body = updated.body;
|
|
184
|
-
ctx.ui.notify(`Updated: ${updated.title}`, "info");
|
|
185
|
-
} catch (error) {
|
|
186
|
-
ctx.ui.notify(`Task update failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
187
|
-
}
|
|
188
|
-
} else if (choice === "Make active") {
|
|
189
|
-
try {
|
|
190
|
-
const focused = await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId, ...sessionSecretField(sessionId) });
|
|
191
|
-
emitTaskFocusEvent({ taskId: focused.id, sessionId, status: "focused" });
|
|
192
|
-
ctx.ui.notify(`Active: ${action.row.title}`, "info");
|
|
193
|
-
} catch (error) {
|
|
194
|
-
ctx.ui.notify(`Focus failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
195
|
-
}
|
|
196
|
-
} else if (choice === "Pause focus" || choice === "Resume focus" || choice === "Clear focus") {
|
|
197
|
-
try {
|
|
198
|
-
if (choice === "Clear focus") {
|
|
199
|
-
await callService("tasks.clear_focus", { actor: "user", source: "tasks-tui", session_id: sessionId, ...sessionSecretField(sessionId) });
|
|
200
|
-
emitTaskFocusEvent({ taskId: null, sessionId, status: "cleared" });
|
|
201
|
-
} else {
|
|
202
|
-
const operation = choice === "Pause focus" ? "tasks.pause" : "tasks.unpause";
|
|
203
|
-
const result = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, { actor: "user", source: "tasks-tui", session_id: sessionId, ...sessionSecretField(sessionId) });
|
|
204
|
-
emitTaskFocusEvent({ taskId: result.artifact.id, sessionId, status: choice === "Pause focus" ? "paused" : "unpaused" });
|
|
205
|
-
}
|
|
206
|
-
ctx.ui.notify(choice === "Clear focus" ? "Task focus cleared" : choice, "info");
|
|
207
|
-
} catch (error) {
|
|
208
|
-
ctx.ui.notify(`Focus action failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
209
|
-
}
|
|
210
|
-
} else if (choice === "Run gates") {
|
|
211
|
-
try {
|
|
212
|
-
const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id, actor: "user", source: "tasks-tui" });
|
|
213
|
-
ctx.ui.notify(`Gates:\n${results.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n")}`, "info");
|
|
214
|
-
} catch (error) {
|
|
215
|
-
ctx.ui.notify(`Gates failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
216
|
-
}
|
|
217
|
-
} else {
|
|
218
|
-
try {
|
|
219
|
-
const operation = choice === "Start"
|
|
220
|
-
? "tasks.start"
|
|
221
|
-
: choice === "Submit for review"
|
|
222
|
-
? "tasks.submit"
|
|
223
|
-
: choice === "Reject"
|
|
224
|
-
? "tasks.reject"
|
|
225
|
-
: choice === "Retry"
|
|
226
|
-
? "tasks.retry"
|
|
227
|
-
: choice === "Cancel"
|
|
228
|
-
? "tasks.cancel"
|
|
229
|
-
: "tasks.complete";
|
|
230
|
-
if (operation === "tasks.complete") {
|
|
231
|
-
const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
232
|
-
action.row.status = result.artifact.status;
|
|
233
|
-
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target}`).join("\n");
|
|
234
|
-
const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}`).join("\n");
|
|
235
|
-
const focused = result.focused ? `\nActive: ${result.focused.title}` : "";
|
|
236
|
-
const taskById = new Map(graph.nodes.map((entry) => [entry.task.id, entry.task]));
|
|
237
|
-
const blocked = result.blocked.length > 0
|
|
238
|
-
? `\nWaiting: ${result.blocked.map((entry) => `${entry.artifact.title} needs ${entry.dependencyIds.map((id) => taskById.get(id)?.title ?? "unknown task").join(", ")}`).join("; ")}`
|
|
239
|
-
: "";
|
|
240
|
-
ctx.ui.notify(
|
|
241
|
-
result.completed
|
|
242
|
-
? `Completed ${result.artifact.title}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`
|
|
243
|
-
: `Review rejected${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`,
|
|
244
|
-
result.completed ? "info" : "warning",
|
|
245
|
-
);
|
|
246
|
-
} else {
|
|
247
|
-
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
248
|
-
action.row.status = updated.status;
|
|
249
|
-
ctx.ui.notify(`${updated.title} → [${updated.status}]`, "info");
|
|
250
|
-
}
|
|
251
|
-
} catch (error) {
|
|
252
|
-
ctx.ui.notify(`Task action failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
interface PanelAction {
|
|
260
|
-
type: "action" | "refresh" | "graph" | "scope";
|
|
261
|
-
row?: TaskRow;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<PanelAction | undefined> {
|
|
265
|
-
return ctx.ui.custom<PanelAction | undefined>((tui, theme, _kb, done) => {
|
|
266
|
-
const rows = graph.nodes.map((node) => node.task);
|
|
267
|
-
const searchInput = new Input();
|
|
268
|
-
const hierarchy = buildTaskHierarchy(graph);
|
|
269
|
-
const taskById = new Map(rows.map((task) => [task.id, task]));
|
|
270
|
-
const executionById = new Map(projectTaskExecution(graph).nodes.map((node) => [node.id, node]));
|
|
271
|
-
let searchActive = false;
|
|
272
|
-
let filtered = [...hierarchy];
|
|
273
|
-
let selectedIndex = 0;
|
|
274
|
-
const maxVisible = 20;
|
|
275
|
-
|
|
276
|
-
function applyFilter(): void {
|
|
277
|
-
const q = searchInput.getValue().trim().toLowerCase();
|
|
278
|
-
filtered = q ? hierarchy.filter(({ task }) =>
|
|
279
|
-
task.title.toLowerCase().includes(q) || task.id.toLowerCase().includes(q)
|
|
280
|
-
) : [...hierarchy];
|
|
281
|
-
selectedIndex = 0;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function statusLine(): string {
|
|
285
|
-
const counts: Record<string, number> = {};
|
|
286
|
-
for (const entry of hierarchy) counts[entry.task.status] = (counts[entry.task.status] ?? 0) + 1;
|
|
287
|
-
const parts = hierarchy.some((entry) => entry.active) ? ["▶ 1 active"] : [];
|
|
288
|
-
for (const status of ["todo", "in-progress", "review", "rejected", "done", "canceled"] as TaskStatus[]) {
|
|
289
|
-
if ((counts[status] ?? 0) > 0) {
|
|
290
|
-
const presentation = TASK_STATUS_PRESENTATION[status];
|
|
291
|
-
parts.push(`${presentation.glyph} ${counts[status]} ${presentation.label}`);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
return parts.join(", ");
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const header = {
|
|
298
|
-
invalidate() {},
|
|
299
|
-
render(width: number): string[] {
|
|
300
|
-
const title = theme.bold(`Tasks · ${graph.scope?.label ?? "scope unavailable"}`);
|
|
301
|
-
const hint = searchActive
|
|
302
|
-
? rawKeyHint("esc", "clear")
|
|
303
|
-
: rawKeyHint("↑/↓", "navigate") +
|
|
304
|
-
theme.fg("muted", " · ") +
|
|
305
|
-
rawKeyHint("enter", "actions") +
|
|
306
|
-
theme.fg("muted", " · ") +
|
|
307
|
-
rawKeyHint("/", "filter") +
|
|
308
|
-
theme.fg("muted", " · ") +
|
|
309
|
-
rawKeyHint("g", "graph") +
|
|
310
|
-
theme.fg("muted", " · ") +
|
|
311
|
-
rawKeyHint("s", "scope") +
|
|
312
|
-
theme.fg("muted", " · ") +
|
|
313
|
-
rawKeyHint("r", "refresh") +
|
|
314
|
-
theme.fg("muted", " · ") +
|
|
315
|
-
rawKeyHint("esc", "close");
|
|
316
|
-
const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
|
|
317
|
-
const line1 = truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, "");
|
|
318
|
-
const line2 = truncateToWidth(theme.fg("muted", statusLine()), width, "");
|
|
319
|
-
return [line1, line2];
|
|
320
|
-
},
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
const list = {
|
|
324
|
-
invalidate() {},
|
|
325
|
-
render(width: number): string[] {
|
|
326
|
-
const lines: string[] = [];
|
|
327
|
-
if (searchActive) lines.push(...searchInput.render(width));
|
|
328
|
-
lines.push("");
|
|
329
|
-
if (filtered.length === 0) {
|
|
330
|
-
lines.push(theme.fg("muted", " No tasks"));
|
|
331
|
-
return lines;
|
|
332
|
-
}
|
|
333
|
-
const start = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), filtered.length - maxVisible));
|
|
334
|
-
const end = Math.min(start + maxVisible, filtered.length);
|
|
335
|
-
for (let i = start; i < end; i++) {
|
|
336
|
-
const entry = filtered[i]!;
|
|
337
|
-
const row = entry.task;
|
|
338
|
-
const selected = i === selectedIndex;
|
|
339
|
-
const cursor = selected ? theme.fg("accent", "❯") : " ";
|
|
340
|
-
const focus = entry.active ? theme.fg("accent", "▶") : " ";
|
|
341
|
-
const execution = executionById.get(row.id);
|
|
342
|
-
const state = execution?.state ?? row.status;
|
|
343
|
-
const presentation = TASK_STATUS_PRESENTATION[row.status as TaskStatus];
|
|
344
|
-
const glyphStyled = state === "invalid"
|
|
345
|
-
? theme.fg("error", "!")
|
|
346
|
-
: presentation
|
|
347
|
-
? theme.fg(presentation.color, presentation.glyph)
|
|
348
|
-
: theme.fg("muted", "?");
|
|
349
|
-
const title = selected ? theme.bold(row.title) : row.title;
|
|
350
|
-
let laterSibling = false;
|
|
351
|
-
for (let candidate = i + 1; candidate < filtered.length; candidate++) {
|
|
352
|
-
if (filtered[candidate]!.depth < entry.depth) break;
|
|
353
|
-
if (filtered[candidate]!.depth === entry.depth) { laterSibling = true; break; }
|
|
354
|
-
}
|
|
355
|
-
const connector = taskTreeConnector({
|
|
356
|
-
depth: entry.depth,
|
|
357
|
-
hasChildren: entry.childCount > 0,
|
|
358
|
-
hasLaterSibling: laterSibling,
|
|
359
|
-
});
|
|
360
|
-
const node = entry.depth === 0 && entry.childCount > 0
|
|
361
|
-
? theme.fg("accent", connector)
|
|
362
|
-
: theme.fg("dim", connector);
|
|
363
|
-
const gates = (row.extra?.["gates"] as any[])?.length;
|
|
364
|
-
const relationParts: string[] = [];
|
|
365
|
-
if (execution) relationParts.push(execution.layer === null ? state : `layer ${execution.layer + 1} · ${state}`);
|
|
366
|
-
if (entry.childCount > 0) relationParts.push(`${entry.childCount} subtask${entry.childCount === 1 ? "" : "s"}`);
|
|
367
|
-
if (entry.dependencies.length > 0) {
|
|
368
|
-
const names = entry.dependencies.map((id) => taskById.get(id)?.title ?? id);
|
|
369
|
-
relationParts.push(`needs ${names.join(", ")}`);
|
|
370
|
-
}
|
|
371
|
-
if (gates) relationParts.push(`${gates} gate${gates === 1 ? "" : "s"}`);
|
|
372
|
-
const relationText = relationParts.length > 0 ? theme.fg("dim", ` · ${relationParts.join(" · ")}`) : "";
|
|
373
|
-
lines.push(truncateToWidth(`${cursor}${focus} ${node} ${glyphStyled} ${title}${relationText}`, width, ""));
|
|
374
|
-
}
|
|
375
|
-
const hasScroll = start > 0 || end < filtered.length;
|
|
376
|
-
lines.push(theme.fg("muted", ` ${hasScroll ? `${selectedIndex + 1}/${filtered.length} · ` : ""}↑/↓ navigate · Enter actions`));
|
|
377
|
-
return lines;
|
|
378
|
-
},
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
const container = new Container();
|
|
382
|
-
container.addChild(new Spacer(1));
|
|
383
|
-
container.addChild(new DynamicBorder());
|
|
384
|
-
container.addChild(new Spacer(1));
|
|
385
|
-
container.addChild(header);
|
|
386
|
-
container.addChild(new Spacer(1));
|
|
387
|
-
container.addChild(list);
|
|
388
|
-
container.addChild(new Spacer(1));
|
|
389
|
-
container.addChild(new DynamicBorder());
|
|
390
|
-
|
|
391
|
-
return {
|
|
392
|
-
render: (width: number) => container.render(width),
|
|
393
|
-
invalidate: () => container.invalidate(),
|
|
394
|
-
handleInput(data: string) {
|
|
395
|
-
if (searchActive) {
|
|
396
|
-
if (matchesKey(data, "escape")) { searchActive = false; applyFilter(); }
|
|
397
|
-
else if (matchesKey(data, "enter")) { searchActive = false; }
|
|
398
|
-
else { searchInput.handleInput(data); applyFilter(); }
|
|
399
|
-
tui.requestRender();
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
if (matchesKey(data, "up")) selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(filtered.length, 1);
|
|
403
|
-
else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
|
|
404
|
-
else if (data === "/") searchActive = true;
|
|
405
|
-
else if (data === "g") { done({ type: "graph" }); return; }
|
|
406
|
-
else if (data === "s") { done({ type: "scope" }); return; }
|
|
407
|
-
else if (data === "r") { done({ type: "refresh" }); return; }
|
|
408
|
-
else if (matchesKey(data, "enter")) {
|
|
409
|
-
const entry = filtered[selectedIndex];
|
|
410
|
-
if (entry) done({ type: "action", row: entry.task });
|
|
411
|
-
return;
|
|
412
|
-
} else if (matchesKey(data, "escape")) { done(undefined); return; }
|
|
413
|
-
else return;
|
|
414
|
-
tui.requestRender();
|
|
415
|
-
},
|
|
416
|
-
};
|
|
417
|
-
});
|
|
418
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { type Component, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
-
import type { ArtifactToolDetails } from "./render-model.ts";
|
|
4
|
-
|
|
5
|
-
const KIND_GLYPHS: Readonly<Record<string, string>> = {
|
|
6
|
-
task: "◇",
|
|
7
|
-
doc: "▤",
|
|
8
|
-
rule: "◆",
|
|
9
|
-
skill: "✦",
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
const STATUS_GLYPHS: Readonly<Record<string, string>> = {
|
|
13
|
-
done: "✓",
|
|
14
|
-
active: "●",
|
|
15
|
-
"in-progress": "●",
|
|
16
|
-
review: "◐",
|
|
17
|
-
rejected: "✗",
|
|
18
|
-
canceled: "×",
|
|
19
|
-
todo: "○",
|
|
20
|
-
draft: "○",
|
|
21
|
-
archived: "·",
|
|
22
|
-
deprecated: "·",
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
type SemanticColor = "success" | "error" | "warning" | "accent" | "muted";
|
|
26
|
-
|
|
27
|
-
function statusColor(status: string): SemanticColor {
|
|
28
|
-
if (status === "done" || status === "active") return "success";
|
|
29
|
-
if (status === "rejected" || status === "canceled") return "error";
|
|
30
|
-
if (status === "review") return "warning";
|
|
31
|
-
if (status === "in-progress") return "accent";
|
|
32
|
-
return "muted";
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function kindGlyph(kind: string): string {
|
|
36
|
-
return KIND_GLYPHS[kind] ?? "•";
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function statusGlyph(status: string): string {
|
|
40
|
-
return STATUS_GLYPHS[status] ?? "•";
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function countSummary(returned: number, total: number): string {
|
|
44
|
-
return returned === total ? String(total) : `${returned} of ${total}`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function emptyState(noun: string): string {
|
|
48
|
-
return `No ${noun}.`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function treeConnector(last: boolean): string {
|
|
52
|
-
return last ? "└─" : "├─";
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function expandHint(): string {
|
|
56
|
-
return "expand for details";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Reusable width-safe artifact card for native tool result rows. */
|
|
60
|
-
export class ArtifactCard implements Component {
|
|
61
|
-
private details: ArtifactToolDetails;
|
|
62
|
-
private theme: Theme;
|
|
63
|
-
private expanded: boolean;
|
|
64
|
-
private cachedWidth: number | undefined;
|
|
65
|
-
private cachedLines: string[] | undefined;
|
|
66
|
-
|
|
67
|
-
constructor(details: ArtifactToolDetails, theme: Theme, expanded: boolean) {
|
|
68
|
-
this.details = details;
|
|
69
|
-
this.theme = theme;
|
|
70
|
-
this.expanded = expanded;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
update(details: ArtifactToolDetails, theme: Theme, expanded: boolean): void {
|
|
74
|
-
this.details = details;
|
|
75
|
-
this.theme = theme;
|
|
76
|
-
this.expanded = expanded;
|
|
77
|
-
this.invalidate();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
render(width: number): string[] {
|
|
81
|
-
const safeWidth = Math.max(1, width);
|
|
82
|
-
if (this.cachedLines && this.cachedWidth === safeWidth) return this.cachedLines;
|
|
83
|
-
|
|
84
|
-
const artifact = this.details.artifact;
|
|
85
|
-
const status = `${statusGlyph(artifact.status)} ${artifact.status}`;
|
|
86
|
-
const header = [
|
|
87
|
-
this.theme.fg("toolTitle", this.theme.bold(`${kindGlyph(artifact.kind)} ${artifact.kind.toUpperCase()}`)),
|
|
88
|
-
...(this.expanded ? [this.theme.fg("accent", artifact.id)] : []),
|
|
89
|
-
this.theme.fg(statusColor(artifact.status), status),
|
|
90
|
-
].join(" ");
|
|
91
|
-
const lines = [truncateToWidth(header, safeWidth)];
|
|
92
|
-
lines.push(truncateToWidth(this.theme.fg("text", artifact.title), safeWidth));
|
|
93
|
-
|
|
94
|
-
if (this.expanded) {
|
|
95
|
-
const metadata = [artifact.subtype, ...artifact.labels].filter(Boolean).join(" · ");
|
|
96
|
-
if (metadata) lines.push(truncateToWidth(this.theme.fg("muted", metadata), safeWidth));
|
|
97
|
-
if (artifact.body) lines.push(...wrapTextWithAnsi(artifact.body, safeWidth));
|
|
98
|
-
if (this.details.completeness.truncated) {
|
|
99
|
-
lines.push(truncateToWidth(
|
|
100
|
-
this.theme.fg("warning", `[truncated ${this.details.completeness.omitted} characters]`),
|
|
101
|
-
safeWidth,
|
|
102
|
-
));
|
|
103
|
-
}
|
|
104
|
-
} else if (artifact.body || artifact.labels.length > 0) {
|
|
105
|
-
lines.push(truncateToWidth(this.theme.fg("dim", expandHint()), safeWidth));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
this.cachedWidth = safeWidth;
|
|
109
|
-
this.cachedLines = lines;
|
|
110
|
-
return lines;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
invalidate(): void {
|
|
114
|
-
this.cachedWidth = undefined;
|
|
115
|
-
this.cachedLines = undefined;
|
|
116
|
-
}
|
|
117
|
-
}
|