create-interview-cockpit 0.5.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 (29) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +734 -1
  3. package/template/client/package.json +1 -0
  4. package/template/client/src/App.tsx +3 -0
  5. package/template/client/src/api.ts +321 -4
  6. package/template/client/src/components/AiSettingsModal.tsx +818 -425
  7. package/template/client/src/components/ChatMessage.tsx +34 -12
  8. package/template/client/src/components/ChatView.tsx +298 -121
  9. package/template/client/src/components/CodeContextPanel.tsx +419 -2
  10. package/template/client/src/components/CodeRunnerModal.tsx +1601 -120
  11. package/template/client/src/components/DocRefModal.tsx +55 -6
  12. package/template/client/src/components/FileAttachments.tsx +20 -4
  13. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  14. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  15. package/template/client/src/components/MarkdownRenderer.tsx +22 -8
  16. package/template/client/src/components/NotesModal.tsx +977 -0
  17. package/template/client/src/components/PlotEmbed.tsx +173 -0
  18. package/template/client/src/components/Sidebar.tsx +184 -0
  19. package/template/client/src/components/VizCraftEmbed.tsx +257 -13
  20. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  21. package/template/client/src/infraLab.ts +124 -0
  22. package/template/client/src/reactLab.ts +477 -0
  23. package/template/client/src/store.ts +219 -6
  24. package/template/client/src/types.ts +35 -3
  25. package/template/client/tsconfig.tsbuildinfo +1 -1
  26. package/template/server/src/google-drive.ts +37 -3
  27. package/template/server/src/index.ts +693 -52
  28. package/template/server/src/infra-runner.ts +1104 -0
  29. package/template/server/src/storage.ts +13 -3
@@ -1,22 +1,46 @@
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 },
@@ -24,6 +48,7 @@ const BASELINE: AiSettings = {
24
48
  brief: { maxOutputTokens: 10000, maxSteps: 5 },
25
49
  },
26
50
  vizGuide: "",
51
+ plotGuide: "",
27
52
  alwaysSendPrefsDefault: false,
28
53
  promptGroups: {
29
54
  length: {
@@ -64,6 +89,19 @@ const BASELINE: AiSettings = {
64
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.",
65
90
  },
66
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
+ },
67
105
  },
68
106
  };
69
107
 
@@ -349,13 +387,188 @@ function PromptGroupSection({
349
387
  // ── Main modal ───────────────────────────────────────────────────────────────
350
388
 
351
389
  export default function AiSettingsModal() {
352
- const { aiSettings, saveAiSettings, closeSettings } = useStore();
390
+ const { aiSettings, saveAiSettings, closeSettings, currentQuestion } =
391
+ useStore();
353
392
  const [draft, setDraft] = useState<AiSettings>(() =>
354
393
  JSON.parse(JSON.stringify(aiSettings)),
355
394
  );
356
395
  const [saving, setSaving] = useState(false);
357
396
  const [saved, setSaved] = useState(false);
358
- 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]);
359
572
 
360
573
  // ── New profile form state ────────────────────────────────────
361
574
  const [showNewProfileForm, setShowNewProfileForm] = useState(false);
@@ -560,481 +773,661 @@ export default function AiSettingsModal() {
560
773
  }
561
774
  }
562
775
 
563
- function handleReset() {
564
- if (!window.confirm("Reset all settings to the committed baseline?"))
565
- return;
566
- 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);
567
786
  }
568
787
 
569
788
  // ── Render ────────────────────────────────────────────────────
570
789
  const profileKeys = Object.keys(draft.responseProfiles);
571
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
+
572
809
  return (
573
810
  <div
574
- ref={overlayRef}
575
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
576
- onMouseDown={(e) => {
577
- if (e.target === overlayRef.current) closeSettings();
578
- }}
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}
579
813
  >
580
- <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">
581
- {/* Header */}
582
- <div className="flex items-center justify-between px-6 py-4 border-b border-slate-700 shrink-0">
583
- <h2 className="text-base font-semibold text-slate-100">
584
- AI Settings
585
- </h2>
586
- <div className="flex items-center gap-2">
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
+ )}
851
+
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>
896
+
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">
903
+ <p className="text-xs font-semibold text-cyan-400 capitalize">
904
+ {key}
905
+ </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>
915
+ )}
916
+ </div>
917
+ <div>
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}
927
+ />
928
+ </div>
929
+ <div>
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}
936
+ />
937
+ </div>
938
+ </div>
939
+ ))}
940
+ </div>
941
+
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
+ )
956
+ }
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
+ />
960
+ <button
961
+ type="button"
962
+ onClick={() => {
963
+ addProfile(newProfileKey);
964
+ resetNewProfileForm();
965
+ }}
966
+ disabled={
967
+ !newProfileKey.trim() ||
968
+ newProfileKey.trim() in draft.responseProfiles
969
+ }
970
+ className="px-3 py-1 text-xs rounded bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 text-white transition-colors"
971
+ >
972
+ Add
973
+ </button>
974
+ <button
975
+ type="button"
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"
978
+ >
979
+ Cancel
980
+ </button>
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
+ )}
987
+ </div>
988
+ ) : (
587
989
  <button
588
990
  type="button"
589
- onClick={handleReset}
590
- title="Reset to baseline defaults"
591
- 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"
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"
592
993
  >
593
- <RotateCcw className="w-3.5 h-3.5" />
594
- Reset to defaults
994
+ <Plus className="w-3.5 h-3.5" /> Add profile
595
995
  </button>
996
+ )}
997
+ </Section>
998
+
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>
596
1031
  <button
597
1032
  type="button"
598
- onClick={closeSettings}
599
- className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
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
+ }`}
600
1042
  >
601
- <X className="w-4 h-4" />
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
+ />
602
1048
  </button>
603
1049
  </div>
604
- </div>
1050
+ </Section>
605
1051
 
606
- {/* Scrollable body */}
607
- <div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
608
- {/* ── System Prompt ─────────────────────────────────── */}
609
- <Section title="System Prompt">
610
- <Textarea
611
- value={draft.systemPrompt}
612
- onChange={(v) => patchTop("systemPrompt", v)}
613
- rows={8}
614
- />
615
- </Section>
616
-
617
- {/* ── Response Profiles ─────────────────────────────── */}
618
- <Section title="Response Profiles (token limits per length setting)">
619
- <div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
620
- {profileKeys.map((key) => (
621
- <div key={key} className="space-y-3">
622
- <div className="flex items-center justify-between">
623
- <p className="text-xs font-semibold text-cyan-400 capitalize">
624
- {key}
625
- </p>
626
- {profileKeys.length > 1 && (
627
- <button
628
- type="button"
629
- title={`Remove "${key}" profile`}
630
- onClick={() => removeProfile(key)}
631
- className="p-0.5 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
632
- >
633
- <Trash2 className="w-3 h-3" />
634
- </button>
635
- )}
636
- </div>
637
- <div>
638
- <Label>Max Output Tokens</Label>
639
- <NumberInput
640
- value={draft.responseProfiles[key].maxOutputTokens}
641
- onChange={(v) =>
642
- patchProfileField(key, "maxOutputTokens", v)
643
- }
644
- min={100}
645
- max={32000}
646
- step={100}
647
- />
648
- </div>
649
- <div>
650
- <Label>Max Steps</Label>
651
- <NumberInput
652
- value={draft.responseProfiles[key].maxSteps}
653
- onChange={(v) => patchProfileField(key, "maxSteps", v)}
654
- min={1}
655
- max={20}
656
- />
657
- </div>
658
- </div>
659
- ))}
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>
660
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>
661
1069
 
662
- {/* Add profile */}
663
- {showNewProfileForm ? (
664
- <div className="mt-4 border border-slate-700 rounded-lg p-3 space-y-2 bg-slate-800/40">
665
- <p className="text-xs font-medium text-slate-400">
666
- New profile key
667
- </p>
668
- <div className="flex gap-2">
669
- <input
670
- type="text"
671
- value={newProfileKey}
672
- onChange={(e) =>
673
- setNewProfileKey(
674
- e.target.value.toLowerCase().replace(/\s+/g, "-"),
675
- )
676
- }
677
- placeholder="e.g. brief"
678
- 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"
679
- />
680
- <button
681
- type="button"
682
- onClick={() => {
683
- addProfile(newProfileKey);
684
- resetNewProfileForm();
685
- }}
686
- disabled={
687
- !newProfileKey.trim() ||
688
- newProfileKey.trim() in draft.responseProfiles
689
- }
690
- className="px-3 py-1 text-xs rounded bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 text-white transition-colors"
691
- >
692
- Add
693
- </button>
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) => (
694
1086
  <button
1087
+ key={preset.label}
695
1088
  type="button"
696
- onClick={resetNewProfileForm}
697
- className="px-3 py-1 text-xs rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
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
+ }`}
698
1095
  >
699
- Cancel
1096
+ {preset.label}
1097
+ {preset.value > 0 && (
1098
+ <span className="ml-1 opacity-60">
1099
+ ({preset.value.toLocaleString()})
1100
+ </span>
1101
+ )}
700
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
+ />
701
1117
  </div>
702
- {newProfileKey.trim() in draft.responseProfiles && (
703
- <p className="text-xs text-red-400">
704
- A profile with that key already exists.
705
- </p>
706
- )}
707
1118
  </div>
708
- ) : (
709
- <button
710
- type="button"
711
- onClick={() => setShowNewProfileForm(true)}
712
- className="mt-3 flex items-center gap-1.5 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
713
- >
714
- <Plus className="w-3.5 h-3.5" /> Add profile
715
- </button>
716
- )}
717
- </Section>
718
-
719
- {/* ── Prompt Groups ─────────────────────────────────── */}
720
- {Object.entries(draft.promptGroups).map(([groupKey, group]) => (
721
- <PromptGroupSection
722
- key={groupKey}
723
- groupKey={groupKey}
724
- group={group}
725
- onOptionChange={(optKey, value) =>
726
- patchGroupOption(groupKey, optKey, value)
727
- }
728
- onDefaultChange={(optKey) => patchGroupDefault(groupKey, optKey)}
729
- onAddOption={(optKey, prompt) =>
730
- addGroupOption(groupKey, optKey, prompt)
731
- }
732
- onRemoveOption={(optKey) => removeGroupOption(groupKey, optKey)}
733
- onRemoveGroup={() => removeGroup(groupKey)}
734
- onMetaChange={(field, value) =>
735
- patchGroupMeta(groupKey, field, value)
736
- }
737
- />
738
- ))}
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.
1128
+ </p>
1129
+ )}
1130
+ </Section>
739
1131
 
740
- {/* ── Preferences behaviour ─────────────────────────── */}
741
- <Section title="Preference Sending Behaviour">
742
- <div className="flex items-center justify-between">
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">
743
1139
  <div>
744
- <p className="text-sm text-slate-200">
745
- Always send preferences
746
- </p>
747
- <p className="text-xs text-slate-500 mt-0.5">
748
- When on, preference prompt texts are appended to every
749
- message. When off, they are only sent when you change a
750
- setting (saves tokens).
751
- </p>
752
- </div>
753
- <button
754
- type="button"
755
- onClick={() =>
756
- patchTop(
757
- "alwaysSendPrefsDefault",
758
- !draft.alwaysSendPrefsDefault,
759
- )
760
- }
761
- className={`relative shrink-0 w-10 h-5 rounded-full transition-colors ${
762
- draft.alwaysSendPrefsDefault ? "bg-amber-500" : "bg-slate-700"
763
- }`}
764
- >
765
- <span
766
- className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
767
- draft.alwaysSendPrefsDefault ? "translate-x-5" : ""
768
- }`}
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"
769
1151
  />
770
- </button>
771
- </div>
772
- </Section>
773
-
774
- {/* ── Model / Thinking ───────────────────────────────── */}
775
- <Section title="Model & Thinking" defaultOpen={false}>
776
- {/* Current model info (read-only) */}
777
- <div className="flex gap-3 mb-4">
778
- <div className="flex-1">
779
- <Label>Provider</Label>
780
- <div className="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-400 font-mono">
781
- {draft.provider || "openai"}
782
- </div>
1152
+ {newGroupKey && newGroupKey in draft.promptGroups && (
1153
+ <p className="text-xs text-red-400 mt-1">
1154
+ Key already exists.
1155
+ </p>
1156
+ )}
783
1157
  </div>
784
- <div className="flex-1">
785
- <Label>Model</Label>
786
- <div className="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm text-slate-400 font-mono truncate">
787
- {draft.model || "(default)"}
788
- </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
+ />
789
1167
  </div>
790
- </div>
791
-
792
- {/* Thinking budget — only useful for Google/Gemini */}
793
- {["google", "gemini"].includes(draft.provider ?? "") ? (
794
1168
  <div>
795
- <Label>Thinking Budget</Label>
796
- <p className="text-xs text-slate-500 mb-2">
797
- Number of tokens Gemini can use for internal reasoning before
798
- responding. 0 = disabled. Shows a collapsible "Thinking…"
799
- block in the chat. Recommended: 8000 for medium, 0 to save
800
- tokens.
801
- </p>
802
- <div className="flex items-center gap-2 flex-wrap">
803
- {[
804
- { label: "Off", value: 0 },
805
- { label: "Low", value: 1024 },
806
- { label: "Medium", value: 8192 },
807
- { label: "High", value: 24576 },
808
- ].map((preset) => (
809
- <button
810
- key={preset.label}
811
- type="button"
812
- onClick={() => patchTop("thinkingBudget", preset.value)}
813
- className={`px-3 py-1 text-xs rounded-md border transition-colors ${
814
- (draft.thinkingBudget ?? 0) === preset.value
815
- ? "bg-cyan-600/30 text-cyan-300 border-cyan-600/50"
816
- : "text-slate-500 hover:text-slate-300 border-slate-700 hover:border-slate-500"
817
- }`}
818
- >
819
- {preset.label}
820
- {preset.value > 0 && (
821
- <span className="ml-1 opacity-60">
822
- ({preset.value.toLocaleString()})
823
- </span>
824
- )}
825
- </button>
826
- ))}
827
- <div className="flex items-center gap-1.5">
828
- <span className="text-xs text-slate-500">Custom:</span>
829
- <input
830
- type="number"
831
- value={draft.thinkingBudget ?? 0}
832
- min={0}
833
- max={32768}
834
- step={256}
835
- onChange={(e) =>
836
- patchTop("thinkingBudget", Number(e.target.value))
837
- }
838
- 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"
839
- />
840
- </div>
841
- </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
+ />
842
1177
  </div>
843
- ) : (
844
- <p className="text-xs text-slate-500">
845
- Thinking / reasoning display is only available for Google /
846
- Gemini models. Switch{" "}
847
- <code className="bg-slate-800 px-1 rounded">AI_PROVIDER</code>{" "}
848
- to <code className="bg-slate-800 px-1 rounded">google</code> in
849
- your <code className="bg-slate-800 px-1 rounded">.env</code> to
850
- enable it.
851
- </p>
852
- )}
853
- </Section>
1178
+ </div>
854
1179
 
855
- {/* ── Add group form ─────────────────────────────── */}
856
- {showNewGroupForm ? (
857
- <div className="border border-cyan-600/30 rounded-lg p-4 space-y-4 bg-slate-900/60">
858
- <p className="text-sm font-medium text-cyan-300">
859
- New setting group
860
- </p>
861
- <div className="grid grid-cols-3 gap-3">
862
- <div>
863
- <Label>Key (internal ID)</Label>
864
- <input
865
- type="text"
866
- value={newGroupKey}
867
- onChange={(e) =>
868
- setNewGroupKey(
869
- e.target.value.toLowerCase().replace(/\s+/g, "-"),
870
- )
871
- }
872
- placeholder="e.g. tone"
873
- 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"
874
- />
875
- {newGroupKey && newGroupKey in draft.promptGroups && (
876
- <p className="text-xs text-red-400 mt-1">
877
- Key already exists.
878
- </p>
879
- )}
880
- </div>
881
- <div>
882
- <Label>Label (shown in bottom bar)</Label>
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">
883
1184
  <input
884
1185
  type="text"
885
- value={newGroupLabel}
886
- onChange={(e) => setNewGroupLabel(e.target.value)}
887
- placeholder="e.g. Response Tone"
888
- 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"
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"
889
1197
  />
890
- </div>
891
- <div>
892
- <Label>Description (optional)</Label>
893
1198
  <input
894
1199
  type="text"
895
- value={newGroupDescription}
896
- onChange={(e) => setNewGroupDescription(e.target.value)}
897
- placeholder="Shown in the settings panel"
898
- 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"
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"
899
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
+ )}
900
1222
  </div>
901
- </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>
902
1237
 
903
- <div className="space-y-2">
904
- <Label>Options (first will be the default)</Label>
905
- {newGroupOptions.map((opt, i) => (
906
- <div key={i} className="flex gap-2 items-start">
907
- <input
908
- type="text"
909
- value={opt.key}
910
- onChange={(e) => {
911
- const upd = [...newGroupOptions];
912
- upd[i] = {
913
- ...upd[i],
914
- key: e.target.value
915
- .toLowerCase()
916
- .replace(/\s+/g, "-"),
917
- };
918
- setNewGroupOptions(upd);
919
- }}
920
- placeholder="key"
921
- 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"
922
- />
923
- <input
924
- type="text"
925
- value={opt.prompt}
926
- onChange={(e) => {
927
- const upd = [...newGroupOptions];
928
- upd[i] = { ...upd[i], prompt: e.target.value };
929
- setNewGroupOptions(upd);
930
- }}
931
- placeholder="Prompt text appended to message when selected (can be empty)"
932
- 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"
933
- />
934
- {newGroupOptions.length > 1 && (
935
- <button
936
- type="button"
937
- onClick={() =>
938
- setNewGroupOptions((prev) =>
939
- prev.filter((_, j) => j !== i),
940
- )
941
- }
942
- className="mt-1 p-1 rounded text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
943
- >
944
- <Trash2 className="w-3.5 h-3.5" />
945
- </button>
946
- )}
947
- </div>
948
- ))}
949
- <button
950
- type="button"
951
- onClick={() =>
952
- setNewGroupOptions((prev) => [
953
- ...prev,
954
- { key: "", prompt: "" },
955
- ])
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
+ ) : (
1261
+ <button
1262
+ type="button"
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"
1265
+ >
1266
+ <Plus className="w-4 h-4" /> Add setting group
1267
+ </button>
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"
956
1347
  }
957
- className="flex items-center gap-1 text-xs text-slate-500 hover:text-cyan-400 transition-colors"
1348
+ style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}
958
1349
  >
959
- <Plus className="w-3.5 h-3.5" /> Add option row
960
- </button>
961
- </div>
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>
962
1359
 
963
- <div className="flex gap-2 pt-1">
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 ? (
964
1382
  <button
965
1383
  type="button"
966
- onClick={addNewGroup}
967
- disabled={
968
- !newGroupKey.trim() ||
969
- newGroupKey in draft.promptGroups ||
970
- newGroupOptions.every((o) => !o.key.trim())
971
- }
972
- className="px-4 py-1.5 text-sm rounded bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors"
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"
973
1387
  >
974
- Create group
1388
+ <StopCircle className="w-3.5 h-3.5" />
975
1389
  </button>
1390
+ ) : (
976
1391
  <button
977
1392
  type="button"
978
- onClick={resetNewGroupForm}
979
- className="px-4 py-1.5 text-sm rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
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)"
980
1397
  >
981
- Cancel
1398
+ <Play className="w-3.5 h-3.5" />
982
1399
  </button>
983
- </div>
1400
+ )}
984
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" />
985
1426
  ) : (
986
- <button
987
- type="button"
988
- onClick={() => setShowNewGroupForm(true)}
989
- 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"
990
- >
991
- <Plus className="w-4 h-4" /> Add setting group
992
- </button>
1427
+ <Save className="w-4 h-4" />
993
1428
  )}
994
-
995
- {/* ── Viz Guide ─────────────────────────────────────── */}
996
- <Section
997
- title="Viz Guide (returned by getVizGuide tool)"
998
- defaultOpen={false}
999
- >
1000
- <p className="text-xs text-slate-500 -mt-1">
1001
- The full VizCraft spec reference the AI receives when it calls{" "}
1002
- <code className="text-cyan-400">getVizGuide()</code>.
1003
- </p>
1004
- <Textarea
1005
- value={draft.vizGuide}
1006
- onChange={(v) => patchTop("vizGuide", v)}
1007
- rows={16}
1008
- mono
1009
- />
1010
- </Section>
1011
- </div>
1012
-
1013
- {/* Footer */}
1014
- <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-700 shrink-0">
1015
- <button
1016
- type="button"
1017
- onClick={closeSettings}
1018
- className="px-4 py-2 text-sm rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
1019
- >
1020
- Cancel
1021
- </button>
1022
- <button
1023
- type="button"
1024
- onClick={handleSave}
1025
- disabled={saving}
1026
- 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"
1027
- >
1028
- {saving ? (
1029
- <Loader2 className="w-4 h-4 animate-spin" />
1030
- ) : saved ? (
1031
- <Check className="w-4 h-4" />
1032
- ) : (
1033
- <Save className="w-4 h-4" />
1034
- )}
1035
- {saved ? "Saved!" : "Save changes"}
1036
- </button>
1037
- </div>
1429
+ {saved ? "Saved!" : "Save"}
1430
+ </button>
1038
1431
  </div>
1039
1432
  </div>
1040
1433
  );