@danypops/papyrus 0.10.0 → 0.11.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 +1 -1
- package/extension/src/artifact-browser.ts +25 -19
- package/extension/src/artifact-detail-format.ts +31 -0
- package/extension/src/artifact-detail-view.ts +112 -0
- package/extension/src/markdown.ts +60 -0
- package/extension/src/task-detail-format.ts +29 -10
- package/extension/src/task-detail-view.ts +39 -14
- package/package.json +1 -1
- package/src/constants.ts +5 -0
package/README.md
CHANGED
|
@@ -116,7 +116,7 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
|
|
|
116
116
|
- `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
|
|
117
117
|
- `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
|
|
118
118
|
|
|
119
|
-
All frontends use daemon-backed domain operations; none opens SQLite from the Pi process.
|
|
119
|
+
All frontends use daemon-backed domain operations; none opens SQLite from the Pi process. **Show details** opens a bounded navigable view across Tasks, Notes, Docs, Rules, legacy Skills, templates, and workflow Skills. User-authored bodies render as width-aware Markdown with headings, emphasis, links, quotes, lists, tables, inline/fenced code, syntax highlighting, and every color/decorative style derived dynamically from the active Pi theme. Generated lifecycle, metadata, checklist, gate, history, and relationship sections keep explicit semantic theme colors. `↑/↓` scrolls, `←/→` pans wide relationships, and Esc returns to the browser; non-interactive clients receive stable source text.
|
|
120
120
|
|
|
121
121
|
## Notes
|
|
122
122
|
|
|
@@ -4,9 +4,12 @@ import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earend
|
|
|
4
4
|
import { SEED_RELATIONS } from "../../src/constants.ts";
|
|
5
5
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
6
6
|
import type { OperationName } from "../../src/service.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { artifactDetailsText } from "./artifact-detail-format.ts";
|
|
8
|
+
import { showArtifactDetailView } from "./artifact-detail-view.ts";
|
|
8
9
|
import { callService } from "./service-client.ts";
|
|
9
10
|
|
|
11
|
+
export { artifactDetailsText } from "./artifact-detail-format.ts";
|
|
12
|
+
|
|
10
13
|
const BROWSER_QUERY_LIMIT = 500;
|
|
11
14
|
const BROWSER_VISIBLE_ROWS = 20;
|
|
12
15
|
const DETAIL_GRAPH_DEPTH = 4;
|
|
@@ -51,31 +54,34 @@ async function loadArtifacts(config: ArtifactBrowserConfig): Promise<Artifact[]>
|
|
|
51
54
|
});
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
export type ArtifactDetailLoader = (
|
|
58
|
+
operation: OperationName,
|
|
59
|
+
input: Record<string, unknown>,
|
|
60
|
+
) => Promise<Artifact | null>;
|
|
61
|
+
|
|
62
|
+
const loadArtifactDetails: ArtifactDetailLoader = (operation, input) =>
|
|
63
|
+
callService<Record<string, unknown>, Artifact | null>(operation, input);
|
|
64
|
+
|
|
54
65
|
export async function showArtifactDetails(
|
|
55
66
|
ctx: ExtensionCommandContext,
|
|
56
67
|
id: string,
|
|
57
68
|
operation: OperationName = "artifact.show",
|
|
58
69
|
input: Record<string, unknown> = {},
|
|
70
|
+
load: ArtifactDetailLoader = loadArtifactDetails,
|
|
59
71
|
): Promise<void> {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (Object.keys(artifact.extra).length > 0) {
|
|
73
|
-
output += `\n\nMetadata:\n${formatMetadata(artifact.extra).map((line) => ` ${line}`).join("\n")}`;
|
|
74
|
-
}
|
|
75
|
-
if (artifact.edges?.length) {
|
|
76
|
-
output += `\n\nEdges:\n${artifact.edges.map((edge) => ` ${edge.from} --${edge.relation}--> ${edge.to}`).join("\n")}`;
|
|
72
|
+
try {
|
|
73
|
+
const artifact = await load(operation, {
|
|
74
|
+
id,
|
|
75
|
+
...input,
|
|
76
|
+
tree: true,
|
|
77
|
+
depth: DETAIL_GRAPH_DEPTH,
|
|
78
|
+
max_nodes: DETAIL_GRAPH_NODES,
|
|
79
|
+
});
|
|
80
|
+
if (!artifact) { ctx.ui.notify(`Artifact ${id} not found`, "error"); return; }
|
|
81
|
+
await showArtifactDetailView(ctx, artifact);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
ctx.ui.notify(`Show details failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
77
84
|
}
|
|
78
|
-
ctx.ui.notify(output, "info");
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
export async function linkFromArtifact(ctx: ExtensionCommandContext, fromId: string, fixedRelation?: string): Promise<void> {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
2
|
+
import { formatMetadata } from "./artifact-format.ts";
|
|
3
|
+
|
|
4
|
+
export interface ArtifactDetailContent {
|
|
5
|
+
title: string;
|
|
6
|
+
identity: string;
|
|
7
|
+
body: string;
|
|
8
|
+
labels: string[];
|
|
9
|
+
metadata: string[];
|
|
10
|
+
relationships: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function artifactDetailContent(artifact: Artifact): ArtifactDetailContent {
|
|
14
|
+
return {
|
|
15
|
+
title: artifact.title,
|
|
16
|
+
identity: `${artifact.id} [${artifact.kind}|${artifact.status}]${artifact.subtype ? ` · ${artifact.subtype}` : ""}`,
|
|
17
|
+
body: artifact.body || "(no body)",
|
|
18
|
+
labels: [...artifact.labels],
|
|
19
|
+
metadata: Object.keys(artifact.extra).length > 0 ? formatMetadata(artifact.extra) : [],
|
|
20
|
+
relationships: (artifact.edges ?? []).map((edge) => `${edge.from} --${edge.relation}--> ${edge.to}`),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function artifactDetailsText(artifact: Artifact): string {
|
|
25
|
+
const content = artifactDetailContent(artifact);
|
|
26
|
+
let output = `${content.title}\n${content.identity}\n\n${content.body}`;
|
|
27
|
+
if (content.labels.length > 0) output += `\n\nLabels: ${content.labels.join(", ")}`;
|
|
28
|
+
if (content.metadata.length > 0) output += `\n\nMetadata:\n${content.metadata.map((line) => ` ${line}`).join("\n")}`;
|
|
29
|
+
if (content.relationships.length > 0) output += `\n\nRelationships:\n${content.relationships.map((line) => ` ${line}`).join("\n")}`;
|
|
30
|
+
return output;
|
|
31
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS,
|
|
5
|
+
ARTIFACT_DETAIL_MAX_VISIBLE_LINES,
|
|
6
|
+
ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
|
|
7
|
+
ARTIFACT_DETAIL_RESERVED_ROWS,
|
|
8
|
+
} from "../../src/constants.ts";
|
|
9
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
10
|
+
import { artifactDetailContent, artifactDetailsText, type ArtifactDetailContent } from "./artifact-detail-format.ts";
|
|
11
|
+
import { renderMarkdownBody, type ActiveTheme } from "./markdown.ts";
|
|
12
|
+
|
|
13
|
+
interface ArtifactDetailLine {
|
|
14
|
+
text: string;
|
|
15
|
+
wide: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class ArtifactDetailViewport {
|
|
19
|
+
private offsetX = 0;
|
|
20
|
+
private offsetY = 0;
|
|
21
|
+
private renderedWidth = 0;
|
|
22
|
+
private lines: ArtifactDetailLine[] = [];
|
|
23
|
+
private readonly visibleLines: number;
|
|
24
|
+
private readonly content: ArtifactDetailContent;
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly tui: TUI,
|
|
28
|
+
private readonly activeTheme: ActiveTheme,
|
|
29
|
+
artifact: Artifact,
|
|
30
|
+
private readonly close: () => void,
|
|
31
|
+
) {
|
|
32
|
+
this.visibleLines = Math.max(
|
|
33
|
+
ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
|
|
34
|
+
Math.min(ARTIFACT_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - ARTIFACT_DETAIL_RESERVED_ROWS),
|
|
35
|
+
);
|
|
36
|
+
this.content = artifactDetailContent(artifact);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
invalidate(): void { this.renderedWidth = 0; }
|
|
40
|
+
|
|
41
|
+
render(width: number): string[] {
|
|
42
|
+
const contentWidth = Math.max(1, width - 2);
|
|
43
|
+
this.buildLines(contentWidth);
|
|
44
|
+
const wideWidth = this.content.relationships.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0);
|
|
45
|
+
this.offsetX = Math.min(this.offsetX, Math.max(0, wideWidth - contentWidth));
|
|
46
|
+
this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
|
|
47
|
+
const end = Math.min(this.lines.length, this.offsetY + this.visibleLines);
|
|
48
|
+
const theme = this.activeTheme();
|
|
49
|
+
const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
|
|
50
|
+
const footer = [
|
|
51
|
+
wideWidth > contentWidth ? `←/→ relationships · column ${this.offsetX + 1}/${wideWidth}` : "",
|
|
52
|
+
this.lines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.lines.length}` : "",
|
|
53
|
+
"Esc back",
|
|
54
|
+
].filter(Boolean).join(" · ");
|
|
55
|
+
return [
|
|
56
|
+
border,
|
|
57
|
+
truncateToWidth(theme.fg("accent", theme.bold("Artifact details")), width, ""),
|
|
58
|
+
border,
|
|
59
|
+
...this.lines.slice(this.offsetY, end).map((line) => line.wide
|
|
60
|
+
? ` ${sliceByColumn(line.text, this.offsetX, contentWidth, true)}`
|
|
61
|
+
: truncateToWidth(` ${line.text}`, width, "")),
|
|
62
|
+
truncateToWidth(theme.fg("dim", footer), width, ""),
|
|
63
|
+
border,
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
handleInput(data: string): void {
|
|
68
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
|
|
69
|
+
if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
70
|
+
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + 1);
|
|
71
|
+
else if (matchesKey(data, "left")) this.offsetX = Math.max(0, this.offsetX - ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS);
|
|
72
|
+
else if (matchesKey(data, "right")) this.offsetX += ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS;
|
|
73
|
+
else return;
|
|
74
|
+
this.tui.requestRender();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private buildLines(width: number): void {
|
|
78
|
+
if (this.renderedWidth === width) return;
|
|
79
|
+
this.renderedWidth = width;
|
|
80
|
+
const theme = this.activeTheme();
|
|
81
|
+
const wrap = (text: string, color: "text" | "muted" | "dim" = "text"): ArtifactDetailLine[] =>
|
|
82
|
+
(text.length === 0 ? [""] : wrapTextWithAnsi(theme.fg(color, text), width)).map((line) => ({ text: line, wide: false }));
|
|
83
|
+
const identity = [
|
|
84
|
+
...wrap(theme.bold(this.content.title)),
|
|
85
|
+
...wrap(this.content.identity, "muted"),
|
|
86
|
+
{ text: "", wide: false },
|
|
87
|
+
];
|
|
88
|
+
const body = renderMarkdownBody(this.content.body, width, this.activeTheme).map((text) => ({ text, wide: false }));
|
|
89
|
+
const labels = this.content.labels.length > 0
|
|
90
|
+
? [{ text: "", wide: false }, ...wrap("Labels:", "muted"), ...wrap(this.content.labels.join(", "))]
|
|
91
|
+
: [];
|
|
92
|
+
const metadata = this.content.metadata.length > 0
|
|
93
|
+
? [{ text: "", wide: false }, ...wrap("Metadata:", "muted"), ...this.content.metadata.flatMap((line) => wrap(` ${line}`, "dim"))]
|
|
94
|
+
: [];
|
|
95
|
+
const relationships = this.content.relationships.length > 0
|
|
96
|
+
? [
|
|
97
|
+
{ text: "", wide: false },
|
|
98
|
+
...wrap("Relationships:", "muted"),
|
|
99
|
+
...this.content.relationships.map((text) => ({ text: theme.fg("text", text), wide: true })),
|
|
100
|
+
]
|
|
101
|
+
: [];
|
|
102
|
+
this.lines = [...identity, ...body, ...labels, ...metadata, ...relationships];
|
|
103
|
+
this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function showArtifactDetailView(ctx: ExtensionCommandContext, artifact: Artifact): Promise<void> {
|
|
108
|
+
const output = artifactDetailsText(artifact);
|
|
109
|
+
if (ctx.mode !== "tui") { ctx.ui.notify(output, "info"); return; }
|
|
110
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
|
|
111
|
+
new ArtifactDetailViewport(tui, () => ctx.ui.theme ?? theme, artifact, done));
|
|
112
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export type ActiveTheme = () => Theme;
|
|
5
|
+
export type ActiveMarkdownTheme = () => Pick<MarkdownTheme, "highlightCode">;
|
|
6
|
+
|
|
7
|
+
function activePiMarkdownTheme(): Pick<MarkdownTheme, "highlightCode"> {
|
|
8
|
+
try {
|
|
9
|
+
return getMarkdownTheme();
|
|
10
|
+
} catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createPapyrusMarkdownTheme(
|
|
16
|
+
activeTheme: ActiveTheme,
|
|
17
|
+
activeMarkdownTheme: ActiveMarkdownTheme = activePiMarkdownTheme,
|
|
18
|
+
): MarkdownTheme {
|
|
19
|
+
return {
|
|
20
|
+
heading: (text) => activeTheme().fg("mdHeading", text),
|
|
21
|
+
link: (text) => activeTheme().fg("mdLink", text),
|
|
22
|
+
linkUrl: (text) => activeTheme().fg("mdLinkUrl", text),
|
|
23
|
+
code: (text) => activeTheme().fg("mdCode", text),
|
|
24
|
+
codeBlock: (text) => activeTheme().fg("mdCodeBlock", text),
|
|
25
|
+
codeBlockBorder: (text) => activeTheme().fg("mdCodeBlockBorder", text),
|
|
26
|
+
quote: (text) => activeTheme().fg("mdQuote", text),
|
|
27
|
+
quoteBorder: (text) => activeTheme().fg("mdQuoteBorder", text),
|
|
28
|
+
hr: (text) => activeTheme().fg("mdHr", text),
|
|
29
|
+
listBullet: (text) => activeTheme().fg("mdListBullet", text),
|
|
30
|
+
bold: (text) => activeTheme().bold(text),
|
|
31
|
+
italic: (text) => activeTheme().italic(text),
|
|
32
|
+
strikethrough: (text) => activeTheme().strikethrough(text),
|
|
33
|
+
underline: (text) => activeTheme().underline(text),
|
|
34
|
+
highlightCode: (code, language) => {
|
|
35
|
+
try {
|
|
36
|
+
const highlighted = activeMarkdownTheme().highlightCode?.(code, language);
|
|
37
|
+
if (highlighted) return highlighted;
|
|
38
|
+
} catch {
|
|
39
|
+
// The host theme may be unavailable in isolated rendering tests.
|
|
40
|
+
}
|
|
41
|
+
return code.split("\n").map((line) => activeTheme().fg("mdCodeBlock", line));
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function renderMarkdownBody(
|
|
47
|
+
body: string,
|
|
48
|
+
width: number,
|
|
49
|
+
activeTheme: ActiveTheme,
|
|
50
|
+
activeMarkdownTheme: ActiveMarkdownTheme = activePiMarkdownTheme,
|
|
51
|
+
): string[] {
|
|
52
|
+
const markdown = new Markdown(
|
|
53
|
+
body || "(no body)",
|
|
54
|
+
0,
|
|
55
|
+
0,
|
|
56
|
+
createPapyrusMarkdownTheme(activeTheme, activeMarkdownTheme),
|
|
57
|
+
{ color: (text) => activeTheme().fg("text", text) },
|
|
58
|
+
);
|
|
59
|
+
return markdown.render(Math.max(1, width));
|
|
60
|
+
}
|
|
@@ -70,19 +70,38 @@ function historyLines(history: TaskEvent[]): string[] {
|
|
|
70
70
|
return lines;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
export interface TaskDetailContent {
|
|
74
|
+
headline: string;
|
|
75
|
+
identity: string;
|
|
76
|
+
labels: string[];
|
|
77
|
+
body: string;
|
|
78
|
+
sections: string[][];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function taskDetailContent(task: Artifact, history: TaskEvent[] = []): TaskDetailContent {
|
|
82
|
+
const sections: string[][] = [];
|
|
77
83
|
const checklist = checklistLines(task.extra["checklist"]);
|
|
78
|
-
if (checklist.length > 0)
|
|
84
|
+
if (checklist.length > 0) sections.push(checklist);
|
|
79
85
|
const gates = gateLines(task.extra["gates"]);
|
|
80
|
-
if (gates.length > 0)
|
|
86
|
+
if (gates.length > 0) sections.push(gates);
|
|
81
87
|
const metadata = Object.fromEntries(Object.entries(task.extra).filter(([key]) => key !== "checklist" && key !== "gates"));
|
|
82
|
-
if (Object.keys(metadata).length > 0) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
if (Object.keys(metadata).length > 0) sections.push(["Metadata:", ...formatMetadata(metadata).map((line) => ` ${line}`)]);
|
|
89
|
+
sections.push(historyLines(history));
|
|
90
|
+
return {
|
|
91
|
+
headline: `${TASK_STATUS_GLYPHS[task.status] ?? "?"} ${task.title}`,
|
|
92
|
+
identity: `${task.id} [task|${task.status}]`,
|
|
93
|
+
labels: [...task.labels],
|
|
94
|
+
body: task.body || "(no body)",
|
|
95
|
+
sections,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function taskDetailsText(task: Artifact, relationshipGraphLines: string[] = [], history: TaskEvent[] = []): string {
|
|
100
|
+
const content = taskDetailContent(task, history);
|
|
101
|
+
let output = `${content.headline}\n${content.identity}`;
|
|
102
|
+
if (content.labels.length > 0) output += `\nLabels: ${content.labels.join(", ")}`;
|
|
103
|
+
output += `\n\n${content.body}`;
|
|
104
|
+
for (const section of content.sections) output += `\n\n${section.join("\n")}`;
|
|
86
105
|
if (task.edges?.length) {
|
|
87
106
|
const graph = relationshipGraphLines.length > 0 ? relationshipGraphLines.join("\n") : " (graph unavailable)";
|
|
88
107
|
output += `\n\nRelationships:\n Dependencies point prerequisite → dependent.\n${graph}`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionCommandContext
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TUI } from "@earendil-works/pi-tui";
|
|
3
3
|
import {
|
|
4
4
|
TASK_DETAIL_HORIZONTAL_PAN_COLUMNS,
|
|
@@ -12,7 +12,9 @@ import type { GraphRenderer } from "../../src/ports/graph-renderer.ts";
|
|
|
12
12
|
import { projectTaskRelationships } from "../../src/task-relationship-view.ts";
|
|
13
13
|
import type { TaskGraph } from "../../src/task-service.ts";
|
|
14
14
|
import { BeautifulMermaidRenderer } from "./beautiful-mermaid-renderer.ts";
|
|
15
|
-
import { taskDetailsText } from "./task-detail-format.ts";
|
|
15
|
+
import { taskDetailContent, taskDetailsText, type TaskDetailContent } from "./task-detail-format.ts";
|
|
16
|
+
import { renderMarkdownBody, type ActiveTheme } from "./markdown.ts";
|
|
17
|
+
import { TASK_STATUS_PRESENTATION } from "./task-presentation.ts";
|
|
16
18
|
|
|
17
19
|
interface DetailLine {
|
|
18
20
|
text: string;
|
|
@@ -25,11 +27,12 @@ class TaskDetailViewport {
|
|
|
25
27
|
private renderedWidth = 0;
|
|
26
28
|
private detailLines: DetailLine[] = [];
|
|
27
29
|
private readonly visibleLines: number;
|
|
28
|
-
private readonly
|
|
30
|
+
private readonly content: TaskDetailContent;
|
|
31
|
+
private readonly status: Artifact["status"];
|
|
29
32
|
|
|
30
33
|
constructor(
|
|
31
34
|
private readonly tui: TUI,
|
|
32
|
-
private readonly
|
|
35
|
+
private readonly activeTheme: ActiveTheme,
|
|
33
36
|
task: Artifact,
|
|
34
37
|
private readonly graphLines: string[],
|
|
35
38
|
history: TaskEvent[],
|
|
@@ -39,7 +42,8 @@ class TaskDetailViewport {
|
|
|
39
42
|
TASK_DETAIL_MIN_VISIBLE_LINES,
|
|
40
43
|
Math.min(TASK_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - TASK_DETAIL_RESERVED_ROWS),
|
|
41
44
|
);
|
|
42
|
-
this.
|
|
45
|
+
this.content = taskDetailContent(task, history);
|
|
46
|
+
this.status = task.status;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
invalidate(): void { this.renderedWidth = 0; }
|
|
@@ -51,7 +55,8 @@ class TaskDetailViewport {
|
|
|
51
55
|
this.offsetX = Math.min(this.offsetX, Math.max(0, graphWidth - contentWidth));
|
|
52
56
|
this.offsetY = Math.min(this.offsetY, Math.max(0, this.detailLines.length - this.visibleLines));
|
|
53
57
|
const end = Math.min(this.detailLines.length, this.offsetY + this.visibleLines);
|
|
54
|
-
const
|
|
58
|
+
const theme = this.activeTheme();
|
|
59
|
+
const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
|
|
55
60
|
const footer = [
|
|
56
61
|
graphWidth > contentWidth ? `←/→ graph · column ${this.offsetX + 1}/${graphWidth}` : "",
|
|
57
62
|
this.detailLines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.detailLines.length}` : "",
|
|
@@ -59,12 +64,12 @@ class TaskDetailViewport {
|
|
|
59
64
|
].filter(Boolean).join(" · ");
|
|
60
65
|
return [
|
|
61
66
|
border,
|
|
62
|
-
truncateToWidth(
|
|
67
|
+
truncateToWidth(theme.fg("accent", theme.bold("Task details")), width, ""),
|
|
63
68
|
border,
|
|
64
69
|
...this.detailLines.slice(this.offsetY, end).map((line) => line.graph
|
|
65
70
|
? ` ${sliceByColumn(line.text, this.offsetX, contentWidth, true)}`
|
|
66
71
|
: truncateToWidth(` ${line.text}`, width, "")),
|
|
67
|
-
truncateToWidth(
|
|
72
|
+
truncateToWidth(theme.fg("dim", footer), width, ""),
|
|
68
73
|
border,
|
|
69
74
|
];
|
|
70
75
|
}
|
|
@@ -82,16 +87,36 @@ class TaskDetailViewport {
|
|
|
82
87
|
private buildLines(width: number): void {
|
|
83
88
|
if (this.renderedWidth === width) return;
|
|
84
89
|
this.renderedWidth = width;
|
|
85
|
-
const
|
|
86
|
-
|
|
90
|
+
const theme = this.activeTheme();
|
|
91
|
+
const wrap = (text: string, color: "text" | "muted" | "dim" = "text"): DetailLine[] =>
|
|
92
|
+
(text.length === 0 ? [""] : wrapTextWithAnsi(theme.fg(color, text), width)).map((line) => ({ text: line, graph: false }));
|
|
93
|
+
const status = TASK_STATUS_PRESENTATION[this.status as keyof typeof TASK_STATUS_PRESENTATION];
|
|
94
|
+
const headline = status ? theme.fg(status.color, theme.bold(this.content.headline)) : theme.bold(this.content.headline);
|
|
95
|
+
const identity = [
|
|
96
|
+
...wrapTextWithAnsi(headline, width).map((text) => ({ text, graph: false })),
|
|
97
|
+
...wrap(this.content.identity, "muted"),
|
|
98
|
+
...(this.content.labels.length > 0 ? wrap(`Labels: ${this.content.labels.join(", ")}`, "muted") : []),
|
|
99
|
+
{ text: "", graph: false },
|
|
100
|
+
];
|
|
101
|
+
const body = renderMarkdownBody(this.content.body, width, this.activeTheme).map((text) => ({ text, graph: false }));
|
|
102
|
+
const sections = this.content.sections.flatMap((section) => [
|
|
103
|
+
{ text: "", graph: false },
|
|
104
|
+
...section.flatMap((line, index) => wrap(line, index === 0 ? "muted" : "dim")),
|
|
105
|
+
]);
|
|
87
106
|
const relationshipHeader = this.graphLines.length > 0
|
|
88
107
|
? [
|
|
89
108
|
{ text: "", graph: false },
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
...wrap("Relationships:", "muted"),
|
|
110
|
+
...wrap(" Dependencies point prerequisite → dependent.", "dim"),
|
|
92
111
|
]
|
|
93
112
|
: [];
|
|
94
|
-
this.detailLines = [
|
|
113
|
+
this.detailLines = [
|
|
114
|
+
...identity,
|
|
115
|
+
...body,
|
|
116
|
+
...sections,
|
|
117
|
+
...relationshipHeader,
|
|
118
|
+
...this.graphLines.map((text) => ({ text: theme.fg("text", text), graph: true })),
|
|
119
|
+
];
|
|
95
120
|
this.offsetY = Math.min(this.offsetY, Math.max(0, this.detailLines.length - this.visibleLines));
|
|
96
121
|
}
|
|
97
122
|
}
|
|
@@ -110,5 +135,5 @@ export async function showTaskDetails(
|
|
|
110
135
|
return;
|
|
111
136
|
}
|
|
112
137
|
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
|
|
113
|
-
new TaskDetailViewport(tui, theme, task, relationshipGraph, history, done));
|
|
138
|
+
new TaskDetailViewport(tui, () => ctx.ui.theme ?? theme, task, relationshipGraph, history, done));
|
|
114
139
|
}
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -29,6 +29,11 @@ export const TASK_DETAIL_MIN_VISIBLE_LINES = 8;
|
|
|
29
29
|
export const TASK_DETAIL_MAX_VISIBLE_LINES = 24;
|
|
30
30
|
export const TASK_DETAIL_RESERVED_ROWS = 8;
|
|
31
31
|
export const TASK_DETAIL_HORIZONTAL_PAN_COLUMNS = 4;
|
|
32
|
+
/** Bounded navigable detail views for non-Task artifacts. */
|
|
33
|
+
export const ARTIFACT_DETAIL_MIN_VISIBLE_LINES = 8;
|
|
34
|
+
export const ARTIFACT_DETAIL_MAX_VISIBLE_LINES = 24;
|
|
35
|
+
export const ARTIFACT_DETAIL_RESERVED_ROWS = 8;
|
|
36
|
+
export const ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS = 4;
|
|
32
37
|
export const TASK_GRAPH_MIN_VISIBLE_LINES = 8;
|
|
33
38
|
export const TASK_GRAPH_MAX_VISIBLE_LINES = 30;
|
|
34
39
|
export const TASK_GRAPH_RESERVED_ROWS = 8;
|