@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.
Files changed (47) hide show
  1. package/README.md +5 -189
  2. package/package.json +8 -16
  3. package/src/artifact-relationship-view.ts +23 -0
  4. package/src/cli.ts +0 -0
  5. package/src/index.ts +32 -0
  6. package/src/task-relationship-view.ts +2 -1
  7. package/extension/src/active-task-continuation.ts +0 -131
  8. package/extension/src/artifact-browser.ts +0 -229
  9. package/extension/src/artifact-detail-format.ts +0 -31
  10. package/extension/src/artifact-detail-view.ts +0 -112
  11. package/extension/src/artifact-format.ts +0 -84
  12. package/extension/src/artifact-status-presentation.ts +0 -71
  13. package/extension/src/base-prompt-breakdown.ts +0 -55
  14. package/extension/src/beautiful-mermaid-renderer.ts +0 -68
  15. package/extension/src/bounded-poll.ts +0 -20
  16. package/extension/src/context-budget.ts +0 -503
  17. package/extension/src/context-injection-telemetry.ts +0 -88
  18. package/extension/src/context-view.ts +0 -222
  19. package/extension/src/discuss-ask-layout.ts +0 -193
  20. package/extension/src/discuss-ask-view.ts +0 -1301
  21. package/extension/src/discuss.ts +0 -134
  22. package/extension/src/discussion-detail-view.ts +0 -136
  23. package/extension/src/docs.ts +0 -58
  24. package/extension/src/domain-tools.ts +0 -886
  25. package/extension/src/index.ts +0 -776
  26. package/extension/src/markdown.ts +0 -60
  27. package/extension/src/note-widget.ts +0 -8
  28. package/extension/src/notes.ts +0 -102
  29. package/extension/src/playbook-bridge.ts +0 -91
  30. package/extension/src/playbooks.ts +0 -97
  31. package/extension/src/rules.ts +0 -51
  32. package/extension/src/service-client.ts +0 -29
  33. package/extension/src/session-identity.ts +0 -22
  34. package/extension/src/skill-catalog-footprint.ts +0 -183
  35. package/extension/src/skills.ts +0 -127
  36. package/extension/src/task-context.ts +0 -1
  37. package/extension/src/task-detail-format.ts +0 -110
  38. package/extension/src/task-detail-view.ts +0 -139
  39. package/extension/src/task-focus-events.ts +0 -57
  40. package/extension/src/task-graph.ts +0 -116
  41. package/extension/src/task-presentation.ts +0 -26
  42. package/extension/src/task-widget.ts +0 -70
  43. package/extension/src/tasks.ts +0 -418
  44. package/extension/src/tool-rendering/artifact-card.ts +0 -117
  45. package/extension/src/tool-rendering/artifact-list.ts +0 -179
  46. package/extension/src/tool-rendering/index.ts +0 -109
  47. package/extension/src/tool-rendering/render-model.ts +0 -410
@@ -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
- }