prdforge-cli 0.1.0 → 0.2.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.
@@ -0,0 +1,215 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ import { theme } from "./theme.js";
5
+
6
+ const TOTAL_STAGES = 8;
7
+
8
+ function StageDots({ completedStages, stale }) {
9
+ const count = completedStages || 0;
10
+ return React.createElement(
11
+ Box,
12
+ { gap: 0 },
13
+ ...Array.from({ length: TOTAL_STAGES }, (_, i) => {
14
+ if (stale && i === count - 1)
15
+ return React.createElement(Text, { key: i, color: theme.warning }, "⚠");
16
+ if (i < count)
17
+ return React.createElement(Text, { key: i, color: theme.success }, "✓");
18
+ return React.createElement(Text, { key: i, color: theme.dim }, "●");
19
+ })
20
+ );
21
+ }
22
+
23
+ /**
24
+ * Left pane: scrollable, searchable project list.
25
+ *
26
+ * @param {{
27
+ * projects: object[],
28
+ * isFocused: boolean,
29
+ * height: number,
30
+ * onOpenPreview: (project) => void,
31
+ * onEnterRepl: (project) => void,
32
+ * onNewProject: () => void,
33
+ * onDelete: (project) => void,
34
+ * onExport: (project) => void,
35
+ * }} props
36
+ */
37
+ export function LeftPane({
38
+ projects = [],
39
+ isFocused,
40
+ height = 20,
41
+ onOpenPreview,
42
+ onEnterRepl,
43
+ onNewProject,
44
+ onDelete,
45
+ onExport,
46
+ }) {
47
+ const [selectedIdx, setSelectedIdx] = useState(0);
48
+ const [searching, setSearching] = useState(false);
49
+ const [searchQuery, setSearchQuery] = useState("");
50
+ const [deleteConfirm, setDeleteConfirm] = useState(false);
51
+
52
+ const filtered = searchQuery.trim()
53
+ ? projects.filter((p) =>
54
+ (p.name ?? "").toLowerCase().includes(searchQuery.toLowerCase())
55
+ )
56
+ : projects;
57
+
58
+ // Clamp selectedIdx when filtered list changes
59
+ useEffect(() => {
60
+ setSelectedIdx((i) => Math.min(i, Math.max(0, filtered.length - 1)));
61
+ }, [filtered.length]);
62
+
63
+ // Chrome: search bar (1) + header (1) + footer hint (1)
64
+ const CHROME = 3;
65
+ const visibleRows = Math.max(1, height - CHROME);
66
+
67
+ // Keep selected item centered in visible window
68
+ const offset = Math.min(
69
+ Math.max(0, selectedIdx - Math.floor(visibleRows / 2)),
70
+ Math.max(0, filtered.length - visibleRows)
71
+ );
72
+ const visible = filtered.slice(offset, offset + visibleRows);
73
+ const remaining = Math.max(0, filtered.length - offset - visibleRows);
74
+
75
+ useInput(
76
+ (input, key) => {
77
+ if (deleteConfirm) {
78
+ if (input === "y") {
79
+ setDeleteConfirm(false);
80
+ if (filtered[selectedIdx]) onDelete?.(filtered[selectedIdx]);
81
+ } else {
82
+ setDeleteConfirm(false);
83
+ }
84
+ return;
85
+ }
86
+
87
+ if (searching) {
88
+ if (key.escape) { setSearching(false); setSearchQuery(""); }
89
+ return; // TextInput handles chars
90
+ }
91
+
92
+ if (key.upArrow || input === "k") {
93
+ setSelectedIdx((i) => Math.max(0, i - 1));
94
+ }
95
+ if (key.downArrow || input === "j") {
96
+ setSelectedIdx((i) => Math.min(Math.max(0, filtered.length - 1), i + 1));
97
+ }
98
+ if (input === "/") {
99
+ setSearching(true);
100
+ setSearchQuery("");
101
+ }
102
+ if (key.return && filtered[selectedIdx]) {
103
+ onOpenPreview?.(filtered[selectedIdx]);
104
+ }
105
+ if (input === "e" && filtered[selectedIdx]) {
106
+ onEnterRepl?.(filtered[selectedIdx]);
107
+ }
108
+ if (input === "n") {
109
+ onNewProject?.();
110
+ }
111
+ if (input === "x" && filtered[selectedIdx]) {
112
+ onExport?.(filtered[selectedIdx]);
113
+ }
114
+ if (input === "d" && filtered[selectedIdx]) {
115
+ setDeleteConfirm(true);
116
+ }
117
+ },
118
+ { isActive: isFocused }
119
+ );
120
+
121
+ // ── Render ────────────────────────────────────────────────────────────────
122
+
123
+ const header = React.createElement(
124
+ Box,
125
+ { marginBottom: 0 },
126
+ React.createElement(Text, { color: theme.dim, bold: true }, " Projects"),
127
+ React.createElement(Text, { color: theme.dim }, ` (${projects.length})`)
128
+ );
129
+
130
+ const searchBar = searching
131
+ ? React.createElement(
132
+ Box,
133
+ { marginY: 0, paddingX: 1 },
134
+ React.createElement(Text, { color: theme.primary }, "/ "),
135
+ React.createElement(TextInput, {
136
+ value: searchQuery,
137
+ onChange: setSearchQuery,
138
+ placeholder: "search…",
139
+ focus: true,
140
+ })
141
+ )
142
+ : React.createElement(
143
+ Box,
144
+ { marginY: 0, paddingX: 1 },
145
+ React.createElement(Text, { color: theme.dim }, "/ search")
146
+ );
147
+
148
+ const rows = visible.map((p, visIdx) => {
149
+ const absIdx = offset + visIdx;
150
+ const isSelected = absIdx === selectedIdx;
151
+ const nameStr = (p.name || "(untitled)").padEnd(18).slice(0, 18);
152
+
153
+ return React.createElement(
154
+ Box,
155
+ { key: p.id, flexDirection: "column" },
156
+ React.createElement(
157
+ Box,
158
+ null,
159
+ React.createElement(
160
+ Text,
161
+ { color: isSelected ? theme.primary : theme.dim, bold: isSelected },
162
+ isSelected ? "❯ " : " "
163
+ ),
164
+ React.createElement(
165
+ Text,
166
+ { color: isSelected ? theme.white : theme.dim, bold: isSelected },
167
+ nameStr + " "
168
+ ),
169
+ React.createElement(StageDots, {
170
+ completedStages: p.completed_stages,
171
+ stale: p.has_stale_stages,
172
+ })
173
+ ),
174
+ isSelected && deleteConfirm
175
+ ? React.createElement(
176
+ Box,
177
+ { paddingLeft: 2 },
178
+ React.createElement(Text, { color: theme.error }, "Delete? "),
179
+ React.createElement(Text, { color: theme.dim }, "[y/N]")
180
+ )
181
+ : null
182
+ );
183
+ });
184
+
185
+ const scrollHint =
186
+ remaining > 0
187
+ ? React.createElement(
188
+ Box,
189
+ { paddingLeft: 2 },
190
+ React.createElement(Text, { color: theme.dim }, `↓ ${remaining} more`)
191
+ )
192
+ : null;
193
+
194
+ const footer = React.createElement(
195
+ Box,
196
+ { marginTop: 0, paddingX: 1 },
197
+ React.createElement(
198
+ Text,
199
+ { color: theme.dim },
200
+ isFocused
201
+ ? "e repl /search n new x export d delete"
202
+ : "Tab: focus"
203
+ )
204
+ );
205
+
206
+ return React.createElement(
207
+ Box,
208
+ { flexDirection: "column", flexGrow: 0, borderStyle: "single", borderColor: isFocused ? theme.primary : theme.dim },
209
+ header,
210
+ searchBar,
211
+ ...rows,
212
+ scrollHint,
213
+ footer
214
+ );
215
+ }
@@ -31,7 +31,7 @@ export function PrdCreation({ projectName, stages }) {
31
31
  React.createElement(
32
32
  Box,
33
33
  { key: i, marginLeft: 2, marginBottom: 0 },
34
- React.createElement(StageIndicator, { label: stage.label, status: stage.status })
34
+ React.createElement(StageIndicator, { label: stage.label, status: stage.status, preview: stage.preview })
35
35
  )
36
36
  )
37
37
  );
@@ -0,0 +1,148 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { theme } from "./theme.js";
4
+
5
+ /**
6
+ * Right pane: scrollable PRD section content for the selected project.
7
+ *
8
+ * @param {{
9
+ * project: object | null,
10
+ * isFocused: boolean,
11
+ * height: number,
12
+ * onEnterRepl: () => void,
13
+ * }} props
14
+ */
15
+ export function PreviewPanel({ project, isFocused, height = 20, onEnterRepl }) {
16
+ const [lines, setLines] = useState([]);
17
+ const [loading, setLoading] = useState(false);
18
+ const [error, setError] = useState(null);
19
+ const [offset, setOffset] = useState(0);
20
+
21
+ useEffect(() => {
22
+ if (!project) { setLines([]); return; }
23
+ setLoading(true);
24
+ setError(null);
25
+ setOffset(0);
26
+
27
+ import("../api/client.js").then(({ prd }) =>
28
+ prd.getSections(project.id)
29
+ ).then((sections) => {
30
+ const all = [];
31
+ if (Array.isArray(sections)) {
32
+ for (const sec of sections) {
33
+ if (sec.section_type) {
34
+ all.push(`## ${sec.section_type}`);
35
+ all.push("");
36
+ }
37
+ if (sec.content) {
38
+ all.push(...sec.content.split("\n"));
39
+ all.push("");
40
+ }
41
+ }
42
+ }
43
+ setLines(all.length ? all : ["(no content yet)"]);
44
+ }).catch((e) => {
45
+ setError(e.message ?? "Failed to load PRD");
46
+ }).finally(() => {
47
+ setLoading(false);
48
+ });
49
+ }, [project?.id]);
50
+
51
+ const CHROME = 3; // header + footer
52
+ const visibleRows = Math.max(1, height - CHROME);
53
+ const maxOffset = Math.max(0, lines.length - visibleRows);
54
+ const visible = lines.slice(offset, offset + visibleRows);
55
+
56
+ useInput(
57
+ (input, key) => {
58
+ if (key.upArrow || input === "k") {
59
+ setOffset((o) => Math.max(0, o - 1));
60
+ }
61
+ if (key.downArrow || input === "j") {
62
+ setOffset((o) => Math.min(maxOffset, o + 1));
63
+ }
64
+ if (input === "e" && project) {
65
+ onEnterRepl?.();
66
+ }
67
+ },
68
+ { isActive: isFocused }
69
+ );
70
+
71
+ const header = React.createElement(
72
+ Box,
73
+ { paddingX: 1 },
74
+ React.createElement(
75
+ Text,
76
+ { bold: true, color: theme.primary },
77
+ project ? project.name ?? "(untitled)" : "Select a project"
78
+ ),
79
+ project
80
+ ? React.createElement(Text, { color: theme.dim }, " PRD Preview")
81
+ : null
82
+ );
83
+
84
+ let content;
85
+ if (loading) {
86
+ content = React.createElement(
87
+ Box,
88
+ { paddingX: 1, flexGrow: 1 },
89
+ React.createElement(Text, { color: theme.dim }, "Loading…")
90
+ );
91
+ } else if (error) {
92
+ content = React.createElement(
93
+ Box,
94
+ { paddingX: 1, flexGrow: 1 },
95
+ React.createElement(Text, { color: theme.error }, error)
96
+ );
97
+ } else if (!project) {
98
+ content = React.createElement(
99
+ Box,
100
+ { paddingX: 1, flexGrow: 1 },
101
+ React.createElement(Text, { color: theme.dim }, "↑↓ navigate Enter select e open REPL")
102
+ );
103
+ } else {
104
+ const rows = visible.map((line, i) =>
105
+ React.createElement(
106
+ Box,
107
+ { key: i, paddingX: 1 },
108
+ React.createElement(
109
+ Text,
110
+ {
111
+ color: line.startsWith("##")
112
+ ? theme.primary
113
+ : line.startsWith("#")
114
+ ? theme.white
115
+ : theme.dim,
116
+ bold: line.startsWith("#"),
117
+ },
118
+ line || " "
119
+ )
120
+ )
121
+ );
122
+ content = React.createElement(Box, { flexDirection: "column", flexGrow: 1 }, ...rows);
123
+ }
124
+
125
+ const scrollInfo = lines.length > visibleRows
126
+ ? `${offset + 1}-${Math.min(offset + visibleRows, lines.length)}/${lines.length} lines`
127
+ : "";
128
+
129
+ const footer = React.createElement(
130
+ Box,
131
+ { paddingX: 1 },
132
+ React.createElement(
133
+ Text,
134
+ { color: theme.dim },
135
+ isFocused
136
+ ? `↑↓ scroll e edit in REPL Tab: left pane${scrollInfo ? " " + scrollInfo : ""}`
137
+ : "Tab: focus"
138
+ )
139
+ );
140
+
141
+ return React.createElement(
142
+ Box,
143
+ { flexDirection: "column", flexGrow: 1, borderStyle: "single", borderColor: isFocused ? theme.accent : theme.dim },
144
+ header,
145
+ content,
146
+ footer
147
+ );
148
+ }