CLI Playground

A component for displaying a CLI playground

Default
Default CLI playground
scaffold-cli
╔══════════════════════════════════════════╗
║ ║
║ Backend Scaffold CLI v1.0.0 ║
║ ║
║ Scaffold your backend API in seconds ║
║ ║
╚══════════════════════════════════════════╝
Type 'scaffold create' to get started
$
Click anywhere in the terminal to focus • Type help for commands
1"use client";
2
3import { useState, useRef, useEffect, useCallback } from "react";
4import { cn } from "@/lib/utils";
5
6interface TerminalLine {
7 type: "input" | "output" | "prompt" | "success" | "error" | "selection";
8 content: string;
9 isTyping?: boolean;
10}
11
12interface SelectionOption {
13 label: string;
14 value: string;
15 description?: string;
16}
17
18type Step =
19 | "awaiting-command"
20 | "choose-mode"
21 | "enter-name"
22 | "choose-template"
23 | "choose-framework"
24 | "choose-database"
25 | "choose-language"
26 | "generating"
27 | "complete";
28
29const TEMPLATES: SelectionOption[] = [
30 {
31 label: "REST API Starter",
32 value: "rest-starter",
33 description: "Basic REST API with CRUD operations",
34 },
35 {
36 label: "GraphQL API",
37 value: "graphql",
38 description: "GraphQL server with schema and resolvers",
39 },
40 {
41 label: "Microservices",
42 value: "microservices",
43 description: "Multi-service architecture template",
44 },
45 {
46 label: "Real-time API",
47 value: "realtime",
48 description: "WebSocket-enabled real-time server",
49 },
50];
51
52const FRAMEWORKS: SelectionOption[] = [
53 {
54 label: "Express",
55 value: "express",
56 description: "Fast, unopinionated, minimalist",
57 },
58 {
59 label: "Hono",
60 value: "hono",
61 description: "Ultrafast, lightweight, multi-runtime",
62 },
63 {
64 label: "Fastify",
65 value: "fastify",
66 description: "Low overhead, high performance",
67 },
68 {
69 label: "Koa",
70 value: "koa",
71 description: "Expressive middleware framework",
72 },
73];
74
75const DATABASES: SelectionOption[] = [
76 {
77 label: "PostgreSQL",
78 value: "postgresql",
79 description: "Powerful, open source relational database",
80 },
81 {
82 label: "SQLite",
83 value: "sqlite",
84 description: "Lightweight, file-based database",
85 },
86 {
87 label: "MySQL",
88 value: "mysql",
89 description: "Popular relational database",
90 },
91 {
92 label: "MongoDB",
93 value: "mongodb",
94 description: "Flexible NoSQL document database",
95 },
96];
97
98const LANGUAGES: SelectionOption[] = [
99 {
100 label: "TypeScript",
101 value: "typescript",
102 description: "JavaScript with types",
103 },
104 { label: "JavaScript", value: "javascript", description: "ES6+ JavaScript" },
105];
106
107/** Inner width between `║` characters (must match spacer / border row length). */
108const CLI_BOX_INNER_WIDTH = 42;
109
110function centerCliBoxInner(text: string): string {
111 const t = text.trim();
112 if (t.length >= CLI_BOX_INNER_WIDTH) return t.slice(0, CLI_BOX_INNER_WIDTH);
113 const pad = CLI_BOX_INNER_WIDTH - t.length;
114 const left = Math.floor(pad / 2);
115 return `${" ".repeat(left)}${t}${" ".repeat(pad - left)}`;
116}
117
118function cliBoxRow(inner: string): string {
119 return `${inner}`;
120}
121
122/** Welcome banner (state initializer avoids Strict Mode double-run). */
123const INITIAL_TERMINAL_LINES: TerminalLine[] = [
124 { type: "output", content: "" },
125 {
126 type: "output",
127 content: " ╔══════════════════════════════════════════╗",
128 },
129 {
130 type: "output",
131 content: cliBoxRow(centerCliBoxInner("")),
132 },
133 {
134 type: "output",
135 content: cliBoxRow(centerCliBoxInner("Backend Scaffold CLI v1.0.0")),
136 },
137 {
138 type: "output",
139 content: cliBoxRow(centerCliBoxInner("")),
140 },
141 {
142 type: "output",
143 content: cliBoxRow(centerCliBoxInner("Scaffold your backend API in seconds")),
144 },
145 {
146 type: "output",
147 content: cliBoxRow(centerCliBoxInner("")),
148 },
149 {
150 type: "output",
151 content: " ╚══════════════════════════════════════════╝",
152 },
153 { type: "output", content: "" },
154 {
155 type: "output",
156 content: " Type 'scaffold create' to get started",
157 },
158 { type: "output", content: "" },
159];
160
161export function CLIPlayground() {
162 const [lines, setLines] = useState<TerminalLine[]>(() =>
163 INITIAL_TERMINAL_LINES.map((line) => ({ ...line })),
164 );
165 const [currentInput, setCurrentInput] = useState("");
166 const [step, setStep] = useState<Step>("awaiting-command");
167 const [selectedIndex, setSelectedIndex] = useState(0);
168 const [projectName, setProjectName] = useState("");
169 const [config, setConfig] = useState({
170 mode: "" as "template" | "custom",
171 template: "",
172 framework: "",
173 database: "",
174 language: "",
175 });
176
177 const inputRef = useRef<HTMLInputElement>(null);
178 const terminalRef = useRef<HTMLDivElement>(null);
179
180 const scrollToBottom = useCallback(() => {
181 if (terminalRef.current) {
182 terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
183 }
184 }, []);
185
186 useEffect(() => {
187 scrollToBottom();
188 }, [lines, scrollToBottom]);
189
190 const addLine = (line: TerminalLine) => {
191 setLines((prev) => [...prev, line]);
192 };
193
194 const typeOutput = async (
195 content: string,
196 type: TerminalLine["type"] = "output",
197 ) => {
198 addLine({ type, content, isTyping: true });
199 await new Promise((resolve) => setTimeout(resolve, 50));
200 };
201
202 const getCurrentOptions = (): SelectionOption[] => {
203 switch (step) {
204 case "choose-mode":
205 return [
206 {
207 label: "Use a template",
208 value: "template",
209 description: "Pre-configured project templates",
210 },
211 {
212 label: "Custom configuration",
213 value: "custom",
214 description: "Choose each component individually",
215 },
216 ];
217 case "choose-template":
218 return TEMPLATES;
219 case "choose-framework":
220 return FRAMEWORKS;
221 case "choose-database":
222 return DATABASES;
223 case "choose-language":
224 return LANGUAGES;
225 default:
226 return [];
227 }
228 };
229
230 const handleSelection = async (option: SelectionOption) => {
231 addLine({ type: "success", content: `${option.label}` });
232 addLine({ type: "output", content: "" });
233
234 if (step === "choose-mode") {
235 setConfig((prev) => ({
236 ...prev,
237 mode: option.value as "template" | "custom",
238 }));
239 await typeOutput(" What would you like to name your project?");
240 addLine({ type: "output", content: "" });
241 setStep("enter-name");
242 setCurrentInput("");
243 setTimeout(() => inputRef.current?.focus(), 100);
244 } else if (step === "choose-template") {
245 setConfig((prev) => ({ ...prev, template: option.value }));
246 await generateProject();
247 } else if (step === "choose-framework") {
248 setConfig((prev) => ({ ...prev, framework: option.value }));
249 await typeOutput(" Select your database:");
250 addLine({ type: "output", content: "" });
251 setStep("choose-database");
252 setSelectedIndex(0);
253 } else if (step === "choose-database") {
254 setConfig((prev) => ({ ...prev, database: option.value }));
255 await typeOutput(" Select your language:");
256 addLine({ type: "output", content: "" });
257 setStep("choose-language");
258 setSelectedIndex(0);
259 } else if (step === "choose-language") {
260 setConfig((prev) => ({ ...prev, language: option.value }));
261 await generateProject();
262 }
263 };
264
265 const generateProject = async () => {
266 setStep("generating");
267 addLine({ type: "output", content: "" });
268 addLine({ type: "output", content: " Scaffolding your project..." });
269 addLine({ type: "output", content: "" });
270
271 const steps = [
272 "Creating project directory...",
273 "Initializing package.json...",
274 "Setting up project structure...",
275 "Installing dependencies...",
276 "Configuring database connection...",
277 "Setting up routes and controllers...",
278 "Adding middleware...",
279 "Generating configuration files...",
280 ];
281
282 for (const stepText of steps) {
283 await new Promise((resolve) => setTimeout(resolve, 300));
284 addLine({ type: "success", content: `${stepText}` });
285 }
286
287 await new Promise((resolve) => setTimeout(resolve, 500));
288 addLine({ type: "output", content: "" });
289 addLine({
290 type: "output",
291 content: " ╔══════════════════════════════════════════╗",
292 });
293 addLine({
294 type: "output",
295 content: cliBoxRow(centerCliBoxInner("")),
296 });
297 addLine({
298 type: "success",
299 content: cliBoxRow(centerCliBoxInner("Project created successfully!")),
300 });
301 addLine({
302 type: "output",
303 content: cliBoxRow(centerCliBoxInner("")),
304 });
305 addLine({
306 type: "output",
307 content: " ╚══════════════════════════════════════════╝",
308 });
309 addLine({ type: "output", content: "" });
310 addLine({ type: "output", content: ` cd ${projectName}` });
311 addLine({ type: "output", content: " npm install" });
312 addLine({ type: "output", content: " npm run dev" });
313 addLine({ type: "output", content: "" });
314 addLine({ type: "output", content: " Happy coding! 🎉" });
315 addLine({ type: "output", content: "" });
316 addLine({
317 type: "output",
318 content: " Type 'scaffold create' to create another project",
319 });
320 addLine({ type: "output", content: "" });
321 setStep("awaiting-command");
322 setSelectedIndex(0);
323 setConfig({
324 mode: "" as "template" | "custom",
325 template: "",
326 framework: "",
327 database: "",
328 language: "",
329 });
330 setProjectName("");
331 };
332
333 const handleCommand = async (command: string) => {
334 addLine({ type: "input", content: ` $ ${command}` });
335 addLine({ type: "output", content: "" });
336
337 if (command.toLowerCase().trim() === "scaffold create") {
338 await typeOutput(" How would you like to set up your project?");
339 addLine({ type: "output", content: "" });
340 setStep("choose-mode");
341 setSelectedIndex(0);
342 } else if (command.toLowerCase().trim() === "clear") {
343 setLines(INITIAL_TERMINAL_LINES.map((line) => ({ ...line })));
344 setStep("awaiting-command");
345 } else if (command.toLowerCase().trim() === "help") {
346 addLine({ type: "output", content: " Available commands:" });
347 addLine({ type: "output", content: "" });
348 addLine({
349 type: "output",
350 content: " scaffold create - Create a new backend project",
351 });
352 addLine({
353 type: "output",
354 content: " clear - Clear the terminal",
355 });
356 addLine({
357 type: "output",
358 content: " help - Show this help message",
359 });
360 addLine({ type: "output", content: "" });
361 } else if (command.trim() === "") {
362 // Do nothing for empty command
363 } else {
364 addLine({ type: "error", content: ` Command not found: ${command}` });
365 addLine({
366 type: "output",
367 content: " Type 'help' for available commands",
368 });
369 addLine({ type: "output", content: "" });
370 }
371
372 setCurrentInput("");
373 };
374
375 const handleNameSubmit = async (name: string) => {
376 if (name.trim() === "") {
377 addLine({ type: "error", content: " Project name cannot be empty" });
378 return;
379 }
380
381 const projectNameSlug = name
382 .toLowerCase()
383 .replace(/\s+/g, "-")
384 .replace(/[^a-z0-9-]/g, "");
385 setProjectName(projectNameSlug);
386 addLine({ type: "input", content: ` > ${projectNameSlug}` });
387 addLine({
388 type: "success",
389 content: ` ✓ Project name: ${projectNameSlug}`,
390 });
391 addLine({ type: "output", content: "" });
392
393 if (config.mode === "template") {
394 await typeOutput(" Select a template:");
395 addLine({ type: "output", content: "" });
396 setStep("choose-template");
397 setSelectedIndex(0);
398 } else {
399 await typeOutput(" Select your framework:");
400 addLine({ type: "output", content: "" });
401 setStep("choose-framework");
402 setSelectedIndex(0);
403 }
404 };
405
406 const handleKeyDown = (e: React.KeyboardEvent) => {
407 const options = getCurrentOptions();
408
409 if (
410 step === "choose-mode" ||
411 step === "choose-template" ||
412 step === "choose-framework" ||
413 step === "choose-database" ||
414 step === "choose-language"
415 ) {
416 if (e.key === "ArrowUp") {
417 e.preventDefault();
418 setSelectedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
419 } else if (e.key === "ArrowDown") {
420 e.preventDefault();
421 setSelectedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
422 } else if (e.key === "Enter") {
423 e.preventDefault();
424 handleSelection(options[selectedIndex]);
425 }
426 } else if (step === "enter-name") {
427 if (e.key === "Enter") {
428 e.preventDefault();
429 handleNameSubmit(currentInput);
430 setCurrentInput("");
431 }
432 } else if (step === "awaiting-command") {
433 if (e.key === "Enter") {
434 e.preventDefault();
435 handleCommand(currentInput);
436 }
437 }
438 };
439
440 const handleTerminalClick = () => {
441 inputRef.current?.focus();
442 };
443
444 const options = getCurrentOptions();
445 const showOptions = [
446 "choose-mode",
447 "choose-template",
448 "choose-framework",
449 "choose-database",
450 "choose-language",
451 ].includes(step);
452 const showInput = step === "enter-name" || step === "awaiting-command";
453
454 return (
455 <div className="w-full mx-auto">
456 <div className="rounded-lg overflow-hidden border border-border shadow-2xl">
457 {/* Terminal Header */}
458 <div className="bg-terminal-header px-4 py-3 flex items-center gap-2">
459 <div className="flex gap-2">
460 <div className="w-3 h-3 rounded-full bg-terminal-red" />
461 <div className="w-3 h-3 rounded-full bg-terminal-yellow" />
462 <div className="w-3 h-3 rounded-full bg-terminal-green" />
463 </div>
464 <div className="flex-1 text-center">
465 <span className="text-muted-foreground text-sm font-mono">
466 scaffold-cli
467 </span>
468 </div>
469 <div className="w-14" />
470 </div>
471
472 {/* Terminal Body */}
473 <div
474 ref={terminalRef}
475 onClick={handleTerminalClick}
476 className="bg-terminal-bg p-4 h-[500px] overflow-auto font-mono text-sm cursor-text [font-variant-ligatures:none]"
477 >
478 {/* Rendered Lines — pre not pre-wrap so box-drawing lines never wrap mid-row */}
479 {lines.map((line, index) => (
480 <div
481 key={index}
482 className={cn(
483 "whitespace-pre leading-relaxed",
484 line.type === "input" && "text-terminal-cyan",
485 line.type === "output" && "text-foreground",
486 line.type === "success" && "text-terminal-green",
487 line.type === "error" && "text-terminal-red",
488 line.type === "prompt" && "text-terminal-yellow",
489 )}
490 >
491 {line.content}
492 </div>
493 ))}
494
495 {/* Selection Options */}
496 {showOptions && (
497 <div className="mt-1">
498 {options.map((option, index) => (
499 <div
500 key={option.value}
501 onClick={() => handleSelection(option)}
502 className={cn(
503 "py-1 px-2 cursor-pointer rounded transition-colors",
504 index === selectedIndex
505 ? "bg-primary/20 text-primary"
506 : "text-muted-foreground hover:bg-muted/30",
507 )}
508 >
509 <span className="inline-block w-6">
510 {index === selectedIndex ? "❯" : " "}
511 </span>
512 <span
513 className={cn(index === selectedIndex && "font-medium")}
514 >
515 {option.label}
516 </span>
517 {option.description && (
518 <span className="text-muted-foreground ml-2 text-xs">
519{option.description}
520 </span>
521 )}
522 </div>
523 ))}
524 <div className="mt-2 text-muted-foreground text-xs">
525 Use ↑↓ to navigate, Enter to select
526 </div>
527 </div>
528 )}
529
530 {/* Input Line */}
531 {showInput && (
532 <div className="flex items-center text-terminal-cyan">
533 <span className="mr-2">
534 {step === "enter-name" ? " >" : " $"}
535 </span>
536 <input
537 ref={inputRef}
538 type="text"
539 value={currentInput}
540 onChange={(e) => setCurrentInput(e.target.value)}
541 onKeyDown={handleKeyDown}
542 className="flex-1 bg-transparent outline-none text-foreground caret-primary"
543 autoFocus
544 spellCheck={false}
545 />
546 <span className="w-2 h-5 bg-primary animate-pulse" />
547 </div>
548 )}
549
550 {/* Loading state */}
551 {step === "generating" && (
552 <div className="flex items-center gap-2 text-muted-foreground mt-2">
553 <div className="flex gap-1">
554 <span
555 className="animate-bounce"
556 style={{ animationDelay: "0ms" }}
557 >
558
559 </span>
560 <span
561 className="animate-bounce"
562 style={{ animationDelay: "150ms" }}
563 >
564
565 </span>
566 <span
567 className="animate-bounce"
568 style={{ animationDelay: "300ms" }}
569 >
570
571 </span>
572 </div>
573 </div>
574 )}
575 </div>
576 </div>
577
578 {/* Footer hint */}
579 <div className="mt-4 text-center text-muted-foreground text-sm">
580 Click anywhere in the terminal to focus • Type{" "}
581 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">
582 help
583 </code>{" "}
584 for commands
585 </div>
586 </div>
587 );
588}

Props

NameTypeDefaultDescription