@polderlabs/bizar 2.3.0 → 2.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 (48) hide show
  1. package/cli/bin.mjs +73 -0
  2. package/cli/copy.mjs +42 -2
  3. package/cli/dashboard/api.mjs +473 -0
  4. package/cli/dashboard/browser.mjs +40 -0
  5. package/cli/dashboard/server.mjs +366 -0
  6. package/cli/dashboard/state.mjs +438 -0
  7. package/cli/dashboard/tasks-store.mjs +203 -0
  8. package/cli/dashboard/watcher.mjs +81 -0
  9. package/cli/dashboard.mjs +97 -0
  10. package/cli/install.mjs +17 -4
  11. package/config/commands/bizar.md +18 -0
  12. package/config/commands/plan.md +26 -0
  13. package/config/commands/visual-plan.md +15 -0
  14. package/config/opencode.json +259 -1
  15. package/dist/assets/index-BVvY22Gt.css +1 -0
  16. package/dist/assets/index-CO3c8O32.js +285 -0
  17. package/dist/assets/index-CO3c8O32.js.map +1 -0
  18. package/dist/index.html +18 -0
  19. package/package.json +26 -2
  20. package/src/App.tsx +233 -0
  21. package/src/components/Button.tsx +55 -0
  22. package/src/components/Card.tsx +40 -0
  23. package/src/components/EmptyState.tsx +30 -0
  24. package/src/components/Modal.tsx +137 -0
  25. package/src/components/Spinner.tsx +19 -0
  26. package/src/components/StatusBadge.tsx +25 -0
  27. package/src/components/Tag.tsx +28 -0
  28. package/src/components/Toast.tsx +142 -0
  29. package/src/components/Topbar.tsx +88 -0
  30. package/src/index.html +17 -0
  31. package/src/lib/api.ts +71 -0
  32. package/src/lib/markdown.tsx +59 -0
  33. package/src/lib/types.ts +200 -0
  34. package/src/lib/utils.ts +79 -0
  35. package/src/lib/ws.ts +132 -0
  36. package/src/main.tsx +12 -0
  37. package/src/styles/main.css +2324 -0
  38. package/src/views/Agents.tsx +199 -0
  39. package/src/views/Chat.tsx +255 -0
  40. package/src/views/Config.tsx +250 -0
  41. package/src/views/Overview.tsx +267 -0
  42. package/src/views/Plans.tsx +667 -0
  43. package/src/views/Projects.tsx +155 -0
  44. package/src/views/Settings.tsx +253 -0
  45. package/src/views/Tasks.tsx +567 -0
  46. package/tsconfig.json +23 -0
  47. package/vite.config.ts +24 -0
  48. package/config/opencode.json.template +0 -52
@@ -0,0 +1,199 @@
1
+ // src/views/Agents.tsx — agent grid + invoke modal.
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import { Bot, RefreshCw, Play, X } from 'lucide-react';
4
+ import { Button } from '../components/Button';
5
+ import { Card, CardTitle } from '../components/Card';
6
+ import { EmptyState } from '../components/EmptyState';
7
+ import { Spinner } from '../components/Spinner';
8
+ import { StatusBadge } from '../components/StatusBadge';
9
+ import { useModal } from '../components/Modal';
10
+ import { useToast } from '../components/Toast';
11
+ import { api } from '../lib/api';
12
+ import { formatRelative, truncate } from '../lib/utils';
13
+ import type { Agent, Settings, Snapshot } from '../lib/types';
14
+
15
+ type Props = {
16
+ snapshot: Snapshot;
17
+ settings: Settings;
18
+ activeTab: string;
19
+ setActiveTab: (id: string) => void;
20
+ refreshSnapshot: () => Promise<void>;
21
+ };
22
+
23
+ export function Agents({ snapshot }: Props) {
24
+ const toast = useToast();
25
+ const modal = useModal();
26
+ const [agents, setAgents] = useState<Agent[]>(snapshot.agents || []);
27
+ const [loading, setLoading] = useState(!snapshot.agents);
28
+
29
+ useEffect(() => {
30
+ if (snapshot.agents?.length) {
31
+ setAgents(snapshot.agents);
32
+ setLoading(false);
33
+ return;
34
+ }
35
+ let cancelled = false;
36
+ api
37
+ .get<{ agents: Agent[] }>('/agents')
38
+ .then((d) => {
39
+ if (!cancelled) {
40
+ setAgents(d.agents || []);
41
+ setLoading(false);
42
+ }
43
+ })
44
+ .catch((err) => {
45
+ if (!cancelled) {
46
+ setLoading(false);
47
+ toast.error(`Could not load agents: ${(err as Error).message}`);
48
+ }
49
+ });
50
+ return () => {
51
+ cancelled = true;
52
+ };
53
+ }, [snapshot.agents, toast]);
54
+
55
+ const sorted = useMemo(
56
+ () => [...agents].sort((a, b) => a.name.localeCompare(b.name)),
57
+ [agents],
58
+ );
59
+
60
+ const refresh = async () => {
61
+ try {
62
+ const d = await api.get<{ agents: Agent[] }>('/agents');
63
+ setAgents(d.agents || []);
64
+ toast.info('Agents refreshed.', 1500);
65
+ } catch (err) {
66
+ toast.error(`Refresh failed: ${(err as Error).message}`);
67
+ }
68
+ };
69
+
70
+ return (
71
+ <div className="view view-agents">
72
+ <header className="view-header">
73
+ <div className="view-header-text">
74
+ <h2 className="view-title">
75
+ <Bot size={18} /> Agents ({sorted.length})
76
+ </h2>
77
+ <p className="view-subtitle">
78
+ The Norse pantheon — 13 specialized agents across 4 cost tiers.
79
+ </p>
80
+ </div>
81
+ <div className="view-actions">
82
+ <Button variant="secondary" size="sm" onClick={refresh}>
83
+ <RefreshCw size={14} /> Refresh
84
+ </Button>
85
+ </div>
86
+ </header>
87
+
88
+ {loading ? (
89
+ <div className="view-loading">
90
+ <Spinner size="lg" />
91
+ </div>
92
+ ) : sorted.length === 0 ? (
93
+ <EmptyState
94
+ icon={<Bot size={32} />}
95
+ title="No agents found"
96
+ message="Run bizar in the terminal to install Bizar."
97
+ />
98
+ ) : (
99
+ <div className="agent-grid">
100
+ {sorted.map((a) => (
101
+ <AgentCard key={a.name} agent={a} onInvoke={() => openInvoke(modal, a, toast, refresh)} />
102
+ ))}
103
+ </div>
104
+ )}
105
+ </div>
106
+ );
107
+ }
108
+
109
+ function AgentCard({
110
+ agent,
111
+ onInvoke,
112
+ }: {
113
+ agent: Agent;
114
+ onInvoke: () => void;
115
+ }) {
116
+ return (
117
+ <Card variant="elevated" interactive className="agent-card">
118
+ <div className="agent-card-head">
119
+ <div className="agent-card-name">{agent.name}</div>
120
+ <StatusBadge kind={agent.mode === 'primary' ? 'accent' : 'neutral'}>
121
+ {agent.mode || 'agent'}
122
+ </StatusBadge>
123
+ </div>
124
+ <p className="agent-card-desc">{truncate(agent.description, 200)}</p>
125
+ <div className="agent-card-meta">
126
+ <span className="mono" title={agent.model || ''}>
127
+ {agent.model || '—'}
128
+ </span>
129
+ <span className="tabular-nums muted">{formatRelative(agent.mtime)}</span>
130
+ </div>
131
+ <div className="agent-card-actions">
132
+ <Button variant="primary" size="sm" onClick={onInvoke}>
133
+ <Play size={12} /> Invoke
134
+ </Button>
135
+ </div>
136
+ </Card>
137
+ );
138
+ }
139
+
140
+ function openInvoke(
141
+ modal: ReturnType<typeof useModal>,
142
+ agent: Agent,
143
+ toast: ReturnType<typeof useToast>,
144
+ refresh: () => Promise<void>,
145
+ ) {
146
+ let promptEl: HTMLTextAreaElement | null = null;
147
+
148
+ const submit = async () => {
149
+ const prompt = (promptEl?.value || '').trim();
150
+ if (!prompt) {
151
+ toast.warning('Prompt is required.');
152
+ return;
153
+ }
154
+ try {
155
+ await api.post(`/agents/${encodeURIComponent(agent.name)}/invoke`, { prompt });
156
+ toast.success(`Invoked ${agent.name}.`);
157
+ modal.close();
158
+ await refresh();
159
+ } catch (err) {
160
+ toast.error(`Invoke failed: ${(err as Error).message}`);
161
+ }
162
+ };
163
+
164
+ modal.open({
165
+ title: `Invoke ${agent.name}`,
166
+ width: 560,
167
+ children: (
168
+ <div className="invoke-form">
169
+ <p className="muted invoke-form-meta mono">
170
+ {agent.model || '—'} · {agent.path}
171
+ </p>
172
+ <p className="invoke-form-desc">{agent.description}</p>
173
+ <label className="field-label" htmlFor="invoke-prompt">
174
+ Prompt
175
+ </label>
176
+ <textarea
177
+ ref={(el) => {
178
+ promptEl = el;
179
+ }}
180
+ id="invoke-prompt"
181
+ className="textarea"
182
+ rows={5}
183
+ placeholder="What should this agent do?"
184
+ autoFocus
185
+ />
186
+ </div>
187
+ ),
188
+ footer: (
189
+ <div className="modal-footer-actions">
190
+ <Button variant="ghost" onClick={() => modal.close()}>
191
+ <X size={14} /> Cancel
192
+ </Button>
193
+ <Button variant="primary" onClick={submit}>
194
+ <Play size={14} /> Invoke
195
+ </Button>
196
+ </div>
197
+ ),
198
+ });
199
+ }
@@ -0,0 +1,255 @@
1
+ // src/views/Chat.tsx — chat history + send box + slash suggestions.
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import ReactMarkdown from 'react-markdown';
4
+ import remarkGfm from 'remark-gfm';
5
+ import { MessageSquare, Send, RefreshCw, Eraser, CornerDownLeft } from 'lucide-react';
6
+ import { Button } from '../components/Button';
7
+ import { EmptyState } from '../components/EmptyState';
8
+ import { Spinner } from '../components/Spinner';
9
+ import { useToast } from '../components/Toast';
10
+ import { api } from '../lib/api';
11
+ import { cn, formatTime } from '../lib/utils';
12
+ import type { ChatMessage, ChatResponse, Settings, Snapshot } from '../lib/types';
13
+
14
+ type Props = {
15
+ snapshot: Snapshot;
16
+ settings: Settings;
17
+ activeTab: string;
18
+ setActiveTab: (id: string) => void;
19
+ refreshSnapshot: () => Promise<void>;
20
+ };
21
+
22
+ type SlashCommand = { cmd: string; desc: string };
23
+
24
+ const SLASH_COMMANDS: SlashCommand[] = [
25
+ { cmd: '/plan new <slug>', desc: 'Create a new plan' },
26
+ { cmd: '/plan list', desc: 'List plans' },
27
+ { cmd: '/plan open <slug>', desc: 'Open a plan URL' },
28
+ { cmd: '/plan status <slug> <status>', desc: 'Set plan status' },
29
+ { cmd: '/visual-plan on|off', desc: 'Toggle visual plan mode' },
30
+ { cmd: '/bizar', desc: 'Show Bizar menu / launch dashboard' },
31
+ { cmd: '/audit', desc: 'Run security audit' },
32
+ { cmd: '/explain <q>', desc: 'Read-only code Q&A' },
33
+ { cmd: '/init', desc: 'Initialize .bizar/ in this project' },
34
+ { cmd: '/learn', desc: 'Extract patterns from session' },
35
+ { cmd: '/pr-review', desc: 'PR review with @mimir + @forseti' },
36
+ { cmd: '/help', desc: 'Show all slash commands' },
37
+ ];
38
+
39
+ export function Chat(_: Props) {
40
+ const toast = useToast();
41
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
42
+ const [sessions, setSessions] = useState<{ id: string; mtime: number }[]>(
43
+ [],
44
+ );
45
+ const [loading, setLoading] = useState(true);
46
+ const [sessionId, setSessionId] = useState<string>('');
47
+ const [text, setText] = useState('');
48
+ const [sending, setSending] = useState(false);
49
+ const [suggestions, setSuggestions] = useState<SlashCommand[]>([]);
50
+ const listRef = useRef<HTMLDivElement>(null);
51
+ const inputRef = useRef<HTMLTextAreaElement>(null);
52
+
53
+ const loadChat = async (sid?: string) => {
54
+ try {
55
+ const url = sid ? `/chat?session=${encodeURIComponent(sid)}` : '/chat?limit=200';
56
+ const data = await api.get<ChatResponse>(url);
57
+ setMessages(data.messages || []);
58
+ setSessions(data.sessions || []);
59
+ } catch (err) {
60
+ toast.error(`Chat load failed: ${(err as Error).message}`);
61
+ } finally {
62
+ setLoading(false);
63
+ }
64
+ };
65
+
66
+ useEffect(() => {
67
+ loadChat();
68
+ // eslint-disable-next-line react-hooks/exhaustive-deps
69
+ }, []);
70
+
71
+ // Auto-scroll on new messages
72
+ useEffect(() => {
73
+ if (listRef.current) {
74
+ listRef.current.scrollTop = listRef.current.scrollHeight;
75
+ }
76
+ }, [messages]);
77
+
78
+ // Slash-command suggestions
79
+ useEffect(() => {
80
+ if (text.startsWith('/') && !text.includes(' ')) {
81
+ const q = text.toLowerCase();
82
+ setSuggestions(
83
+ SLASH_COMMANDS.filter((c) => c.cmd.toLowerCase().startsWith(q)).slice(
84
+ 0,
85
+ 6,
86
+ ),
87
+ );
88
+ } else {
89
+ setSuggestions([]);
90
+ }
91
+ }, [text]);
92
+
93
+ const onSend = async () => {
94
+ const message = text.trim();
95
+ if (!message || sending) return;
96
+ setSending(true);
97
+ const optimistic: ChatMessage = {
98
+ role: 'user',
99
+ content: message,
100
+ ts: new Date().toISOString(),
101
+ };
102
+ setMessages((cur) => [...cur, optimistic]);
103
+ setText('');
104
+ setSuggestions([]);
105
+ try {
106
+ await api.post('/chat', { message });
107
+ toast.success('Message accepted. Open the TUI to dispatch.');
108
+ } catch (err) {
109
+ setMessages((cur) => cur.filter((m) => m !== optimistic));
110
+ toast.error(`Send failed: ${(err as Error).message}`);
111
+ } finally {
112
+ setSending(false);
113
+ inputRef.current?.focus();
114
+ }
115
+ };
116
+
117
+ const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
118
+ if (e.key === 'Enter' && !e.shiftKey) {
119
+ e.preventDefault();
120
+ onSend();
121
+ } else if (e.key === 'Tab' && suggestions.length) {
122
+ e.preventDefault();
123
+ const first = suggestions[0];
124
+ const base = first.cmd.split(' ')[0];
125
+ setText(`${base} `);
126
+ setSuggestions([]);
127
+ }
128
+ };
129
+
130
+ // Sort newest at the bottom for chat feel
131
+ const ordered = [...messages].reverse();
132
+
133
+ return (
134
+ <div className="view view-chat">
135
+ <header className="view-header chat-header">
136
+ <div className="view-header-text">
137
+ <h2 className="view-title">
138
+ <MessageSquare size={18} /> Chat
139
+ </h2>
140
+ <p className="view-subtitle">
141
+ Conversation history. Press Enter to send, Shift+Enter for newline.
142
+ </p>
143
+ </div>
144
+ <div className="view-actions">
145
+ <select
146
+ className="select select-sm"
147
+ value={sessionId}
148
+ onChange={(e) => {
149
+ const sid = e.target.value;
150
+ setSessionId(sid);
151
+ loadChat(sid || undefined);
152
+ }}
153
+ >
154
+ <option value="">All sessions ({sessions.length})</option>
155
+ {sessions.map((s) => (
156
+ <option key={s.id} value={s.id}>
157
+ {s.id.slice(0, 28)}… ·{' '}
158
+ {new Date(s.mtime).toLocaleDateString()}
159
+ </option>
160
+ ))}
161
+ </select>
162
+ <Button
163
+ variant="secondary"
164
+ size="sm"
165
+ onClick={() => loadChat(sessionId || undefined)}
166
+ >
167
+ <RefreshCw size={14} /> Refresh
168
+ </Button>
169
+ <Button variant="ghost" size="sm" onClick={() => setMessages([])}>
170
+ <Eraser size={14} /> Clear view
171
+ </Button>
172
+ </div>
173
+ </header>
174
+
175
+ <div className="chat-container">
176
+ <div className="chat-list" ref={listRef}>
177
+ {loading ? (
178
+ <div className="view-loading">
179
+ <Spinner />
180
+ </div>
181
+ ) : ordered.length === 0 ? (
182
+ <EmptyState
183
+ icon={<MessageSquare size={28} />}
184
+ title="No messages yet"
185
+ message="Type something below to start."
186
+ />
187
+ ) : (
188
+ ordered.map((m, idx) => <ChatBubble key={idx} message={m} />)
189
+ )}
190
+ </div>
191
+
192
+ <div className="chat-composer">
193
+ {suggestions.length > 0 && (
194
+ <div className="chat-suggestions">
195
+ {suggestions.map((s) => (
196
+ <button
197
+ type="button"
198
+ key={s.cmd}
199
+ className="chat-suggestion"
200
+ onClick={() => {
201
+ const base = s.cmd.split(' ')[0];
202
+ setText(`${base} `);
203
+ inputRef.current?.focus();
204
+ }}
205
+ >
206
+ <span className="mono">{s.cmd}</span>
207
+ <span className="chat-suggestion-desc">{s.desc}</span>
208
+ </button>
209
+ ))}
210
+ </div>
211
+ )}
212
+ <div className="chat-composer-row">
213
+ <textarea
214
+ ref={inputRef}
215
+ className="chat-input"
216
+ placeholder="Send a message… (Enter to send, Shift+Enter for newline, / for commands)"
217
+ rows={2}
218
+ value={text}
219
+ onChange={(e) => setText(e.target.value)}
220
+ onKeyDown={onKeyDown}
221
+ disabled={sending}
222
+ />
223
+ <Button
224
+ variant="primary"
225
+ onClick={onSend}
226
+ disabled={sending || !text.trim()}
227
+ >
228
+ {sending ? <Spinner size="sm" /> : <Send size={14} />}{' '}
229
+ <span>Send</span>{' '}
230
+ <CornerDownLeft size={12} className="hint-key" />
231
+ </Button>
232
+ </div>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ );
237
+ }
238
+
239
+ function ChatBubble({ message }: { message: ChatMessage }) {
240
+ const role = (message.role || 'assistant').toLowerCase();
241
+ const ts = message.ts ? formatTime(message.ts) : '';
242
+ const content = message.content || message.message || '';
243
+ return (
244
+ <div className={cn('chat-msg', `chat-msg-${role}`)}>
245
+ <div className="chat-msg-meta">
246
+ <span className="chat-msg-role">{role}</span>
247
+ {message.agent && <span className="chat-msg-agent">{message.agent}</span>}
248
+ {ts && <span className="chat-msg-ts tabular-nums">{ts}</span>}
249
+ </div>
250
+ <div className="chat-msg-body markdown-body">
251
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
252
+ </div>
253
+ </div>
254
+ );
255
+ }
@@ -0,0 +1,250 @@
1
+ // src/views/Config.tsx — opencode.json editor with live validation.
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Settings2, RefreshCw, Save, FileCode2 } from 'lucide-react';
4
+ import { Button } from '../components/Button';
5
+ import { Card, CardTitle, CardMeta } from '../components/Card';
6
+ import { useToast } from '../components/Toast';
7
+ import { api } from '../lib/api';
8
+ import { cn, debounce, hashText } from '../lib/utils';
9
+ import { JsonHighlight } from '../lib/markdown';
10
+ import type { ConfigResponse, Settings, Snapshot } from '../lib/types';
11
+
12
+ type Props = {
13
+ snapshot: Snapshot;
14
+ settings: Settings;
15
+ activeTab: string;
16
+ setActiveTab: (id: string) => void;
17
+ refreshSnapshot: () => Promise<void>;
18
+ };
19
+
20
+ type Parsed = {
21
+ raw: string;
22
+ data: unknown | null;
23
+ error: string | null;
24
+ };
25
+
26
+ export function Config({ snapshot }: Props) {
27
+ const toast = useToast();
28
+ const initial: ConfigResponse | undefined = snapshot.config;
29
+ const [original, setOriginal] = useState<string>(
30
+ initial?.raw || (initial?.data ? JSON.stringify(initial.data, null, 2) : ''),
31
+ );
32
+ const [parsed, setParsed] = useState<Parsed>({
33
+ raw: initial?.raw || '',
34
+ data: initial?.data ?? null,
35
+ error: null,
36
+ });
37
+ const [dirty, setDirty] = useState(false);
38
+ const [saving, setSaving] = useState(false);
39
+ const [path, setPath] = useState<string>(initial?.path || '');
40
+ const [reloadPending, setReloadPending] = useState(false);
41
+ const taRef = useRef<HTMLTextAreaElement>(null);
42
+
43
+ const originalHash = useMemo(() => hashText(original), [original]);
44
+
45
+ // Refresh from server
46
+ const reload = async () => {
47
+ try {
48
+ const d = await api.post<ConfigResponse>('/config/reload');
49
+ const raw = d.raw || (d.data ? JSON.stringify(d.data, null, 2) : '');
50
+ setOriginal(raw);
51
+ setParsed({ raw, data: d.data ?? null, error: null });
52
+ setDirty(false);
53
+ setPath(d.path || '');
54
+ toast.info('Config reloaded.', 1500);
55
+ } catch (err) {
56
+ toast.error(`Reload failed: ${(err as Error).message}`);
57
+ }
58
+ };
59
+
60
+ const onChange = (val: string) => {
61
+ setParsed((cur) => {
62
+ const next: Parsed = { raw: val, data: cur.data, error: null };
63
+ try {
64
+ next.data = JSON.parse(val);
65
+ next.error = null;
66
+ } catch (e) {
67
+ next.data = null;
68
+ next.error = (e as Error).message;
69
+ }
70
+ return next;
71
+ });
72
+ };
73
+
74
+ // Debounce sync of dirty state to avoid setState storms
75
+ const onChangeDebounced = useMemo(
76
+ () =>
77
+ debounce((val: string) => {
78
+ setDirty(hashText(val) !== originalHash);
79
+ }, 100),
80
+ [originalHash],
81
+ );
82
+
83
+ const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
84
+ const v = e.target.value;
85
+ onChange(v);
86
+ onChangeDebounced(v);
87
+ };
88
+
89
+ // Warn before navigating away with unsaved changes
90
+ useEffect(() => {
91
+ if (!dirty && !reloadPending) return;
92
+ const handler = (e: BeforeUnloadEvent) => {
93
+ if (dirty) {
94
+ e.preventDefault();
95
+ e.returnValue = '';
96
+ }
97
+ };
98
+ window.addEventListener('beforeunload', handler);
99
+ return () => window.removeEventListener('beforeunload', handler);
100
+ }, [dirty, reloadPending]);
101
+
102
+ const save = async () => {
103
+ if (!parsed.data || parsed.error || saving) return;
104
+ setSaving(true);
105
+ try {
106
+ const result = await api.put<ConfigResponse>('/config', parsed.data);
107
+ const raw = result.raw || JSON.stringify(result.data, null, 2);
108
+ setOriginal(raw);
109
+ setParsed({ raw, data: result.data ?? null, error: null });
110
+ setDirty(false);
111
+ toast.success('Config saved.');
112
+ } catch (err) {
113
+ toast.error(`Save failed: ${(err as Error).message}`);
114
+ } finally {
115
+ setSaving(false);
116
+ }
117
+ };
118
+
119
+ const canSave = !!parsed.data && !parsed.error && dirty && !saving;
120
+
121
+ return (
122
+ <div className="view view-config">
123
+ <header className="view-header">
124
+ <div className="view-header-text">
125
+ <h2 className="view-title">
126
+ <Settings2 size={18} /> Config
127
+ </h2>
128
+ <p className="view-subtitle mono ellipsis" title={path}>
129
+ {path}
130
+ </p>
131
+ </div>
132
+ <div className="view-actions">
133
+ <Button
134
+ variant="secondary"
135
+ size="sm"
136
+ onClick={() => {
137
+ if (dirty) {
138
+ setReloadPending(true);
139
+ if (
140
+ // eslint-disable-next-line no-alert
141
+ confirm('Discard unsaved changes and reload from disk?')
142
+ ) {
143
+ reload();
144
+ }
145
+ setReloadPending(false);
146
+ } else {
147
+ reload();
148
+ }
149
+ }}
150
+ >
151
+ <RefreshCw size={14} /> Reload from disk
152
+ </Button>
153
+ <Button
154
+ variant="primary"
155
+ size="sm"
156
+ disabled={!canSave}
157
+ onClick={save}
158
+ >
159
+ {saving ? (
160
+ <span className="btn-spinner" />
161
+ ) : (
162
+ <Save size={14} />
163
+ )}
164
+ {saving ? 'Saving…' : 'Save'}
165
+ </Button>
166
+ </div>
167
+ </header>
168
+
169
+ <div className="config-grid">
170
+ <Card>
171
+ <CardTitle>
172
+ <FileCode2 size={14} /> JSON tree
173
+ </CardTitle>
174
+ <CardMeta>Parsed from current editor content</CardMeta>
175
+ <div className="json-tree">
176
+ {parsed.data != null ? (
177
+ <JsonHighlight value={parsed.data} />
178
+ ) : (
179
+ <span className="muted">
180
+ {parsed.error ? 'Invalid JSON' : 'No data'}
181
+ </span>
182
+ )}
183
+ </div>
184
+ </Card>
185
+ <Card>
186
+ <CardTitle>Raw JSON</CardTitle>
187
+ <CardMeta>
188
+ {parsed.error ? (
189
+ <span className="text-error">{parsed.error}</span>
190
+ ) : (
191
+ <span className="muted">Live validation as you type</span>
192
+ )}
193
+ </CardMeta>
194
+ <textarea
195
+ ref={taRef}
196
+ className={cn('textarea config-textarea', parsed.error && 'invalid')}
197
+ spellCheck={false}
198
+ value={parsed.raw}
199
+ onChange={handleInput}
200
+ />
201
+ </Card>
202
+ </div>
203
+
204
+ <Card className="diff-card">
205
+ <CardTitle>Diff vs last save</CardTitle>
206
+ <CardMeta>
207
+ {parsed.raw === original
208
+ ? 'No changes.'
209
+ : 'Line-level changes from disk.'}
210
+ </CardMeta>
211
+ <div className="diff-view mono">
212
+ {renderDiff(original, parsed.raw)}
213
+ </div>
214
+ </Card>
215
+ </div>
216
+ );
217
+ }
218
+
219
+ function renderDiff(a: string, b: string) {
220
+ if (a === b) return null;
221
+ const aLines = (a || '').split('\n');
222
+ const bLines = (b || '').split('\n');
223
+ const max = Math.max(aLines.length, bLines.length);
224
+ const out: React.ReactNode[] = [];
225
+ for (let i = 0; i < max; i++) {
226
+ const al = aLines[i] ?? '';
227
+ const bl = bLines[i] ?? '';
228
+ if (al === bl) {
229
+ out.push(
230
+ <div key={i} className="diff-line">
231
+ {al || ' '}
232
+ </div>,
233
+ );
234
+ } else {
235
+ if (al)
236
+ out.push(
237
+ <div key={`a${i}`} className="diff-line diff-line-removed">
238
+ - {al}
239
+ </div>,
240
+ );
241
+ if (bl)
242
+ out.push(
243
+ <div key={`b${i}`} className="diff-line diff-line-added">
244
+ + {bl}
245
+ </div>,
246
+ );
247
+ }
248
+ }
249
+ return out;
250
+ }