create-interview-cockpit 0.4.0 → 0.6.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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +753 -1
  3. package/template/client/package.json +4 -0
  4. package/template/client/src/App.tsx +20 -0
  5. package/template/client/src/api.ts +455 -3
  6. package/template/client/src/components/AiSettingsModal.tsx +855 -248
  7. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  8. package/template/client/src/components/ChatMessage.tsx +132 -27
  9. package/template/client/src/components/ChatView.tsx +365 -123
  10. package/template/client/src/components/CodeContextPanel.tsx +714 -0
  11. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  12. package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
  13. package/template/client/src/components/DocRefModal.tsx +551 -0
  14. package/template/client/src/components/FileAttachments.tsx +128 -12
  15. package/template/client/src/components/FilePickerModal.tsx +181 -0
  16. package/template/client/src/components/FileViewerModal.tsx +406 -28
  17. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  18. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  19. package/template/client/src/components/MarkdownRenderer.tsx +219 -2
  20. package/template/client/src/components/NotesModal.tsx +977 -0
  21. package/template/client/src/components/PlotEmbed.tsx +173 -0
  22. package/template/client/src/components/Sidebar.tsx +397 -127
  23. package/template/client/src/components/TextAnnotator.tsx +8 -15
  24. package/template/client/src/components/VizCraftEmbed.tsx +412 -25
  25. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  26. package/template/client/src/infraLab.ts +124 -0
  27. package/template/client/src/reactLab.ts +477 -0
  28. package/template/client/src/store.ts +416 -2
  29. package/template/client/src/types.ts +41 -1
  30. package/template/client/tsconfig.tsbuildinfo +1 -1
  31. package/template/cockpit.json +1 -1
  32. package/template/package.json +1 -1
  33. package/template/server/src/google-drive.ts +144 -2
  34. package/template/server/src/index.ts +1890 -188
  35. package/template/server/src/infra-runner.ts +1104 -0
  36. package/template/server/src/storage.ts +274 -3
@@ -1,28 +1,55 @@
1
- import { useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import {
3
3
  X,
4
- RotateCcw,
4
+ GripVertical,
5
+ Maximize2,
6
+ Minimize2,
5
7
  Save,
8
+ Download,
6
9
  ChevronDown,
7
10
  ChevronRight,
8
11
  Check,
9
12
  Loader2,
10
13
  Plus,
11
14
  Trash2,
15
+ Terminal,
16
+ Play,
17
+ StopCircle,
12
18
  } from "lucide-react";
13
19
  import { useStore } from "../store";
14
- import type { AiSettings, PromptGroup } from "../api";
20
+ import type {
21
+ AiSettings,
22
+ PromptGroup,
23
+ InfraCommandStreamMessage,
24
+ } from "../api";
25
+ import * as api from "../api";
26
+ import { DEFAULT_INFRA_LAB } from "../infraLab";
27
+
28
+ const MIN_W = 480;
29
+ const MIN_H = 420;
30
+ const DEFAULT_W = 860;
31
+ const DEFAULT_H = 700;
32
+ type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
33
+
34
+ interface ConsoleLine {
35
+ id: string;
36
+ kind: "stdout" | "stderr" | "info" | "input";
37
+ text: string;
38
+ }
15
39
 
16
40
  // ── Committed baseline — mirrors data/ai-settings.json ───────────────────────
17
41
  const BASELINE: AiSettings = {
18
42
  systemPrompt:
19
- "You are a senior engineering interview coach.\n\nHighest priority: follow the user's explicit response preferences and current conversation context. If they conflict with your default teaching behavior, the user's preference wins.\nExplain clearly, accurately, and practically.\nOnly include Mermaid diagrams, code blocks, or tables when the user explicitly asks for them or when they materially improve the answer.\nIf you show code, use a fenced code block with the correct language.\n\nMermaid syntax rules (follow strictly):\n- Wrap node labels in quotes when they contain special characters: A[\"Microservice A (Producer)\"]\n- Edge labels use |text| syntax: A -->|sends message| B\n- Never put parentheses or brackets inside [] without quoting the label\n- Use simple node IDs (letters/numbers) and put descriptive text in the label\n\nYou can produce animated diagrams using ```viz blocks for flows, step-through walkthroughs, or any explanation that benefits from animation. Call getVizGuide() first to get the full spec reference before writing a viz block.",
43
+ "You are a senior engineering interview coach.\n\nHighest priority: follow the user's explicit response preferences and current conversation context. If they conflict with your default teaching behavior, the user's preference wins.\nExplain clearly, accurately, and practically.\nOnly include Mermaid diagrams, plot blocks, code blocks, or tables when the user explicitly asks for them or when they materially improve the answer.\nIf you show code, use a fenced code block with the correct language.\n\nMermaid syntax rules (follow strictly):\n- Wrap node labels in quotes when they contain special characters: A[\"Microservice A (Producer)\"]\n- Edge labels use |text| syntax: A -->|sends message| B\n- Never put parentheses or brackets inside [] without quoting the label\n- Use simple node IDs (letters/numbers) and put descriptive text in the label\n\nYou can produce animated diagrams using ```viz blocks for flows, step-through walkthroughs, or any explanation that benefits from animation. Call getVizGuide() first to get the full spec reference before writing a viz block.\n\nYou can produce graphs, curves, distributions, and data charts using ```plot blocks. Call getPlotGuide() first to get the full plotting spec reference before writing a plot block.",
20
44
  responseProfiles: {
21
45
  concise: { maxOutputTokens: 1000, maxSteps: 3 },
22
46
  moderate: { maxOutputTokens: 1000, maxSteps: 5 },
23
47
  normal: { maxOutputTokens: 3000, maxSteps: 5 },
48
+ brief: { maxOutputTokens: 10000, maxSteps: 5 },
24
49
  },
25
50
  vizGuide: "",
51
+ plotGuide: "",
52
+ alwaysSendPrefsDefault: false,
26
53
  promptGroups: {
27
54
  length: {
28
55
  label: "Response Length",
@@ -62,6 +89,19 @@ const BASELINE: AiSettings = {
62
89
  "When using technical terms or abbreviations, immediately expand their meaning in square brackets right after the term — e.g. 'TCP [Transmission Control Protocol — a connection-oriented transport protocol]' or 'idempotent [an operation that produces the same result no matter how many times it is applied]'. Do this throughout your response so I never need to look anything up.",
63
90
  },
64
91
  },
92
+ "diagram-use": {
93
+ label: "Diagram Usage",
94
+ description: "Which diagrams to use and how often",
95
+ default: "none",
96
+ options: {
97
+ none: "",
98
+ vizcraft:
99
+ "Prioritize using vizcraft (viz) diagrams as much as possible. *NB:* Character limits do not apply to these diagrams",
100
+ mermaid:
101
+ "Prioritize using mermaid diagrams as much as possible. *NB:* Character limits do not apply to these diagrams",
102
+ plot: "Prioritize using plot blocks for graphs, curves, distributions, and data charts when they would make the explanation clearer. *NB:* Character limits do not apply to these plots",
103
+ },
104
+ },
65
105
  },
66
106
  };
67
107
 
@@ -347,13 +387,197 @@ function PromptGroupSection({
347
387
  // ── Main modal ───────────────────────────────────────────────────────────────
348
388
 
349
389
  export default function AiSettingsModal() {
350
- const { aiSettings, saveAiSettings, closeSettings } = useStore();
390
+ const { aiSettings, saveAiSettings, closeSettings, currentQuestion } =
391
+ useStore();
351
392
  const [draft, setDraft] = useState<AiSettings>(() =>
352
393
  JSON.parse(JSON.stringify(aiSettings)),
353
394
  );
354
395
  const [saving, setSaving] = useState(false);
355
396
  const [saved, setSaved] = useState(false);
356
- const overlayRef = useRef<HTMLDivElement>(null);
397
+
398
+ // ── Drag / resize ─────────────────────────────────────────────
399
+ const [pos, setPos] = useState(() => ({
400
+ x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
401
+ y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
402
+ }));
403
+ const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
404
+ const [maximized, setMaximized] = useState(false);
405
+
406
+ const dragStart = useRef<{
407
+ mx: number;
408
+ my: number;
409
+ ox: number;
410
+ oy: number;
411
+ } | null>(null);
412
+ const resizeDir = useRef<ResizeDir>(null);
413
+ const resizeStart = useRef<{
414
+ mx: number;
415
+ my: number;
416
+ ox: number;
417
+ oy: number;
418
+ ow: number;
419
+ oh: number;
420
+ } | null>(null);
421
+ const savedPos = useRef(pos);
422
+ const savedSize = useRef(size);
423
+
424
+ const onTitleMouseDown = useCallback(
425
+ (e: React.MouseEvent) => {
426
+ if (maximized) return;
427
+ e.preventDefault();
428
+ dragStart.current = {
429
+ mx: e.clientX,
430
+ my: e.clientY,
431
+ ox: pos.x,
432
+ oy: pos.y,
433
+ };
434
+ },
435
+ [maximized, pos],
436
+ );
437
+
438
+ const startResize = useCallback(
439
+ (dir: ResizeDir) => (e: React.MouseEvent) => {
440
+ if (maximized) return;
441
+ e.preventDefault();
442
+ e.stopPropagation();
443
+ resizeDir.current = dir;
444
+ resizeStart.current = {
445
+ mx: e.clientX,
446
+ my: e.clientY,
447
+ ox: pos.x,
448
+ oy: pos.y,
449
+ ow: size.w,
450
+ oh: size.h,
451
+ };
452
+ },
453
+ [maximized, pos, size],
454
+ );
455
+
456
+ const toggleMax = useCallback(() => {
457
+ if (!maximized) {
458
+ savedPos.current = pos;
459
+ savedSize.current = size;
460
+ setMaximized(true);
461
+ } else {
462
+ setPos(savedPos.current);
463
+ setSize(savedSize.current);
464
+ setMaximized(false);
465
+ }
466
+ }, [maximized, pos, size]);
467
+
468
+ useEffect(() => {
469
+ const onMove = (e: MouseEvent) => {
470
+ const drag = dragStart.current;
471
+ const resize = resizeStart.current;
472
+ const dir = resizeDir.current;
473
+ if (drag) {
474
+ setPos({
475
+ x: Math.max(0, drag.ox + e.clientX - drag.mx),
476
+ y: Math.max(0, drag.oy + e.clientY - drag.my),
477
+ });
478
+ }
479
+ if (resize && dir) {
480
+ const dx = e.clientX - resize.mx;
481
+ const dy = e.clientY - resize.my;
482
+ setSize((prev) => {
483
+ let w = prev.w,
484
+ h = prev.h;
485
+ if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
486
+ if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
487
+ if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
488
+ if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
489
+ return { w, h };
490
+ });
491
+ if (dir.includes("w"))
492
+ setPos((p) => ({
493
+ ...p,
494
+ x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
495
+ }));
496
+ if (dir.includes("n"))
497
+ setPos((p) => ({
498
+ ...p,
499
+ y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
500
+ }));
501
+ }
502
+ };
503
+ const onUp = () => {
504
+ dragStart.current = null;
505
+ resizeStart.current = null;
506
+ resizeDir.current = null;
507
+ };
508
+ document.addEventListener("mousemove", onMove);
509
+ document.addEventListener("mouseup", onUp);
510
+ return () => {
511
+ document.removeEventListener("mousemove", onMove);
512
+ document.removeEventListener("mouseup", onUp);
513
+ };
514
+ }, []);
515
+
516
+ // ── Console ───────────────────────────────────────────────────
517
+ const [consoleOpen, setConsoleOpen] = useState(false);
518
+ const [consoleLines, setConsoleLines] = useState<ConsoleLine[]>([]);
519
+ const [cmdInput, setCmdInput] = useState("");
520
+ const [consoleRunning, setConsoleRunning] = useState(false);
521
+ const consoleOutputRef = useRef<HTMLDivElement>(null);
522
+ const cmdAbortRef = useRef<{ aborted: boolean }>({ aborted: false });
523
+
524
+ const appendLine = useCallback((kind: ConsoleLine["kind"], text: string) => {
525
+ setConsoleLines((prev) => [
526
+ ...prev,
527
+ { id: crypto.randomUUID(), kind, text },
528
+ ]);
529
+ }, []);
530
+
531
+ useEffect(() => {
532
+ if (consoleOutputRef.current) {
533
+ consoleOutputRef.current.scrollTop =
534
+ consoleOutputRef.current.scrollHeight;
535
+ }
536
+ }, [consoleLines]);
537
+
538
+ const handleRunCommand = useCallback(async () => {
539
+ const cmd = cmdInput.trim();
540
+ if (!cmd || consoleRunning) return;
541
+ setCmdInput("");
542
+ setConsoleRunning(true);
543
+ const abort = { aborted: false };
544
+ cmdAbortRef.current = abort;
545
+ appendLine("input", `$ ${cmd}`);
546
+ try {
547
+ await api.streamInfraCommand(
548
+ {
549
+ questionId: currentQuestion?.id,
550
+ command: cmd,
551
+ workspace: DEFAULT_INFRA_LAB,
552
+ },
553
+ (msg: InfraCommandStreamMessage) => {
554
+ if (abort.aborted) return;
555
+ if (msg.type === "output") appendLine(msg.kind, msg.text);
556
+ else if (msg.type === "error") appendLine("stderr", msg.error);
557
+ },
558
+ );
559
+ } catch (err: unknown) {
560
+ if (!abort.aborted)
561
+ appendLine("stderr", (err as Error)?.message ?? "Command failed");
562
+ } finally {
563
+ if (!abort.aborted) setConsoleRunning(false);
564
+ }
565
+ }, [cmdInput, consoleRunning, currentQuestion, appendLine]);
566
+
567
+ const handleStop = useCallback(() => {
568
+ cmdAbortRef.current.aborted = true;
569
+ setConsoleRunning(false);
570
+ appendLine("info", "^C");
571
+ }, [appendLine]);
572
+
573
+ // ── New profile form state ────────────────────────────────────
574
+ const [showNewProfileForm, setShowNewProfileForm] = useState(false);
575
+ const [newProfileKey, setNewProfileKey] = useState("");
576
+
577
+ function resetNewProfileForm() {
578
+ setShowNewProfileForm(false);
579
+ setNewProfileKey("");
580
+ }
357
581
 
358
582
  // ── New group form state ─────────────────────
359
583
  const [showNewGroupForm, setShowNewGroupForm] = useState(false);
@@ -406,6 +630,26 @@ export default function AiSettingsModal() {
406
630
  }));
407
631
  }
408
632
 
633
+ function addProfile(key: string) {
634
+ const k = key.trim().toLowerCase().replace(/\s+/g, "-");
635
+ if (!k || k in draft.responseProfiles) return;
636
+ setDraft((d) => ({
637
+ ...d,
638
+ responseProfiles: {
639
+ ...d.responseProfiles,
640
+ [k]: { maxOutputTokens: 2000, maxSteps: 5 },
641
+ },
642
+ }));
643
+ }
644
+
645
+ function removeProfile(key: string) {
646
+ setDraft((d) => {
647
+ const next = { ...d.responseProfiles };
648
+ delete next[key];
649
+ return { ...d, responseProfiles: next };
650
+ });
651
+ }
652
+
409
653
  function patchGroupOption(groupKey: string, optKey: string, value: string) {
410
654
  setDraft((d) => ({
411
655
  ...d,
@@ -529,298 +773,661 @@ export default function AiSettingsModal() {
529
773
  }
530
774
  }
531
775
 
532
- function handleReset() {
533
- if (!window.confirm("Reset all settings to the committed baseline?"))
534
- return;
535
- setDraft(JSON.parse(JSON.stringify(BASELINE)));
776
+ function handleSaveAs() {
777
+ const blob = new Blob([JSON.stringify(draft, null, 2)], {
778
+ type: "application/json",
779
+ });
780
+ const url = URL.createObjectURL(blob);
781
+ const a = document.createElement("a");
782
+ a.href = url;
783
+ a.download = "ai-settings.json";
784
+ a.click();
785
+ URL.revokeObjectURL(url);
536
786
  }
537
787
 
538
788
  // ── Render ────────────────────────────────────────────────────
539
789
  const profileKeys = Object.keys(draft.responseProfiles);
540
790
 
791
+ const windowStyle: React.CSSProperties = maximized
792
+ ? {
793
+ position: "fixed",
794
+ inset: 0,
795
+ width: "100vw",
796
+ height: "100vh",
797
+ borderRadius: 0,
798
+ }
799
+ : {
800
+ position: "fixed",
801
+ left: pos.x,
802
+ top: pos.y,
803
+ width: size.w,
804
+ height: size.h,
805
+ minWidth: MIN_W,
806
+ minHeight: MIN_H,
807
+ };
808
+
541
809
  return (
542
810
  <div
543
- ref={overlayRef}
544
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
545
- onMouseDown={(e) => {
546
- if (e.target === overlayRef.current) closeSettings();
547
- }}
811
+ className="fixed z-50 flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl overflow-hidden select-none"
812
+ style={windowStyle}
548
813
  >
549
- <div className="relative w-full max-w-3xl max-h-[90vh] flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl">
550
- {/* Header */}
551
- <div className="flex items-center justify-between px-6 py-4 border-b border-slate-700 shrink-0">
552
- <h2 className="text-base font-semibold text-slate-100">
553
- AI Settings
554
- </h2>
555
- <div className="flex items-center gap-2">
556
- <button
557
- type="button"
558
- onClick={handleReset}
559
- title="Reset to baseline defaults"
560
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
561
- >
562
- <RotateCcw className="w-3.5 h-3.5" />
563
- Reset to defaults
564
- </button>
565
- <button
566
- type="button"
567
- onClick={closeSettings}
568
- className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
569
- >
570
- <X className="w-4 h-4" />
571
- </button>
572
- </div>
573
- </div>
814
+ {/* ── Resize handles ── */}
815
+ {!maximized && (
816
+ <>
817
+ <div
818
+ className="absolute top-0 left-0 right-0 h-1 cursor-n-resize z-10"
819
+ onMouseDown={startResize("n")}
820
+ />
821
+ <div
822
+ className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize z-10"
823
+ onMouseDown={startResize("s")}
824
+ />
825
+ <div
826
+ className="absolute top-0 left-0 bottom-0 w-1 cursor-w-resize z-10"
827
+ onMouseDown={startResize("w")}
828
+ />
829
+ <div
830
+ className="absolute top-0 right-0 bottom-0 w-1 cursor-e-resize z-10"
831
+ onMouseDown={startResize("e")}
832
+ />
833
+ <div
834
+ className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-20"
835
+ onMouseDown={startResize("nw")}
836
+ />
837
+ <div
838
+ className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-20"
839
+ onMouseDown={startResize("ne")}
840
+ />
841
+ <div
842
+ className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-20"
843
+ onMouseDown={startResize("sw")}
844
+ />
845
+ <div
846
+ className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-20"
847
+ onMouseDown={startResize("se")}
848
+ />
849
+ </>
850
+ )}
574
851
 
575
- {/* Scrollable body */}
576
- <div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
577
- {/* ── System Prompt ─────────────────────────────────── */}
578
- <Section title="System Prompt">
579
- <Textarea
580
- value={draft.systemPrompt}
581
- onChange={(v) => patchTop("systemPrompt", v)}
582
- rows={8}
583
- />
584
- </Section>
852
+ {/* ── Title bar ── */}
853
+ <div
854
+ className="flex items-center gap-2 px-3 py-2.5 bg-slate-800 border-b border-slate-700 shrink-0"
855
+ onMouseDown={onTitleMouseDown}
856
+ style={{ cursor: maximized ? "default" : "grab" }}
857
+ >
858
+ <GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
859
+ <span className="text-sm font-semibold text-slate-100 flex-1">
860
+ AI Settings
861
+ </span>
862
+ <button
863
+ type="button"
864
+ onMouseDown={(e) => e.stopPropagation()}
865
+ onClick={toggleMax}
866
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
867
+ title={maximized ? "Restore" : "Maximise"}
868
+ >
869
+ {maximized ? (
870
+ <Minimize2 className="w-3.5 h-3.5" />
871
+ ) : (
872
+ <Maximize2 className="w-3.5 h-3.5" />
873
+ )}
874
+ </button>
875
+ <button
876
+ type="button"
877
+ onMouseDown={(e) => e.stopPropagation()}
878
+ onClick={closeSettings}
879
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-red-400 transition-colors shrink-0"
880
+ title="Close (Esc)"
881
+ >
882
+ <X className="w-3.5 h-3.5" />
883
+ </button>
884
+ </div>
885
+
886
+ {/* Scrollable body */}
887
+ <div className="flex-1 overflow-y-auto px-6 py-5 space-y-4 min-h-0">
888
+ {/* ── System Prompt ─────────────────────────────────── */}
889
+ <Section title="System Prompt">
890
+ <Textarea
891
+ value={draft.systemPrompt}
892
+ onChange={(v) => patchTop("systemPrompt", v)}
893
+ rows={8}
894
+ />
895
+ </Section>
585
896
 
586
- {/* ── Response Profiles ─────────────────────────────── */}
587
- <Section title="Response Profiles (token limits per length setting)">
588
- <div className="grid grid-cols-3 gap-4">
589
- {profileKeys.map((key) => (
590
- <div key={key} className="space-y-3">
897
+ {/* ── Response Profiles ─────────────────────────────── */}
898
+ <Section title="Response Profiles (token limits per length setting)">
899
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
900
+ {profileKeys.map((key) => (
901
+ <div key={key} className="space-y-3">
902
+ <div className="flex items-center justify-between">
591
903
  <p className="text-xs font-semibold text-cyan-400 capitalize">
592
904
  {key}
593
905
  </p>
594
- <div>
595
- <Label>Max Output Tokens</Label>
596
- <NumberInput
597
- value={draft.responseProfiles[key].maxOutputTokens}
598
- onChange={(v) =>
599
- patchProfileField(key, "maxOutputTokens", v)
600
- }
601
- min={100}
602
- max={32000}
603
- step={100}
604
- />
605
- </div>
606
- <div>
607
- <Label>Max Steps</Label>
608
- <NumberInput
609
- value={draft.responseProfiles[key].maxSteps}
610
- onChange={(v) => patchProfileField(key, "maxSteps", v)}
611
- min={1}
612
- max={20}
613
- />
614
- </div>
615
- </div>
616
- ))}
617
- </div>
618
- </Section>
619
-
620
- {/* ── Prompt Groups ─────────────────────────────────── */}
621
- {Object.entries(draft.promptGroups).map(([groupKey, group]) => (
622
- <PromptGroupSection
623
- key={groupKey}
624
- groupKey={groupKey}
625
- group={group}
626
- onOptionChange={(optKey, value) =>
627
- patchGroupOption(groupKey, optKey, value)
628
- }
629
- onDefaultChange={(optKey) => patchGroupDefault(groupKey, optKey)}
630
- onAddOption={(optKey, prompt) =>
631
- addGroupOption(groupKey, optKey, prompt)
632
- }
633
- onRemoveOption={(optKey) => removeGroupOption(groupKey, optKey)}
634
- onRemoveGroup={() => removeGroup(groupKey)}
635
- onMetaChange={(field, value) =>
636
- patchGroupMeta(groupKey, field, value)
637
- }
638
- />
639
- ))}
640
-
641
- {/* ── Add group form ─────────────────────────────── */}
642
- {showNewGroupForm ? (
643
- <div className="border border-cyan-600/30 rounded-lg p-4 space-y-4 bg-slate-900/60">
644
- <p className="text-sm font-medium text-cyan-300">
645
- New setting group
646
- </p>
647
- <div className="grid grid-cols-3 gap-3">
648
- <div>
649
- <Label>Key (internal ID)</Label>
650
- <input
651
- type="text"
652
- value={newGroupKey}
653
- onChange={(e) =>
654
- setNewGroupKey(
655
- e.target.value.toLowerCase().replace(/\s+/g, "-"),
656
- )
657
- }
658
- placeholder="e.g. tone"
659
- className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
660
- />
661
- {newGroupKey && newGroupKey in draft.promptGroups && (
662
- <p className="text-xs text-red-400 mt-1">
663
- Key already exists.
664
- </p>
906
+ {profileKeys.length > 1 && (
907
+ <button
908
+ type="button"
909
+ title={`Remove "${key}" profile`}
910
+ onClick={() => removeProfile(key)}
911
+ className="p-0.5 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
912
+ >
913
+ <Trash2 className="w-3 h-3" />
914
+ </button>
665
915
  )}
666
916
  </div>
667
917
  <div>
668
- <Label>Label (shown in bottom bar)</Label>
669
- <input
670
- type="text"
671
- value={newGroupLabel}
672
- onChange={(e) => setNewGroupLabel(e.target.value)}
673
- placeholder="e.g. Response Tone"
674
- className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
918
+ <Label>Max Output Tokens</Label>
919
+ <NumberInput
920
+ value={draft.responseProfiles[key].maxOutputTokens}
921
+ onChange={(v) =>
922
+ patchProfileField(key, "maxOutputTokens", v)
923
+ }
924
+ min={100}
925
+ max={32000}
926
+ step={100}
675
927
  />
676
928
  </div>
677
929
  <div>
678
- <Label>Description (optional)</Label>
679
- <input
680
- type="text"
681
- value={newGroupDescription}
682
- onChange={(e) => setNewGroupDescription(e.target.value)}
683
- placeholder="Shown in the settings panel"
684
- className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
930
+ <Label>Max Steps</Label>
931
+ <NumberInput
932
+ value={draft.responseProfiles[key].maxSteps}
933
+ onChange={(v) => patchProfileField(key, "maxSteps", v)}
934
+ min={1}
935
+ max={20}
685
936
  />
686
937
  </div>
687
938
  </div>
939
+ ))}
940
+ </div>
688
941
 
689
- <div className="space-y-2">
690
- <Label>Options (first will be the default)</Label>
691
- {newGroupOptions.map((opt, i) => (
692
- <div key={i} className="flex gap-2 items-start">
693
- <input
694
- type="text"
695
- value={opt.key}
696
- onChange={(e) => {
697
- const upd = [...newGroupOptions];
698
- upd[i] = {
699
- ...upd[i],
700
- key: e.target.value
701
- .toLowerCase()
702
- .replace(/\s+/g, "-"),
703
- };
704
- setNewGroupOptions(upd);
705
- }}
706
- placeholder="key"
707
- className="w-28 shrink-0 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
708
- />
709
- <input
710
- type="text"
711
- value={opt.prompt}
712
- onChange={(e) => {
713
- const upd = [...newGroupOptions];
714
- upd[i] = { ...upd[i], prompt: e.target.value };
715
- setNewGroupOptions(upd);
716
- }}
717
- placeholder="Prompt text appended to message when selected (can be empty)"
718
- className="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
719
- />
720
- {newGroupOptions.length > 1 && (
721
- <button
722
- type="button"
723
- onClick={() =>
724
- setNewGroupOptions((prev) =>
725
- prev.filter((_, j) => j !== i),
726
- )
727
- }
728
- className="mt-1 p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
729
- >
730
- <Trash2 className="w-3.5 h-3.5" />
731
- </button>
732
- )}
733
- </div>
734
- ))}
735
- <button
736
- type="button"
737
- onClick={() =>
738
- setNewGroupOptions((prev) => [
739
- ...prev,
740
- { key: "", prompt: "" },
741
- ])
942
+ {/* Add profile */}
943
+ {showNewProfileForm ? (
944
+ <div className="mt-4 border border-slate-700 rounded-lg p-3 space-y-2 bg-slate-800/40">
945
+ <p className="text-xs font-medium text-slate-400">
946
+ New profile key
947
+ </p>
948
+ <div className="flex gap-2">
949
+ <input
950
+ type="text"
951
+ value={newProfileKey}
952
+ onChange={(e) =>
953
+ setNewProfileKey(
954
+ e.target.value.toLowerCase().replace(/\s+/g, "-"),
955
+ )
742
956
  }
743
- className="flex items-center gap-1 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
744
- >
745
- <Plus className="w-3.5 h-3.5" /> Add option row
746
- </button>
747
- </div>
748
-
749
- <div className="flex gap-2 pt-1">
957
+ placeholder="e.g. brief"
958
+ className="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
959
+ />
750
960
  <button
751
961
  type="button"
752
- onClick={addNewGroup}
962
+ onClick={() => {
963
+ addProfile(newProfileKey);
964
+ resetNewProfileForm();
965
+ }}
753
966
  disabled={
754
- !newGroupKey.trim() ||
755
- newGroupKey in draft.promptGroups ||
756
- newGroupOptions.every((o) => !o.key.trim())
967
+ !newProfileKey.trim() ||
968
+ newProfileKey.trim() in draft.responseProfiles
757
969
  }
758
- className="px-4 py-1.5 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors"
970
+ className="px-3 py-1 text-xs rounded bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 text-white transition-colors"
759
971
  >
760
- Create group
972
+ Add
761
973
  </button>
762
974
  <button
763
975
  type="button"
764
- onClick={resetNewGroupForm}
765
- className="px-4 py-1.5 text-sm rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
976
+ onClick={resetNewProfileForm}
977
+ className="px-3 py-1 text-xs rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
766
978
  >
767
979
  Cancel
768
980
  </button>
769
981
  </div>
982
+ {newProfileKey.trim() in draft.responseProfiles && (
983
+ <p className="text-xs text-red-400">
984
+ A profile with that key already exists.
985
+ </p>
986
+ )}
770
987
  </div>
771
988
  ) : (
772
989
  <button
773
990
  type="button"
774
- onClick={() => setShowNewGroupForm(true)}
775
- className="flex items-center gap-2 w-full justify-center py-2.5 border border-dashed border-slate-700 hover:border-cyan-600/50 rounded-lg text-sm text-slate-500 hover:text-cyan-400 transition-colors"
991
+ onClick={() => setShowNewProfileForm(true)}
992
+ className="mt-3 flex items-center gap-1.5 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
776
993
  >
777
- <Plus className="w-4 h-4" /> Add setting group
994
+ <Plus className="w-3.5 h-3.5" /> Add profile
778
995
  </button>
779
996
  )}
997
+ </Section>
780
998
 
781
- {/* ── Viz Guide ─────────────────────────────────────── */}
782
- <Section
783
- title="Viz Guide (returned by getVizGuide tool)"
784
- defaultOpen={false}
785
- >
786
- <p className="text-xs text-slate-500 -mt-1">
787
- The full VizCraft spec reference the AI receives when it calls{" "}
788
- <code className="text-cyan-400">getVizGuide()</code>.
999
+ {/* ── Prompt Groups ─────────────────────────────────── */}
1000
+ {Object.entries(draft.promptGroups).map(([groupKey, group]) => (
1001
+ <PromptGroupSection
1002
+ key={groupKey}
1003
+ groupKey={groupKey}
1004
+ group={group}
1005
+ onOptionChange={(optKey, value) =>
1006
+ patchGroupOption(groupKey, optKey, value)
1007
+ }
1008
+ onDefaultChange={(optKey) => patchGroupDefault(groupKey, optKey)}
1009
+ onAddOption={(optKey, prompt) =>
1010
+ addGroupOption(groupKey, optKey, prompt)
1011
+ }
1012
+ onRemoveOption={(optKey) => removeGroupOption(groupKey, optKey)}
1013
+ onRemoveGroup={() => removeGroup(groupKey)}
1014
+ onMetaChange={(field, value) =>
1015
+ patchGroupMeta(groupKey, field, value)
1016
+ }
1017
+ />
1018
+ ))}
1019
+
1020
+ {/* ── Preferences behaviour ─────────────────────────── */}
1021
+ <Section title="Preference Sending Behaviour">
1022
+ <div className="flex items-center justify-between">
1023
+ <div>
1024
+ <p className="text-sm text-slate-200">Always send preferences</p>
1025
+ <p className="text-xs text-slate-500 mt-0.5">
1026
+ When on, preference prompt texts are appended to every message.
1027
+ When off, they are only sent when you change a setting (saves
1028
+ tokens).
1029
+ </p>
1030
+ </div>
1031
+ <button
1032
+ type="button"
1033
+ onClick={() =>
1034
+ patchTop(
1035
+ "alwaysSendPrefsDefault",
1036
+ !draft.alwaysSendPrefsDefault,
1037
+ )
1038
+ }
1039
+ className={`relative shrink-0 w-10 h-5 rounded-full transition-colors ${
1040
+ draft.alwaysSendPrefsDefault ? "bg-amber-500" : "bg-slate-700"
1041
+ }`}
1042
+ >
1043
+ <span
1044
+ className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
1045
+ draft.alwaysSendPrefsDefault ? "translate-x-5" : ""
1046
+ }`}
1047
+ />
1048
+ </button>
1049
+ </div>
1050
+ </Section>
1051
+
1052
+ {/* ── Model / Thinking ───────────────────────────────── */}
1053
+ <Section title="Model & Thinking" defaultOpen={false}>
1054
+ {/* Current model info (read-only) */}
1055
+ <div className="flex gap-3 mb-4">
1056
+ <div className="flex-1">
1057
+ <Label>Provider</Label>
1058
+ <div className="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-400 font-mono">
1059
+ {draft.provider || "openai"}
1060
+ </div>
1061
+ </div>
1062
+ <div className="flex-1">
1063
+ <Label>Model</Label>
1064
+ <div className="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-400 font-mono truncate">
1065
+ {draft.model || "(default)"}
1066
+ </div>
1067
+ </div>
1068
+ </div>
1069
+
1070
+ {/* Thinking budget — only useful for Google/Gemini */}
1071
+ {["google", "gemini"].includes(draft.provider ?? "") ? (
1072
+ <div>
1073
+ <Label>Thinking Budget</Label>
1074
+ <p className="text-xs text-slate-500 mb-2">
1075
+ Number of tokens Gemini can use for internal reasoning before
1076
+ responding. 0 = disabled. Shows a collapsible "Thinking…" block
1077
+ in the chat. Recommended: 8000 for medium, 0 to save tokens.
1078
+ </p>
1079
+ <div className="flex items-center gap-2 flex-wrap">
1080
+ {[
1081
+ { label: "Off", value: 0 },
1082
+ { label: "Low", value: 1024 },
1083
+ { label: "Medium", value: 8192 },
1084
+ { label: "High", value: 24576 },
1085
+ ].map((preset) => (
1086
+ <button
1087
+ key={preset.label}
1088
+ type="button"
1089
+ onClick={() => patchTop("thinkingBudget", preset.value)}
1090
+ className={`px-3 py-1 text-xs rounded-md border transition-colors ${
1091
+ (draft.thinkingBudget ?? 0) === preset.value
1092
+ ? "bg-cyan-600/30 text-cyan-300 border-cyan-600/50"
1093
+ : "text-slate-500 hover:text-slate-300 border-slate-700 hover:border-slate-500"
1094
+ }`}
1095
+ >
1096
+ {preset.label}
1097
+ {preset.value > 0 && (
1098
+ <span className="ml-1 opacity-60">
1099
+ ({preset.value.toLocaleString()})
1100
+ </span>
1101
+ )}
1102
+ </button>
1103
+ ))}
1104
+ <div className="flex items-center gap-1.5">
1105
+ <span className="text-xs text-slate-500">Custom:</span>
1106
+ <input
1107
+ type="number"
1108
+ value={draft.thinkingBudget ?? 0}
1109
+ min={0}
1110
+ max={32768}
1111
+ step={256}
1112
+ onChange={(e) =>
1113
+ patchTop("thinkingBudget", Number(e.target.value))
1114
+ }
1115
+ className="w-24 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"
1116
+ />
1117
+ </div>
1118
+ </div>
1119
+ </div>
1120
+ ) : (
1121
+ <p className="text-xs text-slate-500">
1122
+ Thinking / reasoning display is only available for Google / Gemini
1123
+ models. Switch{" "}
1124
+ <code className="bg-slate-800 px-1 rounded">AI_PROVIDER</code> to{" "}
1125
+ <code className="bg-slate-800 px-1 rounded">google</code> in your{" "}
1126
+ <code className="bg-slate-800 px-1 rounded">.env</code> to enable
1127
+ it.
789
1128
  </p>
790
- <Textarea
791
- value={draft.vizGuide}
792
- onChange={(v) => patchTop("vizGuide", v)}
793
- rows={16}
794
- mono
795
- />
796
- </Section>
797
- </div>
1129
+ )}
1130
+ </Section>
798
1131
 
799
- {/* Footer */}
800
- <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-700 shrink-0">
801
- <button
802
- type="button"
803
- onClick={closeSettings}
804
- className="px-4 py-2 text-sm rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
805
- >
806
- Cancel
807
- </button>
1132
+ {/* ── Add group form ─────────────────────────────── */}
1133
+ {showNewGroupForm ? (
1134
+ <div className="border border-cyan-600/30 rounded-lg p-4 space-y-4 bg-slate-900/60">
1135
+ <p className="text-sm font-medium text-cyan-300">
1136
+ New setting group
1137
+ </p>
1138
+ <div className="grid grid-cols-3 gap-3">
1139
+ <div>
1140
+ <Label>Key (internal ID)</Label>
1141
+ <input
1142
+ type="text"
1143
+ value={newGroupKey}
1144
+ onChange={(e) =>
1145
+ setNewGroupKey(
1146
+ e.target.value.toLowerCase().replace(/\s+/g, "-"),
1147
+ )
1148
+ }
1149
+ placeholder="e.g. tone"
1150
+ className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
1151
+ />
1152
+ {newGroupKey && newGroupKey in draft.promptGroups && (
1153
+ <p className="text-xs text-red-400 mt-1">
1154
+ Key already exists.
1155
+ </p>
1156
+ )}
1157
+ </div>
1158
+ <div>
1159
+ <Label>Label (shown in bottom bar)</Label>
1160
+ <input
1161
+ type="text"
1162
+ value={newGroupLabel}
1163
+ onChange={(e) => setNewGroupLabel(e.target.value)}
1164
+ placeholder="e.g. Response Tone"
1165
+ className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
1166
+ />
1167
+ </div>
1168
+ <div>
1169
+ <Label>Description (optional)</Label>
1170
+ <input
1171
+ type="text"
1172
+ value={newGroupDescription}
1173
+ onChange={(e) => setNewGroupDescription(e.target.value)}
1174
+ placeholder="Shown in the settings panel"
1175
+ className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
1176
+ />
1177
+ </div>
1178
+ </div>
1179
+
1180
+ <div className="space-y-2">
1181
+ <Label>Options (first will be the default)</Label>
1182
+ {newGroupOptions.map((opt, i) => (
1183
+ <div key={i} className="flex gap-2 items-start">
1184
+ <input
1185
+ type="text"
1186
+ value={opt.key}
1187
+ onChange={(e) => {
1188
+ const upd = [...newGroupOptions];
1189
+ upd[i] = {
1190
+ ...upd[i],
1191
+ key: e.target.value.toLowerCase().replace(/\s+/g, "-"),
1192
+ };
1193
+ setNewGroupOptions(upd);
1194
+ }}
1195
+ placeholder="key"
1196
+ className="w-28 shrink-0 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
1197
+ />
1198
+ <input
1199
+ type="text"
1200
+ value={opt.prompt}
1201
+ onChange={(e) => {
1202
+ const upd = [...newGroupOptions];
1203
+ upd[i] = { ...upd[i], prompt: e.target.value };
1204
+ setNewGroupOptions(upd);
1205
+ }}
1206
+ placeholder="Prompt text appended to message when selected (can be empty)"
1207
+ className="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-cyan-500"
1208
+ />
1209
+ {newGroupOptions.length > 1 && (
1210
+ <button
1211
+ type="button"
1212
+ onClick={() =>
1213
+ setNewGroupOptions((prev) =>
1214
+ prev.filter((_, j) => j !== i),
1215
+ )
1216
+ }
1217
+ className="mt-1 p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
1218
+ >
1219
+ <Trash2 className="w-3.5 h-3.5" />
1220
+ </button>
1221
+ )}
1222
+ </div>
1223
+ ))}
1224
+ <button
1225
+ type="button"
1226
+ onClick={() =>
1227
+ setNewGroupOptions((prev) => [
1228
+ ...prev,
1229
+ { key: "", prompt: "" },
1230
+ ])
1231
+ }
1232
+ className="flex items-center gap-1 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
1233
+ >
1234
+ <Plus className="w-3.5 h-3.5" /> Add option row
1235
+ </button>
1236
+ </div>
1237
+
1238
+ <div className="flex gap-2 pt-1">
1239
+ <button
1240
+ type="button"
1241
+ onClick={addNewGroup}
1242
+ disabled={
1243
+ !newGroupKey.trim() ||
1244
+ newGroupKey in draft.promptGroups ||
1245
+ newGroupOptions.every((o) => !o.key.trim())
1246
+ }
1247
+ className="px-4 py-1.5 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors"
1248
+ >
1249
+ Create group
1250
+ </button>
1251
+ <button
1252
+ type="button"
1253
+ onClick={resetNewGroupForm}
1254
+ className="px-4 py-1.5 text-sm rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
1255
+ >
1256
+ Cancel
1257
+ </button>
1258
+ </div>
1259
+ </div>
1260
+ ) : (
808
1261
  <button
809
1262
  type="button"
810
- onClick={handleSave}
811
- disabled={saving}
812
- className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-60 text-white transition-colors"
1263
+ onClick={() => setShowNewGroupForm(true)}
1264
+ className="flex items-center gap-2 w-full justify-center py-2.5 border border-dashed border-slate-700 hover:border-cyan-600/50 rounded-lg text-sm text-slate-500 hover:text-cyan-400 transition-colors"
813
1265
  >
814
- {saving ? (
815
- <Loader2 className="w-4 h-4 animate-spin" />
816
- ) : saved ? (
817
- <Check className="w-4 h-4" />
818
- ) : (
819
- <Save className="w-4 h-4" />
820
- )}
821
- {saved ? "Saved!" : "Save changes"}
1266
+ <Plus className="w-4 h-4" /> Add setting group
822
1267
  </button>
823
- </div>
1268
+ )}
1269
+
1270
+ {/* ── Viz Guide ─────────────────────────────────────── */}
1271
+ <Section
1272
+ title="Viz Guide (returned by getVizGuide tool)"
1273
+ defaultOpen={false}
1274
+ >
1275
+ <p className="text-xs text-slate-500 -mt-1">
1276
+ The full VizCraft spec reference the AI receives when it calls{" "}
1277
+ <code className="text-cyan-400">getVizGuide()</code>.
1278
+ </p>
1279
+ <Textarea
1280
+ value={draft.vizGuide}
1281
+ onChange={(v) => patchTop("vizGuide", v)}
1282
+ rows={16}
1283
+ mono
1284
+ />
1285
+ </Section>
1286
+
1287
+ <Section
1288
+ title="Plot Guide (returned by getPlotGuide tool)"
1289
+ defaultOpen={false}
1290
+ >
1291
+ <p className="text-xs text-slate-500 -mt-1">
1292
+ The plotting spec reference the AI receives when it calls{" "}
1293
+ <code className="text-cyan-400">getPlotGuide()</code>.
1294
+ </p>
1295
+ <Textarea
1296
+ value={draft.plotGuide}
1297
+ onChange={(v) => patchTop("plotGuide", v)}
1298
+ rows={16}
1299
+ mono
1300
+ />
1301
+ </Section>
1302
+ </div>
1303
+
1304
+ {/* ── Practice Console ── */}
1305
+ <div className="shrink-0 border-t border-slate-700">
1306
+ <button
1307
+ type="button"
1308
+ onClick={() => setConsoleOpen((v) => !v)}
1309
+ className="w-full flex items-center gap-2 px-4 py-2.5 bg-slate-800/60 hover:bg-slate-800 text-left transition-colors"
1310
+ >
1311
+ <Terminal className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
1312
+ <span className="text-xs font-medium text-slate-300 flex-1">
1313
+ Practice Console
1314
+ </span>
1315
+ <span className="text-[10px] text-slate-500 mr-1">terraform</span>
1316
+ {consoleOpen ? (
1317
+ <ChevronDown className="w-3.5 h-3.5 text-slate-400 shrink-0" />
1318
+ ) : (
1319
+ <ChevronRight className="w-3.5 h-3.5 text-slate-400 shrink-0" />
1320
+ )}
1321
+ </button>
1322
+
1323
+ {consoleOpen && (
1324
+ <div className="flex flex-col bg-slate-950" style={{ height: 220 }}>
1325
+ {/* Output area */}
1326
+ <div
1327
+ ref={consoleOutputRef}
1328
+ className="flex-1 overflow-y-auto px-3 py-2 font-mono text-xs leading-5"
1329
+ >
1330
+ {consoleLines.length === 0 && (
1331
+ <p className="text-slate-600">
1332
+ Type a terraform command and press Enter to run it. e.g.{" "}
1333
+ <span className="text-slate-500">terraform version</span>
1334
+ </p>
1335
+ )}
1336
+ {consoleLines.map((line) => (
1337
+ <div
1338
+ key={line.id}
1339
+ className={
1340
+ line.kind === "input"
1341
+ ? "text-cyan-400"
1342
+ : line.kind === "stderr"
1343
+ ? "text-red-400"
1344
+ : line.kind === "info"
1345
+ ? "text-slate-500"
1346
+ : "text-slate-200"
1347
+ }
1348
+ style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}
1349
+ >
1350
+ {line.text}
1351
+ </div>
1352
+ ))}
1353
+ {consoleRunning && (
1354
+ <div className="flex items-center gap-1.5 text-slate-500 mt-1">
1355
+ <Loader2 className="w-3 h-3 animate-spin" /> running…
1356
+ </div>
1357
+ )}
1358
+ </div>
1359
+
1360
+ {/* Input bar */}
1361
+ <div className="flex items-center gap-1 px-3 py-2 border-t border-slate-800 bg-slate-900/60">
1362
+ <span className="text-emerald-400 font-mono text-xs select-none shrink-0">
1363
+ $
1364
+ </span>
1365
+ <input
1366
+ type="text"
1367
+ value={cmdInput}
1368
+ onChange={(e) => setCmdInput(e.target.value)}
1369
+ onKeyDown={(e) => {
1370
+ if (e.key === "Enter" && !e.shiftKey) {
1371
+ e.preventDefault();
1372
+ void handleRunCommand();
1373
+ }
1374
+ }}
1375
+ placeholder="terraform version"
1376
+ disabled={consoleRunning}
1377
+ className="flex-1 bg-transparent font-mono text-xs text-slate-200 placeholder-slate-600 outline-none disabled:opacity-50"
1378
+ autoComplete="off"
1379
+ spellCheck={false}
1380
+ />
1381
+ {consoleRunning ? (
1382
+ <button
1383
+ type="button"
1384
+ onClick={handleStop}
1385
+ className="p-1 rounded text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
1386
+ title="Stop"
1387
+ >
1388
+ <StopCircle className="w-3.5 h-3.5" />
1389
+ </button>
1390
+ ) : (
1391
+ <button
1392
+ type="button"
1393
+ onClick={() => void handleRunCommand()}
1394
+ disabled={!cmdInput.trim()}
1395
+ className="p-1 rounded text-slate-600 hover:text-emerald-400 hover:bg-emerald-500/10 disabled:opacity-40 transition-colors shrink-0"
1396
+ title="Run (Enter)"
1397
+ >
1398
+ <Play className="w-3.5 h-3.5" />
1399
+ </button>
1400
+ )}
1401
+ </div>
1402
+ </div>
1403
+ )}
1404
+ </div>
1405
+
1406
+ {/* Footer */}
1407
+ <div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-slate-700 shrink-0 bg-slate-900">
1408
+ <button
1409
+ type="button"
1410
+ onClick={handleSaveAs}
1411
+ className="flex items-center gap-2 px-4 py-2 text-sm rounded border border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800 transition-colors"
1412
+ >
1413
+ <Download className="w-4 h-4" />
1414
+ Save As
1415
+ </button>
1416
+ <button
1417
+ type="button"
1418
+ onClick={handleSave}
1419
+ disabled={saving}
1420
+ className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-60 text-white transition-colors"
1421
+ >
1422
+ {saving ? (
1423
+ <Loader2 className="w-4 h-4 animate-spin" />
1424
+ ) : saved ? (
1425
+ <Check className="w-4 h-4" />
1426
+ ) : (
1427
+ <Save className="w-4 h-4" />
1428
+ )}
1429
+ {saved ? "Saved!" : "Save"}
1430
+ </button>
824
1431
  </div>
825
1432
  </div>
826
1433
  );