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,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
+ }