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.
- package/package.json +2 -2
- package/src/api/client.js +35 -9
- package/src/api/stream.js +97 -0
- package/src/commands/auth.js +132 -1
- package/src/commands/dashboard.js +2 -74
- package/src/hooks/useScroll.js +20 -0
- package/src/hooks/useStream.js +144 -0
- package/src/hooks/useTerminalSize.js +24 -0
- package/src/index.js +10 -5
- package/src/ui/LeftPane.js +215 -0
- package/src/ui/PrdCreation.js +1 -1
- package/src/ui/PreviewPanel.js +148 -0
- package/src/ui/REPLDashboard.js +418 -0
- package/src/ui/ReplPanel.js +227 -0
- package/src/ui/RightPane.js +66 -0
- package/src/ui/StageIndicator.js +51 -20
- package/src/ui/theme.js +4 -2
- package/src/utils/debug.js +7 -0
- package/src/utils/logo.js +18 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { Box, Text, useInput, useApp } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
import { LeftPane } from "./LeftPane.js";
|
|
5
|
+
import { RightPane } from "./RightPane.js";
|
|
6
|
+
import { PrdCreation, buildStages, setStageStatus, markAllDone } from "./PrdCreation.js";
|
|
7
|
+
import { theme } from "./theme.js";
|
|
8
|
+
import { useTerminalSize } from "../hooks/useTerminalSize.js";
|
|
9
|
+
import { useStream } from "../hooks/useStream.js";
|
|
10
|
+
|
|
11
|
+
const FALLBACK_MODELS = [
|
|
12
|
+
{ model_id: null, name: "Default", is_free_tier: true, credit_cost: 4 },
|
|
13
|
+
{ model_id: "standard", name: "Standard", is_free_tier: true, credit_cost: 4 },
|
|
14
|
+
{ model_id: "premium", name: "Premium", is_free_tier: false, credit_cost: 8 },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// ── Compose form (new project) ────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function ComposeForm({ onSubmit, onCancel, modelList, modelIdx, onTabModel }) {
|
|
20
|
+
const [step, setStep] = useState("name"); // "name" | "prompt"
|
|
21
|
+
const [projectName, setProjectName] = useState("");
|
|
22
|
+
const [promptText, setPromptText] = useState("");
|
|
23
|
+
const model = modelList[modelIdx];
|
|
24
|
+
|
|
25
|
+
useInput((input, key) => {
|
|
26
|
+
if (key.escape) { onCancel?.(); return; }
|
|
27
|
+
if (key.tab) { onTabModel?.(); return; }
|
|
28
|
+
if (key.return) {
|
|
29
|
+
if (step === "name" && projectName.trim()) {
|
|
30
|
+
setStep("prompt");
|
|
31
|
+
} else if (step === "prompt" && promptText.trim()) {
|
|
32
|
+
onSubmit?.({ name: projectName.trim(), prompt: promptText.trim(), modelId: model?.model_id ?? null });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return React.createElement(
|
|
38
|
+
Box,
|
|
39
|
+
{ flexDirection: "column", paddingX: 1, paddingY: 1 },
|
|
40
|
+
React.createElement(
|
|
41
|
+
Box,
|
|
42
|
+
{ marginBottom: 1 },
|
|
43
|
+
React.createElement(Text, { bold: true, color: theme.primary }, "✦ New PRD"),
|
|
44
|
+
React.createElement(Text, { color: theme.dim }, " Esc to cancel")
|
|
45
|
+
),
|
|
46
|
+
|
|
47
|
+
// Step 1: project name
|
|
48
|
+
React.createElement(
|
|
49
|
+
Box,
|
|
50
|
+
{ marginBottom: 1 },
|
|
51
|
+
React.createElement(Text, { color: step === "name" ? theme.white : theme.dim }, "Project name: "),
|
|
52
|
+
step === "name"
|
|
53
|
+
? React.createElement(TextInput, {
|
|
54
|
+
value: projectName,
|
|
55
|
+
onChange: setProjectName,
|
|
56
|
+
placeholder: "e.g. TaskFlow",
|
|
57
|
+
focus: true,
|
|
58
|
+
})
|
|
59
|
+
: React.createElement(Text, { color: theme.success }, projectName)
|
|
60
|
+
),
|
|
61
|
+
|
|
62
|
+
// Step 2: prompt
|
|
63
|
+
step !== "name"
|
|
64
|
+
? React.createElement(
|
|
65
|
+
Box,
|
|
66
|
+
{ marginBottom: 1 },
|
|
67
|
+
React.createElement(Text, { color: theme.white }, "Describe your product: "),
|
|
68
|
+
React.createElement(TextInput, {
|
|
69
|
+
value: promptText,
|
|
70
|
+
onChange: setPromptText,
|
|
71
|
+
placeholder: "A task management tool for remote teams…",
|
|
72
|
+
focus: step === "prompt",
|
|
73
|
+
})
|
|
74
|
+
)
|
|
75
|
+
: null,
|
|
76
|
+
|
|
77
|
+
// Model pill
|
|
78
|
+
React.createElement(
|
|
79
|
+
Box,
|
|
80
|
+
{ marginTop: 1 },
|
|
81
|
+
React.createElement(Text, { color: theme.dim }, "Model: "),
|
|
82
|
+
React.createElement(Text, { color: theme.accent }, model?.name ?? "Default"),
|
|
83
|
+
React.createElement(Text, { color: theme.dim }, " Tab to cycle")
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── REPLDashboard ─────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Root TUI component replacing the original Dashboard.
|
|
92
|
+
* Provides: two-pane layout, SSE streaming generation, REPL loop.
|
|
93
|
+
*/
|
|
94
|
+
export function REPLDashboard() {
|
|
95
|
+
const { exit } = useApp();
|
|
96
|
+
const { columns, rows } = useTerminalSize();
|
|
97
|
+
const isTwoPaneCapable = columns >= 80;
|
|
98
|
+
|
|
99
|
+
// ── Data state ──────────────────────────────────────────────────────────────
|
|
100
|
+
const [projects, setProjects] = useState([]);
|
|
101
|
+
const [userInfo, setUserInfo] = useState(null);
|
|
102
|
+
const [modelList, setModelList] = useState(FALLBACK_MODELS);
|
|
103
|
+
const [loading, setLoading] = useState(true);
|
|
104
|
+
const [statusMsg, setStatusMsg] = useState(null);
|
|
105
|
+
|
|
106
|
+
// ── UI state ────────────────────────────────────────────────────────────────
|
|
107
|
+
const [panelMode, setPanelMode] = useState("preview"); // "preview"|"repl"|"compose"|"creating"
|
|
108
|
+
const [activeProject, setActiveProject] = useState(null);
|
|
109
|
+
const [leftFocused, setLeftFocused] = useState(true);
|
|
110
|
+
const [narrowPane, setNarrowPane] = useState("left"); // "left"|"right" for < 80 col
|
|
111
|
+
const [modelIdx, setModelIdx] = useState(0);
|
|
112
|
+
|
|
113
|
+
// ── Streaming state (REPL + creation) ───────────────────────────────────────
|
|
114
|
+
const { stages, setStages, messages, setMessages, isStreaming, error, startStream, abort } = useStream();
|
|
115
|
+
|
|
116
|
+
// Creation streaming state (separate from REPL)
|
|
117
|
+
const [creatingName, setCreatingName] = useState(null);
|
|
118
|
+
const [creationStages, setCreationStages] = useState([]);
|
|
119
|
+
|
|
120
|
+
// ── Load data ───────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const loadData = useCallback(async () => {
|
|
123
|
+
setLoading(true);
|
|
124
|
+
setStatusMsg(null);
|
|
125
|
+
try {
|
|
126
|
+
const { projects: api, user, models } = await import("../api/client.js");
|
|
127
|
+
const [projs, info, mods] = await Promise.all([
|
|
128
|
+
api.list(),
|
|
129
|
+
user.validate(),
|
|
130
|
+
models.list(),
|
|
131
|
+
]);
|
|
132
|
+
setProjects(Array.isArray(projs) ? projs : []);
|
|
133
|
+
setUserInfo(info);
|
|
134
|
+
if (Array.isArray(mods) && mods.length > 0) setModelList(mods);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
setStatusMsg(err.message);
|
|
137
|
+
} finally {
|
|
138
|
+
setLoading(false);
|
|
139
|
+
}
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
useEffect(() => { loadData(); }, []);
|
|
143
|
+
|
|
144
|
+
// ── Global keyboard ─────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
useInput((input, key) => {
|
|
147
|
+
if (loading || panelMode === "compose" || panelMode === "creating") return;
|
|
148
|
+
|
|
149
|
+
// q or Esc at top level exits
|
|
150
|
+
if ((input === "q" || key.escape) && !isStreaming && panelMode !== "repl") {
|
|
151
|
+
exit();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Tab: switch pane focus (two-pane) or toggle narrow pane
|
|
156
|
+
if (key.tab) {
|
|
157
|
+
if (isTwoPaneCapable) {
|
|
158
|
+
setLeftFocused((f) => !f);
|
|
159
|
+
} else {
|
|
160
|
+
setNarrowPane((p) => (p === "left" ? "right" : "left"));
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── Callbacks ───────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
const handleOpenPreview = useCallback((project) => {
|
|
169
|
+
setActiveProject(project);
|
|
170
|
+
setPanelMode("preview");
|
|
171
|
+
if (isTwoPaneCapable) setLeftFocused(false);
|
|
172
|
+
else setNarrowPane("right");
|
|
173
|
+
}, [isTwoPaneCapable]);
|
|
174
|
+
|
|
175
|
+
const handleEnterRepl = useCallback((project) => {
|
|
176
|
+
setActiveProject(project);
|
|
177
|
+
setMessages([]);
|
|
178
|
+
setStages([]);
|
|
179
|
+
setPanelMode("repl");
|
|
180
|
+
if (isTwoPaneCapable) setLeftFocused(false);
|
|
181
|
+
else setNarrowPane("right");
|
|
182
|
+
}, [isTwoPaneCapable, setMessages, setStages]);
|
|
183
|
+
|
|
184
|
+
const handleExitRepl = useCallback(() => {
|
|
185
|
+
if (isStreaming) return;
|
|
186
|
+
setPanelMode("preview");
|
|
187
|
+
setLeftFocused(true);
|
|
188
|
+
if (!isTwoPaneCapable) setNarrowPane("left");
|
|
189
|
+
}, [isStreaming, isTwoPaneCapable]);
|
|
190
|
+
|
|
191
|
+
const handleNewProject = useCallback(() => {
|
|
192
|
+
setPanelMode("compose");
|
|
193
|
+
if (isTwoPaneCapable) setLeftFocused(false);
|
|
194
|
+
else setNarrowPane("right");
|
|
195
|
+
}, [isTwoPaneCapable]);
|
|
196
|
+
|
|
197
|
+
const handleComposeCancel = useCallback(() => {
|
|
198
|
+
setPanelMode("preview");
|
|
199
|
+
setLeftFocused(true);
|
|
200
|
+
if (!isTwoPaneCapable) setNarrowPane("left");
|
|
201
|
+
}, [isTwoPaneCapable]);
|
|
202
|
+
|
|
203
|
+
const handleComposeSubmit = useCallback(async ({ name, prompt, modelId }) => {
|
|
204
|
+
const { projects: api, isAuthError } = await import("../api/client.js");
|
|
205
|
+
setPanelMode("creating");
|
|
206
|
+
setCreatingName(name);
|
|
207
|
+
const initStages = buildStages(theme.stages);
|
|
208
|
+
setCreationStages(initStages);
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const project = await api.create(name, prompt);
|
|
212
|
+
await startStream(
|
|
213
|
+
{
|
|
214
|
+
action: "generate_from_prompt",
|
|
215
|
+
project_id: project.id,
|
|
216
|
+
prompt,
|
|
217
|
+
...(modelId ? { model_id: modelId } : {}),
|
|
218
|
+
},
|
|
219
|
+
{ initialStages: theme.stages }
|
|
220
|
+
);
|
|
221
|
+
await loadData();
|
|
222
|
+
setActiveProject(project);
|
|
223
|
+
setPanelMode("preview");
|
|
224
|
+
setLeftFocused(true);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
setStatusMsg(e.message);
|
|
227
|
+
setPanelMode("preview");
|
|
228
|
+
}
|
|
229
|
+
}, [startStream, loadData]);
|
|
230
|
+
|
|
231
|
+
const handleReplSubmit = useCallback((text) => {
|
|
232
|
+
if (!activeProject || isStreaming) return;
|
|
233
|
+
setMessages((prev) => [
|
|
234
|
+
...prev,
|
|
235
|
+
{ id: crypto.randomUUID(), role: "user", text, status: "done" },
|
|
236
|
+
]);
|
|
237
|
+
startStream({
|
|
238
|
+
action: "smart_update",
|
|
239
|
+
project_id: activeProject.id,
|
|
240
|
+
prompt: text,
|
|
241
|
+
...(modelList[modelIdx]?.model_id ? { model_id: modelList[modelIdx].model_id } : {}),
|
|
242
|
+
});
|
|
243
|
+
}, [activeProject, isStreaming, setMessages, startStream, modelList, modelIdx]);
|
|
244
|
+
|
|
245
|
+
const handleDelete = useCallback(async (project) => {
|
|
246
|
+
try {
|
|
247
|
+
const { projects: api } = await import("../api/client.js");
|
|
248
|
+
await api.delete(project.id);
|
|
249
|
+
if (activeProject?.id === project.id) {
|
|
250
|
+
setActiveProject(null);
|
|
251
|
+
setPanelMode("preview");
|
|
252
|
+
}
|
|
253
|
+
await loadData();
|
|
254
|
+
} catch (e) {
|
|
255
|
+
setStatusMsg(e.message);
|
|
256
|
+
}
|
|
257
|
+
}, [activeProject, loadData]);
|
|
258
|
+
|
|
259
|
+
const handleExport = useCallback(async (project) => {
|
|
260
|
+
try {
|
|
261
|
+
const { exports_ } = await import("../api/client.js");
|
|
262
|
+
const result = await exports_.export(project.id, "markdown");
|
|
263
|
+
if (result?.content) {
|
|
264
|
+
const filename = `${(project.name ?? "prd").replace(/\s+/g, "-").toLowerCase()}.md`;
|
|
265
|
+
const { writeFileSync } = await import("fs");
|
|
266
|
+
writeFileSync(filename, result.content, "utf8");
|
|
267
|
+
setStatusMsg(`Exported to ${filename}`);
|
|
268
|
+
}
|
|
269
|
+
} catch (e) {
|
|
270
|
+
setStatusMsg(e.message);
|
|
271
|
+
}
|
|
272
|
+
}, []);
|
|
273
|
+
|
|
274
|
+
// ── Header ──────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
const header = React.createElement(
|
|
277
|
+
Box,
|
|
278
|
+
{ justifyContent: "space-between", paddingX: 1, marginBottom: 0 },
|
|
279
|
+
React.createElement(
|
|
280
|
+
Box,
|
|
281
|
+
{ gap: 1 },
|
|
282
|
+
React.createElement(Text, { bold: true, color: theme.primary }, "PRDForge"),
|
|
283
|
+
React.createElement(Text, { color: theme.dim }, "v0.1.1")
|
|
284
|
+
),
|
|
285
|
+
React.createElement(
|
|
286
|
+
Box,
|
|
287
|
+
{ gap: 1 },
|
|
288
|
+
statusMsg
|
|
289
|
+
? React.createElement(Text, { color: theme.warning }, statusMsg.slice(0, 40))
|
|
290
|
+
: userInfo
|
|
291
|
+
? React.createElement(
|
|
292
|
+
Text,
|
|
293
|
+
{ color: theme.dim },
|
|
294
|
+
[
|
|
295
|
+
userInfo.email,
|
|
296
|
+
userInfo.credits_used_this_month != null
|
|
297
|
+
? `${userInfo.credits_used_this_month} cr`
|
|
298
|
+
: null,
|
|
299
|
+
userInfo.subscription,
|
|
300
|
+
]
|
|
301
|
+
.filter(Boolean)
|
|
302
|
+
.join(" · ")
|
|
303
|
+
)
|
|
304
|
+
: loading
|
|
305
|
+
? React.createElement(Text, { color: theme.dim }, "Loading…")
|
|
306
|
+
: null
|
|
307
|
+
)
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// ── Content area ─────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
const contentHeight = rows - 2; // minus header + narrow footer
|
|
313
|
+
|
|
314
|
+
let content;
|
|
315
|
+
|
|
316
|
+
if (panelMode === "compose") {
|
|
317
|
+
content = React.createElement(ComposeForm, {
|
|
318
|
+
onSubmit: handleComposeSubmit,
|
|
319
|
+
onCancel: handleComposeCancel,
|
|
320
|
+
modelList,
|
|
321
|
+
modelIdx,
|
|
322
|
+
onTabModel: () => setModelIdx((i) => (i + 1) % modelList.length),
|
|
323
|
+
});
|
|
324
|
+
} else if (panelMode === "creating") {
|
|
325
|
+
content = React.createElement(PrdCreation, {
|
|
326
|
+
projectName: creatingName ?? "New Project",
|
|
327
|
+
stages: stages.length > 0
|
|
328
|
+
? stages
|
|
329
|
+
: buildStages(theme.stages),
|
|
330
|
+
});
|
|
331
|
+
} else if (isTwoPaneCapable) {
|
|
332
|
+
content = React.createElement(
|
|
333
|
+
Box,
|
|
334
|
+
{ flexDirection: "row", flexGrow: 1 },
|
|
335
|
+
React.createElement(LeftPane, {
|
|
336
|
+
projects,
|
|
337
|
+
isFocused: leftFocused,
|
|
338
|
+
height: contentHeight,
|
|
339
|
+
onOpenPreview: handleOpenPreview,
|
|
340
|
+
onEnterRepl: handleEnterRepl,
|
|
341
|
+
onNewProject: handleNewProject,
|
|
342
|
+
onDelete: handleDelete,
|
|
343
|
+
onExport: handleExport,
|
|
344
|
+
}),
|
|
345
|
+
React.createElement(RightPane, {
|
|
346
|
+
mode: panelMode,
|
|
347
|
+
project: activeProject,
|
|
348
|
+
isFocused: !leftFocused,
|
|
349
|
+
height: contentHeight,
|
|
350
|
+
onEnterRepl: () => activeProject && handleEnterRepl(activeProject),
|
|
351
|
+
messages,
|
|
352
|
+
stages,
|
|
353
|
+
isStreaming,
|
|
354
|
+
modelList,
|
|
355
|
+
modelIdx,
|
|
356
|
+
onTabModel: () => setModelIdx((i) => (i + 1) % modelList.length),
|
|
357
|
+
onSubmit: handleReplSubmit,
|
|
358
|
+
onAbort: abort,
|
|
359
|
+
onExitRepl: handleExitRepl,
|
|
360
|
+
})
|
|
361
|
+
);
|
|
362
|
+
} else {
|
|
363
|
+
// Narrow: show one pane at a time
|
|
364
|
+
content = narrowPane === "left"
|
|
365
|
+
? React.createElement(LeftPane, {
|
|
366
|
+
projects,
|
|
367
|
+
isFocused: true,
|
|
368
|
+
height: contentHeight,
|
|
369
|
+
onOpenPreview: handleOpenPreview,
|
|
370
|
+
onEnterRepl: handleEnterRepl,
|
|
371
|
+
onNewProject: handleNewProject,
|
|
372
|
+
onDelete: handleDelete,
|
|
373
|
+
onExport: handleExport,
|
|
374
|
+
})
|
|
375
|
+
: React.createElement(RightPane, {
|
|
376
|
+
mode: panelMode,
|
|
377
|
+
project: activeProject,
|
|
378
|
+
isFocused: true,
|
|
379
|
+
height: contentHeight,
|
|
380
|
+
onEnterRepl: () => activeProject && handleEnterRepl(activeProject),
|
|
381
|
+
messages,
|
|
382
|
+
stages,
|
|
383
|
+
isStreaming,
|
|
384
|
+
modelList,
|
|
385
|
+
modelIdx,
|
|
386
|
+
onTabModel: () => setModelIdx((i) => (i + 1) % modelList.length),
|
|
387
|
+
onSubmit: handleReplSubmit,
|
|
388
|
+
onAbort: abort,
|
|
389
|
+
onExitRepl: handleExitRepl,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Footer ───────────────────────────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
const footerHints = panelMode === "repl"
|
|
396
|
+
? "In REPL — Esc exit · Ctrl+C abort"
|
|
397
|
+
: panelMode === "compose"
|
|
398
|
+
? "Enter confirm · Esc cancel"
|
|
399
|
+
: panelMode === "creating"
|
|
400
|
+
? "Generating PRD…"
|
|
401
|
+
: isTwoPaneCapable
|
|
402
|
+
? "Tab switch pane · q quit"
|
|
403
|
+
: `[${narrowPane.toUpperCase()}] Tab switch pane · q quit`;
|
|
404
|
+
|
|
405
|
+
const footer = React.createElement(
|
|
406
|
+
Box,
|
|
407
|
+
{ paddingX: 1, borderStyle: "single", borderColor: theme.dim, borderBottom: false, borderLeft: false, borderRight: false },
|
|
408
|
+
React.createElement(Text, { color: theme.dim }, footerHints)
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
return React.createElement(
|
|
412
|
+
Box,
|
|
413
|
+
{ flexDirection: "column", paddingTop: 0 },
|
|
414
|
+
header,
|
|
415
|
+
content,
|
|
416
|
+
footer
|
|
417
|
+
);
|
|
418
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
import { StageIndicator } from "./StageIndicator.js";
|
|
5
|
+
import { theme } from "./theme.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Right pane in REPL mode: conversation history + streaming stages + input bar.
|
|
9
|
+
*
|
|
10
|
+
* @param {{
|
|
11
|
+
* project: object | null,
|
|
12
|
+
* messages: Array<{ id: string, role: "user"|"ai"|"status", text: string, status: string }>,
|
|
13
|
+
* stages: Array<{ label: string, status: string, preview?: string }>,
|
|
14
|
+
* isStreaming: boolean,
|
|
15
|
+
* isFocused: boolean,
|
|
16
|
+
* height: number,
|
|
17
|
+
* modelList: object[],
|
|
18
|
+
* modelIdx: number,
|
|
19
|
+
* onTabModel: () => void,
|
|
20
|
+
* onSubmit: (text: string) => void,
|
|
21
|
+
* onAbort: () => void,
|
|
22
|
+
* onExit: () => void,
|
|
23
|
+
* }} props
|
|
24
|
+
*/
|
|
25
|
+
export function ReplPanel({
|
|
26
|
+
project,
|
|
27
|
+
messages = [],
|
|
28
|
+
stages = [],
|
|
29
|
+
isStreaming,
|
|
30
|
+
isFocused,
|
|
31
|
+
height = 20,
|
|
32
|
+
modelList = [],
|
|
33
|
+
modelIdx = 0,
|
|
34
|
+
onTabModel,
|
|
35
|
+
onSubmit,
|
|
36
|
+
onAbort,
|
|
37
|
+
onExit,
|
|
38
|
+
}) {
|
|
39
|
+
const [inputText, setInputText] = useState("");
|
|
40
|
+
const [msgOffset, setMsgOffset] = useState(0);
|
|
41
|
+
|
|
42
|
+
const activeModel = modelList[modelIdx];
|
|
43
|
+
|
|
44
|
+
// Stages area height: 0 when not streaming generation, else stage count + 1 header
|
|
45
|
+
const showStages = isStreaming && stages.length > 0 &&
|
|
46
|
+
stages.some((s) => s.status === "streaming" || s.status === "done");
|
|
47
|
+
const stageAreaH = showStages ? Math.min(stages.length + 1, 6) : 0;
|
|
48
|
+
|
|
49
|
+
const INPUT_H = 3; // input bar
|
|
50
|
+
const HEADER_H = 2; // project title + divider
|
|
51
|
+
const FOOTER_H = 1; // hints
|
|
52
|
+
const visibleMsgRows = Math.max(1, height - INPUT_H - HEADER_H - FOOTER_H - stageAreaH);
|
|
53
|
+
|
|
54
|
+
// Auto-scroll to bottom when messages change
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const maxOffset = Math.max(0, messages.length - visibleMsgRows);
|
|
57
|
+
setMsgOffset(maxOffset);
|
|
58
|
+
}, [messages.length, visibleMsgRows]);
|
|
59
|
+
|
|
60
|
+
const visibleMsgs = messages.slice(msgOffset, msgOffset + visibleMsgRows);
|
|
61
|
+
|
|
62
|
+
useInput(
|
|
63
|
+
(input, key) => {
|
|
64
|
+
if (key.escape && !isStreaming) {
|
|
65
|
+
onExit?.();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (key.ctrl && input === "c") {
|
|
69
|
+
if (isStreaming) { onAbort?.(); }
|
|
70
|
+
else { onExit?.(); }
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (key.tab) {
|
|
74
|
+
onTabModel?.();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (key.upArrow) {
|
|
78
|
+
setMsgOffset((o) => Math.max(0, o - 1));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (key.downArrow) {
|
|
82
|
+
setMsgOffset((o) =>
|
|
83
|
+
Math.min(Math.max(0, messages.length - visibleMsgRows), o + 1)
|
|
84
|
+
);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (key.return && inputText.trim() && !isStreaming) {
|
|
88
|
+
onSubmit?.(inputText.trim());
|
|
89
|
+
setInputText("");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
{ isActive: isFocused }
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const header = React.createElement(
|
|
99
|
+
Box,
|
|
100
|
+
{ paddingX: 1, marginBottom: 0 },
|
|
101
|
+
React.createElement(Text, { bold: true, color: theme.primary }, project?.name ?? "REPL"),
|
|
102
|
+
React.createElement(Text, { color: theme.dim }, " iterating with AI")
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const msgRows = visibleMsgs.map((msg, i) => {
|
|
106
|
+
const isUser = msg.role === "user";
|
|
107
|
+
const isStatus = msg.role === "status";
|
|
108
|
+
const isError = msg.status === "error";
|
|
109
|
+
const isCurrent = msg.status === "streaming";
|
|
110
|
+
|
|
111
|
+
const prefix = isUser ? "> "
|
|
112
|
+
: isStatus ? " "
|
|
113
|
+
: " ";
|
|
114
|
+
const color = isUser ? theme.primary
|
|
115
|
+
: isStatus ? theme.success
|
|
116
|
+
: isError ? theme.error
|
|
117
|
+
: theme.dim;
|
|
118
|
+
|
|
119
|
+
return React.createElement(
|
|
120
|
+
Box,
|
|
121
|
+
{ key: msg.id ?? i, paddingX: 1 },
|
|
122
|
+
React.createElement(
|
|
123
|
+
Text,
|
|
124
|
+
{ color },
|
|
125
|
+
prefix + msg.text + (isCurrent ? "█" : "")
|
|
126
|
+
)
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const messageHistory = React.createElement(
|
|
131
|
+
Box,
|
|
132
|
+
{ flexDirection: "column", flexGrow: 1 },
|
|
133
|
+
messages.length === 0
|
|
134
|
+
? React.createElement(
|
|
135
|
+
Box,
|
|
136
|
+
{ paddingX: 1, flexGrow: 1 },
|
|
137
|
+
React.createElement(
|
|
138
|
+
Text,
|
|
139
|
+
{ color: theme.dim },
|
|
140
|
+
"Type an update below to iterate on this PRD with AI."
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
: React.createElement(Box, { flexDirection: "column" }, ...msgRows)
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Stage progress (only during initial generation streaming)
|
|
147
|
+
const stageArea = showStages
|
|
148
|
+
? React.createElement(
|
|
149
|
+
Box,
|
|
150
|
+
{ flexDirection: "column", paddingX: 1, marginY: 0 },
|
|
151
|
+
React.createElement(
|
|
152
|
+
Box,
|
|
153
|
+
{ marginBottom: 0 },
|
|
154
|
+
React.createElement(Text, { color: theme.dim, bold: true }, "Generating stages")
|
|
155
|
+
),
|
|
156
|
+
...stages.slice(0, 5).map((s, i) =>
|
|
157
|
+
React.createElement(
|
|
158
|
+
Box,
|
|
159
|
+
{ key: i, paddingLeft: 1 },
|
|
160
|
+
React.createElement(StageIndicator, {
|
|
161
|
+
label: s.label,
|
|
162
|
+
status: s.status,
|
|
163
|
+
preview: s.preview,
|
|
164
|
+
})
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
: null;
|
|
169
|
+
|
|
170
|
+
const modelPill = activeModel
|
|
171
|
+
? React.createElement(
|
|
172
|
+
Text,
|
|
173
|
+
{ color: theme.dim },
|
|
174
|
+
` [${activeModel.name}]`
|
|
175
|
+
)
|
|
176
|
+
: null;
|
|
177
|
+
|
|
178
|
+
const inputBar = React.createElement(
|
|
179
|
+
Box,
|
|
180
|
+
{
|
|
181
|
+
flexDirection: "column",
|
|
182
|
+
borderStyle: "round",
|
|
183
|
+
borderColor: isStreaming ? theme.dim : theme.accent,
|
|
184
|
+
paddingX: 1,
|
|
185
|
+
marginX: 1,
|
|
186
|
+
},
|
|
187
|
+
React.createElement(
|
|
188
|
+
Box,
|
|
189
|
+
{ gap: 1 },
|
|
190
|
+
React.createElement(Text, { color: theme.accent, bold: true }, ">"),
|
|
191
|
+
React.createElement(TextInput, {
|
|
192
|
+
value: inputText,
|
|
193
|
+
onChange: setInputText,
|
|
194
|
+
placeholder: isStreaming ? "streaming…" : "update PRD or describe a change…",
|
|
195
|
+
focus: isFocused && !isStreaming,
|
|
196
|
+
}),
|
|
197
|
+
modelPill
|
|
198
|
+
)
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const footer = React.createElement(
|
|
202
|
+
Box,
|
|
203
|
+
{ paddingX: 1 },
|
|
204
|
+
React.createElement(
|
|
205
|
+
Text,
|
|
206
|
+
{ color: theme.dim },
|
|
207
|
+
isStreaming
|
|
208
|
+
? "Ctrl+C abort stream"
|
|
209
|
+
: "Enter submit Tab model Esc exit REPL"
|
|
210
|
+
)
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return React.createElement(
|
|
214
|
+
Box,
|
|
215
|
+
{
|
|
216
|
+
flexDirection: "column",
|
|
217
|
+
flexGrow: 1,
|
|
218
|
+
borderStyle: "single",
|
|
219
|
+
borderColor: isFocused ? theme.accent : theme.dim,
|
|
220
|
+
},
|
|
221
|
+
header,
|
|
222
|
+
messageHistory,
|
|
223
|
+
stageArea,
|
|
224
|
+
inputBar,
|
|
225
|
+
footer
|
|
226
|
+
);
|
|
227
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { PreviewPanel } from "./PreviewPanel.js";
|
|
3
|
+
import { ReplPanel } from "./ReplPanel.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Right pane router — switches between Preview and REPL modes.
|
|
7
|
+
*
|
|
8
|
+
* @param {{
|
|
9
|
+
* mode: "preview" | "repl",
|
|
10
|
+
* project: object | null,
|
|
11
|
+
* isFocused: boolean,
|
|
12
|
+
* height: number,
|
|
13
|
+
* // Preview props
|
|
14
|
+
* onEnterRepl: () => void,
|
|
15
|
+
* // REPL props
|
|
16
|
+
* messages: object[],
|
|
17
|
+
* stages: object[],
|
|
18
|
+
* isStreaming: boolean,
|
|
19
|
+
* modelList: object[],
|
|
20
|
+
* modelIdx: number,
|
|
21
|
+
* onTabModel: () => void,
|
|
22
|
+
* onSubmit: (text: string) => void,
|
|
23
|
+
* onAbort: () => void,
|
|
24
|
+
* onExitRepl: () => void,
|
|
25
|
+
* }} props
|
|
26
|
+
*/
|
|
27
|
+
export function RightPane({
|
|
28
|
+
mode = "preview",
|
|
29
|
+
project,
|
|
30
|
+
isFocused,
|
|
31
|
+
height,
|
|
32
|
+
onEnterRepl,
|
|
33
|
+
messages,
|
|
34
|
+
stages,
|
|
35
|
+
isStreaming,
|
|
36
|
+
modelList,
|
|
37
|
+
modelIdx,
|
|
38
|
+
onTabModel,
|
|
39
|
+
onSubmit,
|
|
40
|
+
onAbort,
|
|
41
|
+
onExitRepl,
|
|
42
|
+
}) {
|
|
43
|
+
if (mode === "repl") {
|
|
44
|
+
return React.createElement(ReplPanel, {
|
|
45
|
+
project,
|
|
46
|
+
messages,
|
|
47
|
+
stages,
|
|
48
|
+
isStreaming,
|
|
49
|
+
isFocused,
|
|
50
|
+
height,
|
|
51
|
+
modelList,
|
|
52
|
+
modelIdx,
|
|
53
|
+
onTabModel,
|
|
54
|
+
onSubmit,
|
|
55
|
+
onAbort,
|
|
56
|
+
onExit: onExitRepl,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return React.createElement(PreviewPanel, {
|
|
61
|
+
project,
|
|
62
|
+
isFocused,
|
|
63
|
+
height,
|
|
64
|
+
onEnterRepl,
|
|
65
|
+
});
|
|
66
|
+
}
|