@aion0/forge 0.1.10 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback} from 'react';
4
+ import { Terminal } from '@xterm/xterm';
5
+ import { FitAddon } from '@xterm/addon-fit';
6
+ import '@xterm/xterm/css/xterm.css';
7
+
8
+ const SESSION_NAME = 'mw-docs-claude';
9
+
10
+ function getWsUrl() {
11
+ if (typeof window === 'undefined') return 'ws://localhost:3001';
12
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
13
+ const wsHost = window.location.hostname;
14
+ if (wsHost !== 'localhost' && wsHost !== '127.0.0.1') {
15
+ return `${wsProtocol}//${window.location.host}/terminal-ws`;
16
+ }
17
+ return `${wsProtocol}//${wsHost}:3001`;
18
+ }
19
+
20
+ export default function DocTerminal({ docRoot }: { docRoot: string }) {
21
+ const containerRef = useRef<HTMLDivElement>(null);
22
+ const [connected, setConnected] = useState(false);
23
+ const wsRef = useRef<WebSocket | null>(null);
24
+ const docRootRef = useRef(docRoot);
25
+ docRootRef.current = docRoot;
26
+
27
+ useEffect(() => {
28
+ if (!containerRef.current) return;
29
+
30
+ let disposed = false;
31
+ const term = new Terminal({
32
+ cursorBlink: true,
33
+ fontSize: 13,
34
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
35
+ scrollback: 5000,
36
+ logger: { trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
37
+ theme: {
38
+ background: '#1a1a2e',
39
+ foreground: '#e0e0e0',
40
+ cursor: '#7c5bf0',
41
+ selectionBackground: '#7c5bf066',
42
+ },
43
+ });
44
+ const fit = new FitAddon();
45
+ term.loadAddon(fit);
46
+
47
+ term.open(containerRef.current);
48
+ try { fit.fit(); } catch {}
49
+
50
+ const wsUrl = getWsUrl();
51
+ let ws: WebSocket | null = null;
52
+ let reconnectTimer = 0;
53
+ let isNewSession = false;
54
+
55
+ function connect() {
56
+ if (disposed) return;
57
+ const socket = new WebSocket(wsUrl);
58
+ ws = socket;
59
+ wsRef.current = socket;
60
+
61
+ socket.onopen = () => {
62
+ if (disposed) { socket.close(); return; }
63
+ const cols = term.cols;
64
+ const rows = term.rows;
65
+ socket.send(JSON.stringify({ type: 'attach', sessionName: SESSION_NAME, cols, rows }));
66
+ };
67
+
68
+ socket.onmessage = (event) => {
69
+ if (disposed) return;
70
+ try {
71
+ const msg = JSON.parse(event.data);
72
+ if (msg.type === 'output') {
73
+ try { term.write(msg.data); } catch {};
74
+ } else if (msg.type === 'connected') {
75
+ setConnected(true);
76
+ // For newly created session: cd to doc root and run claude --resume to let user pick
77
+ if (isNewSession && docRootRef.current) {
78
+ isNewSession = false;
79
+ setTimeout(() => {
80
+ if (socket.readyState === WebSocket.OPEN) {
81
+ socket.send(JSON.stringify({ type: 'input', data: `cd "${docRootRef.current}" && claude --resume\n` }));
82
+ }
83
+ }, 300);
84
+ }
85
+ } else if (msg.type === 'error') {
86
+ // Session doesn't exist — create it
87
+ isNewSession = true;
88
+ if (socket.readyState === WebSocket.OPEN) {
89
+ socket.send(JSON.stringify({ type: 'create', cols: term.cols, rows: term.rows, sessionName: SESSION_NAME }));
90
+ }
91
+ }
92
+ } catch {}
93
+ };
94
+
95
+ socket.onclose = () => {
96
+ if (disposed) return;
97
+ setConnected(false);
98
+ reconnectTimer = window.setTimeout(connect, 3000);
99
+ };
100
+
101
+ socket.onerror = () => {};
102
+ }
103
+
104
+ connect();
105
+
106
+ term.onData((data) => {
107
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
108
+ });
109
+
110
+ // Resize with protection
111
+ const resizeObserver = new ResizeObserver(() => {
112
+ const el = containerRef.current;
113
+ if (!el || el.offsetWidth < 100 || el.offsetHeight < 50) return;
114
+ try {
115
+ fit.fit();
116
+ if (term.cols < 2 || term.rows < 2) return;
117
+ if (ws?.readyState === WebSocket.OPEN) {
118
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
119
+ }
120
+ } catch {}
121
+ });
122
+ resizeObserver.observe(containerRef.current);
123
+
124
+ return () => {
125
+ disposed = true;
126
+ clearTimeout(reconnectTimer);
127
+ resizeObserver.disconnect();
128
+ if (ws) { ws.onclose = null; ws.close(); }
129
+ term.dispose();
130
+ };
131
+ }, []);
132
+
133
+ const runCommand = useCallback((cmd: string) => {
134
+ const ws = wsRef.current;
135
+ if (ws?.readyState === WebSocket.OPEN) {
136
+ ws.send(JSON.stringify({ type: 'input', data: cmd + '\n' }));
137
+ }
138
+ }, []);
139
+
140
+ return (
141
+ <div className="h-full flex flex-col bg-[#1a1a2e]">
142
+ {/* Toolbar */}
143
+ <div className="flex items-center gap-2 px-2 py-1 border-b border-[#2a2a4a] shrink-0">
144
+ <span className="text-[9px] text-gray-500">Claude Console</span>
145
+ <span className={`text-[9px] ${connected ? 'text-green-500' : 'text-gray-600'}`}>
146
+ {connected ? '● connected' : '○'}
147
+ </span>
148
+ <div className="ml-auto flex items-center gap-1">
149
+ <button
150
+ onClick={() => runCommand(`cd "${docRoot}" && claude`)}
151
+ className="text-[10px] px-2 py-0.5 text-[var(--accent)] hover:bg-[#2a2a4a] rounded"
152
+ >
153
+ New
154
+ </button>
155
+ <button
156
+ onClick={() => runCommand(`cd "${docRoot}" && claude --resume`)}
157
+ className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded"
158
+ >
159
+ Resume
160
+ </button>
161
+ </div>
162
+ </div>
163
+
164
+ {/* Terminal */}
165
+ <div ref={containerRef} className="flex-1 min-h-0" />
166
+ </div>
167
+ );
168
+ }
@@ -0,0 +1,254 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
4
+ import MarkdownContent from './MarkdownContent';
5
+
6
+ const DocTerminal = lazy(() => import('./DocTerminal'));
7
+
8
+ interface FileNode {
9
+ name: string;
10
+ path: string;
11
+ type: 'file' | 'dir';
12
+ children?: FileNode[];
13
+ }
14
+
15
+ // ─── File Tree ───────────────────────────────────────────
16
+
17
+ function TreeNode({ node, depth, selected, onSelect }: {
18
+ node: FileNode;
19
+ depth: number;
20
+ selected: string | null;
21
+ onSelect: (path: string) => void;
22
+ }) {
23
+ const [expanded, setExpanded] = useState(depth < 1);
24
+
25
+ if (node.type === 'dir') {
26
+ return (
27
+ <div>
28
+ <button
29
+ onClick={() => setExpanded(v => !v)}
30
+ className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs"
31
+ style={{ paddingLeft: depth * 12 + 4 }}
32
+ >
33
+ <span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
34
+ <span className="text-[var(--text-primary)]">{node.name}</span>
35
+ </button>
36
+ {expanded && node.children?.map(child => (
37
+ <TreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} />
38
+ ))}
39
+ </div>
40
+ );
41
+ }
42
+
43
+ const isSelected = selected === node.path;
44
+ return (
45
+ <button
46
+ onClick={() => onSelect(node.path)}
47
+ className={`w-full text-left flex items-center gap-1 px-1 py-0.5 rounded text-xs truncate ${
48
+ isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
49
+ }`}
50
+ style={{ paddingLeft: depth * 12 + 16 }}
51
+ title={node.path}
52
+ >
53
+ {node.name.replace(/\.md$/, '')}
54
+ </button>
55
+ );
56
+ }
57
+
58
+ // ─── Search ──────────────────────────────────────────────
59
+
60
+ function flattenTree(nodes: FileNode[]): FileNode[] {
61
+ const result: FileNode[] = [];
62
+ for (const node of nodes) {
63
+ if (node.type === 'file') result.push(node);
64
+ if (node.children) result.push(...flattenTree(node.children));
65
+ }
66
+ return result;
67
+ }
68
+
69
+ // ─── Main Component ──────────────────────────────────────
70
+
71
+ export default function DocsViewer() {
72
+ const [roots, setRoots] = useState<string[]>([]);
73
+ const [rootPaths, setRootPaths] = useState<string[]>([]);
74
+ const [activeRoot, setActiveRoot] = useState(0);
75
+ const [tree, setTree] = useState<FileNode[]>([]);
76
+ const [selectedFile, setSelectedFile] = useState<string | null>(null);
77
+ const [content, setContent] = useState<string | null>(null);
78
+ const [loading, setLoading] = useState(false);
79
+ const [search, setSearch] = useState('');
80
+ const [terminalHeight, setTerminalHeight] = useState(250);
81
+ const [sidebarOpen, setSidebarOpen] = useState(true);
82
+ const dragRef = useRef<{ startY: number; startH: number } | null>(null);
83
+
84
+ // Fetch tree
85
+ const fetchTree = useCallback(async (rootIdx: number) => {
86
+ const res = await fetch(`/api/docs?root=${rootIdx}`);
87
+ const data = await res.json();
88
+ setRoots(data.roots || []);
89
+ setRootPaths(data.rootPaths || []);
90
+ setTree(data.tree || []);
91
+ }, []);
92
+
93
+ useEffect(() => { fetchTree(activeRoot); }, [activeRoot, fetchTree]);
94
+
95
+ // Fetch file content
96
+ const openFile = useCallback(async (path: string) => {
97
+ setSelectedFile(path);
98
+ setLoading(true);
99
+ const res = await fetch(`/api/docs?root=${activeRoot}&file=${encodeURIComponent(path)}`);
100
+ const data = await res.json();
101
+ setContent(data.content || null);
102
+ setLoading(false);
103
+ }, [activeRoot]);
104
+
105
+ // Search filter
106
+ const allFiles = flattenTree(tree);
107
+ const filtered = search
108
+ ? allFiles.filter(f => f.name.toLowerCase().includes(search.toLowerCase()) || f.path.toLowerCase().includes(search.toLowerCase()))
109
+ : null;
110
+
111
+ // Drag to resize terminal
112
+ const onDragStart = (e: React.MouseEvent) => {
113
+ e.preventDefault();
114
+ dragRef.current = { startY: e.clientY, startH: terminalHeight };
115
+ const onMove = (ev: MouseEvent) => {
116
+ if (!dragRef.current) return;
117
+ const delta = dragRef.current.startY - ev.clientY;
118
+ setTerminalHeight(Math.max(100, Math.min(500, dragRef.current.startH + delta)));
119
+ };
120
+ const onUp = () => {
121
+ dragRef.current = null;
122
+ window.removeEventListener('mousemove', onMove);
123
+ window.removeEventListener('mouseup', onUp);
124
+ };
125
+ window.addEventListener('mousemove', onMove);
126
+ window.addEventListener('mouseup', onUp);
127
+ };
128
+
129
+ if (roots.length === 0) {
130
+ return (
131
+ <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
132
+ <div className="text-center space-y-2">
133
+ <p className="text-lg">No document directories configured</p>
134
+ <p className="text-xs">Add directories in Settings → Document Roots</p>
135
+ </div>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ return (
141
+ <div className="flex-1 flex flex-col min-h-0">
142
+ {/* Doc content area */}
143
+ <div className="flex-1 flex min-h-0">
144
+ {/* Collapsible sidebar — file tree */}
145
+ {sidebarOpen && (
146
+ <aside className="w-56 border-r border-[var(--border)] flex flex-col shrink-0">
147
+ {/* Root selector */}
148
+ {roots.length > 1 && (
149
+ <div className="p-2 border-b border-[var(--border)]">
150
+ <select
151
+ value={activeRoot}
152
+ onChange={e => { setActiveRoot(Number(e.target.value)); setSelectedFile(null); setContent(null); }}
153
+ className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
154
+ >
155
+ {roots.map((r, i) => <option key={i} value={i}>{r}</option>)}
156
+ </select>
157
+ </div>
158
+ )}
159
+
160
+ {/* Search */}
161
+ <div className="p-2 border-b border-[var(--border)]">
162
+ <input
163
+ type="text"
164
+ placeholder="Search..."
165
+ value={search}
166
+ onChange={e => setSearch(e.target.value)}
167
+ className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
168
+ />
169
+ </div>
170
+
171
+ {/* Tree / search results */}
172
+ <div className="flex-1 overflow-y-auto p-1">
173
+ {filtered ? (
174
+ filtered.length === 0 ? (
175
+ <div className="text-xs text-[var(--text-secondary)] p-2">No matches</div>
176
+ ) : (
177
+ filtered.map(f => (
178
+ <button
179
+ key={f.path}
180
+ onClick={() => { openFile(f.path); setSearch(''); }}
181
+ className={`w-full text-left px-2 py-1 rounded text-xs truncate ${
182
+ selectedFile === f.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
183
+ }`}
184
+ title={f.path}
185
+ >
186
+ <span className="text-[var(--text-primary)]">{f.name.replace(/\.md$/, '')}</span>
187
+ <span className="text-[9px] text-[var(--text-secondary)] ml-1">{f.path.split('/').slice(0, -1).join('/')}</span>
188
+ </button>
189
+ ))
190
+ )
191
+ ) : (
192
+ tree.map(node => (
193
+ <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
194
+ ))
195
+ )}
196
+ </div>
197
+ </aside>
198
+ )}
199
+
200
+ {/* Main content — full width markdown */}
201
+ <main className="flex-1 flex flex-col min-w-0">
202
+ {/* Top bar */}
203
+ <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0 flex items-center gap-2">
204
+ <button
205
+ onClick={() => setSidebarOpen(v => !v)}
206
+ className="text-[10px] px-1.5 py-0.5 text-gray-400 hover:text-white hover:bg-[var(--bg-tertiary)] rounded"
207
+ title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
208
+ >
209
+ {sidebarOpen ? '◀' : '▶'}
210
+ </button>
211
+ {selectedFile ? (
212
+ <>
213
+ <span className="text-xs font-semibold text-[var(--text-primary)] truncate">{selectedFile.replace(/\.md$/, '')}</span>
214
+ <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{selectedFile}</span>
215
+ </>
216
+ ) : (
217
+ <span className="text-xs text-[var(--text-secondary)]">{roots[activeRoot] || 'Docs'}</span>
218
+ )}
219
+ </div>
220
+
221
+ {/* Content */}
222
+ {selectedFile && content ? (
223
+ <div className="flex-1 overflow-y-auto px-8 py-6">
224
+ {loading ? (
225
+ <div className="text-xs text-[var(--text-secondary)]">Loading...</div>
226
+ ) : (
227
+ <div className="max-w-none">
228
+ <MarkdownContent content={content} />
229
+ </div>
230
+ )}
231
+ </div>
232
+ ) : (
233
+ <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
234
+ <p className="text-xs">Select a document to view</p>
235
+ </div>
236
+ )}
237
+ </main>
238
+ </div>
239
+
240
+ {/* Resize handle */}
241
+ <div
242
+ onMouseDown={onDragStart}
243
+ className="h-1 bg-[var(--border)] cursor-row-resize hover:bg-[var(--accent)]/50 shrink-0"
244
+ />
245
+
246
+ {/* Bottom — Claude console */}
247
+ <div className="shrink-0" style={{ height: terminalHeight }}>
248
+ <Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
249
+ <DocTerminal docRoot={rootPaths[activeRoot] || ''} />
250
+ </Suspense>
251
+ </div>
252
+ </div>
253
+ );
254
+ }
@@ -1,10 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import Markdown from 'react-markdown';
4
+ import remarkGfm from 'remark-gfm';
4
5
 
5
6
  export default function MarkdownContent({ content }: { content: string }) {
6
7
  return (
7
8
  <Markdown
9
+ remarkPlugins={[remarkGfm]}
8
10
  components={{
9
11
  h1: ({ children }) => <h1 className="text-base font-bold text-[var(--text-primary)] mt-3 mb-1">{children}</h1>,
10
12
  h2: ({ children }) => <h2 className="text-sm font-bold text-[var(--text-primary)] mt-3 mb-1">{children}</h2>,
@@ -17,8 +19,10 @@ export default function MarkdownContent({ content }: { content: string }) {
17
19
  em: ({ children }) => <em className="italic text-[var(--text-secondary)]">{children}</em>,
18
20
  a: ({ href, children }) => <a href={href} className="text-[var(--accent)] hover:underline" target="_blank" rel="noopener">{children}</a>,
19
21
  blockquote: ({ children }) => <blockquote className="border-l-2 border-[var(--accent)]/40 pl-3 my-1.5 text-[var(--text-secondary)] text-xs italic">{children}</blockquote>,
20
- code: ({ className, children }) => {
21
- const isBlock = className?.includes('language-');
22
+ code: ({ className, children, node, ...props }) => {
23
+ // Block code: has language class OR parent is <pre> (checked via node)
24
+ const isBlock = !!className?.includes('language-');
25
+
22
26
  if (isBlock) {
23
27
  const lang = className?.replace('language-', '') || '';
24
28
  return (
@@ -29,7 +33,7 @@ export default function MarkdownContent({ content }: { content: string }) {
29
33
  </div>
30
34
  )}
31
35
  <pre className="p-3 bg-[var(--bg-tertiary)] overflow-x-auto max-w-full">
32
- <code className="text-[11px] font-mono text-[var(--text-primary)] break-all">{children}</code>
36
+ <code className="text-[12px] font-mono text-[var(--text-primary)] whitespace-pre leading-[1.4]" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace' }}>{children}</code>
33
37
  </pre>
34
38
  </div>
35
39
  );
@@ -40,15 +44,27 @@ export default function MarkdownContent({ content }: { content: string }) {
40
44
  </code>
41
45
  );
42
46
  },
43
- pre: ({ children }) => <>{children}</>,
47
+ pre: ({ children, ...props }) => {
48
+ // If code child already rendered as block (has language-), just pass through
49
+ // Otherwise wrap plain code blocks (no language) with proper styling
50
+ const child = (children as any)?.props;
51
+ if (child?.className?.includes('language-')) return <>{children}</>;
52
+ return (
53
+ <div className="my-2 rounded border border-[var(--border)] overflow-hidden max-w-full">
54
+ <pre className="p-3 bg-[var(--bg-tertiary)] overflow-x-auto max-w-full">
55
+ <code className="text-[12px] font-mono text-[var(--text-primary)] whitespace-pre leading-[1.4]" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace' }}>{child?.children || children}</code>
56
+ </pre>
57
+ </div>
58
+ );
59
+ },
44
60
  hr: () => <hr className="my-3 border-[var(--border)]" />,
45
61
  table: ({ children }) => (
46
- <div className="my-2 overflow-x-auto">
47
- <table className="text-xs border-collapse w-full">{children}</table>
62
+ <div className="my-3 overflow-x-auto">
63
+ <table className="text-xs border-collapse">{children}</table>
48
64
  </div>
49
65
  ),
50
- th: ({ children }) => <th className="border border-[var(--border)] px-2 py-1 bg-[var(--bg-tertiary)] text-left font-semibold text-[11px]">{children}</th>,
51
- td: ({ children }) => <td className="border border-[var(--border)] px-2 py-1 text-[11px]">{children}</td>,
66
+ th: ({ children }) => <th className="border border-[var(--border)] px-3 py-1.5 bg-[var(--bg-tertiary)] text-left font-semibold text-[11px] whitespace-nowrap">{children}</th>,
67
+ td: ({ children }) => <td className="border border-[var(--border)] px-3 py-1.5 text-[11px]">{children}</td>,
52
68
  }}
53
69
  >
54
70
  {content}
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
4
4
 
5
5
  interface Settings {
6
6
  projectRoots: string[];
7
+ docRoots: string[];
7
8
  claudePath: string;
8
9
  telegramBotToken: string;
9
10
  telegramChatId: string;
@@ -24,6 +25,7 @@ interface TunnelStatus {
24
25
  export default function SettingsModal({ onClose }: { onClose: () => void }) {
25
26
  const [settings, setSettings] = useState<Settings>({
26
27
  projectRoots: [],
28
+ docRoots: [],
27
29
  claudePath: '',
28
30
  telegramBotToken: '',
29
31
  telegramChatId: '',
@@ -33,6 +35,7 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
33
35
  telegramTunnelPassword: '',
34
36
  });
35
37
  const [newRoot, setNewRoot] = useState('');
38
+ const [newDocRoot, setNewDocRoot] = useState('');
36
39
  const [saved, setSaved] = useState(false);
37
40
  const [tunnel, setTunnel] = useState<TunnelStatus>({
38
41
  status: 'stopped', url: null, error: null, installed: false, log: [],
@@ -128,6 +131,58 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
128
131
  </div>
129
132
  </div>
130
133
 
134
+ {/* Document Roots */}
135
+ <div className="space-y-2">
136
+ <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
137
+ Document Directories
138
+ </label>
139
+ <p className="text-[10px] text-[var(--text-secondary)]">
140
+ Markdown document directories (e.g. Obsidian vaults). Shown in the Docs tab.
141
+ </p>
142
+
143
+ {(settings.docRoots || []).map(root => (
144
+ <div key={root} className="flex items-center gap-2">
145
+ <span className="flex-1 text-xs px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded font-mono truncate">
146
+ {root}
147
+ </span>
148
+ <button
149
+ onClick={() => setSettings({ ...settings, docRoots: settings.docRoots.filter(r => r !== root) })}
150
+ className="text-[10px] px-2 py-1 text-[var(--red)] hover:bg-[var(--red)] hover:text-white rounded transition-colors"
151
+ >
152
+ Remove
153
+ </button>
154
+ </div>
155
+ ))}
156
+
157
+ <div className="flex gap-2">
158
+ <input
159
+ value={newDocRoot}
160
+ onChange={e => setNewDocRoot(e.target.value)}
161
+ onKeyDown={e => {
162
+ if (e.key === 'Enter' && newDocRoot.trim()) {
163
+ if (!settings.docRoots.includes(newDocRoot.trim())) {
164
+ setSettings({ ...settings, docRoots: [...(settings.docRoots || []), newDocRoot.trim()] });
165
+ }
166
+ setNewDocRoot('');
167
+ }
168
+ }}
169
+ placeholder="/Users/you/obsidian-vault"
170
+ className="flex-1 px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
171
+ />
172
+ <button
173
+ onClick={() => {
174
+ if (newDocRoot.trim() && !settings.docRoots.includes(newDocRoot.trim())) {
175
+ setSettings({ ...settings, docRoots: [...(settings.docRoots || []), newDocRoot.trim()] });
176
+ }
177
+ setNewDocRoot('');
178
+ }}
179
+ className="text-[10px] px-3 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
180
+ >
181
+ Add
182
+ </button>
183
+ </div>
184
+ </div>
185
+
131
186
  {/* Claude Path */}
132
187
  <div className="space-y-2">
133
188
  <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
@@ -11,6 +11,11 @@ export interface WebTerminalHandle {
11
11
  openSessionInTerminal: (sessionId: string, projectPath: string) => void;
12
12
  }
13
13
 
14
+ export interface WebTerminalProps {
15
+ onActiveSession?: (sessionName: string | null) => void;
16
+ onCodeOpenChange?: (open: boolean) => void;
17
+ }
18
+
14
19
  // ─── Types ───────────────────────────────────────────────────
15
20
 
16
21
  interface TmuxSession {
@@ -156,7 +161,7 @@ let globalDragging = false;
156
161
 
157
162
  // ─── Main component ─────────────────────────────────────────
158
163
 
159
- const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, ref) {
164
+ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange }, ref) {
160
165
  const [tabs, setTabs] = useState<TabState[]>(() => {
161
166
  const tree = makeTerminal();
162
167
  return [{ id: nextId++, label: 'Terminal 1', tree, ratios: {}, activeId: firstTerminalId(tree) }];
@@ -172,6 +177,7 @@ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, r
172
177
  const sessionLabelsRef = useRef<Record<string, string>>({});
173
178
  const dragTabRef = useRef<number | null>(null);
174
179
  const [refreshKeys, setRefreshKeys] = useState<Record<number, number>>({});
180
+ const [tabCodeOpen, setTabCodeOpen] = useState<Record<number, boolean>>({});
175
181
 
176
182
  // Restore shared state from server after mount
177
183
  useEffect(() => {
@@ -208,6 +214,18 @@ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, r
208
214
 
209
215
  const activeTab = tabs.find(t => t.id === activeTabId) || tabs[0];
210
216
 
217
+ // Notify parent when active terminal session or code state changes
218
+ useEffect(() => {
219
+ if (!activeTab) return;
220
+ if (onActiveSession) {
221
+ const sessions = collectSessionNames(activeTab.tree);
222
+ onActiveSession(sessions[0] || null);
223
+ }
224
+ if (onCodeOpenChange) {
225
+ onCodeOpenChange(tabCodeOpen[activeTab.id] ?? false);
226
+ }
227
+ }, [activeTabId, activeTab, onActiveSession, onCodeOpenChange, tabCodeOpen]);
228
+
211
229
  // ─── Imperative handle for parent ─────────────────────
212
230
 
213
231
  useImperativeHandle(ref, () => ({
@@ -501,11 +519,25 @@ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, r
501
519
  if (!activeTab) return;
502
520
  setRefreshKeys(prev => ({ ...prev, [activeTab.activeId]: (prev[activeTab.activeId] || 0) + 1 }));
503
521
  }}
504
- className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded"
522
+ className="text-[11px] px-3 py-1 text-black bg-yellow-400 hover:bg-yellow-300 rounded font-bold"
505
523
  title="Refresh terminal (fix garbled display)"
506
524
  >
507
525
  Refresh
508
526
  </button>
527
+ {onCodeOpenChange && activeTab && (
528
+ <button
529
+ onClick={() => {
530
+ const current = tabCodeOpen[activeTab.id] ?? false;
531
+ const next = !current;
532
+ setTabCodeOpen(prev => ({ ...prev, [activeTab.id]: next }));
533
+ onCodeOpenChange(next);
534
+ }}
535
+ className={`text-[11px] px-3 py-1 rounded font-bold ${(tabCodeOpen[activeTab.id] ?? false) ? 'text-white bg-red-500 hover:bg-red-400' : 'text-red-400 border border-red-500 hover:bg-red-500 hover:text-white'}`}
536
+ title={(tabCodeOpen[activeTab.id] ?? false) ? 'Hide code panel' : 'Show code panel'}
537
+ >
538
+ Code
539
+ </button>
540
+ )}
509
541
  {activeTab && countTerminals(activeTab.tree) > 1 && (
510
542
  <button onClick={onClosePane} className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-red-400 hover:bg-[#2a2a4a] rounded">
511
543
  Close Pane
@@ -918,11 +950,11 @@ const MemoTerminalPane = memo(function TerminalPane({
918
950
  };
919
951
 
920
952
  ws.onmessage = (event) => {
921
- if (disposed) return;
953
+ if (disposed || !initDone) return;
922
954
  try {
923
955
  const msg = JSON.parse(event.data);
924
956
  if (msg.type === 'output') {
925
- term.write(msg.data);
957
+ try { term.write(msg.data); } catch {};
926
958
  } else if (msg.type === 'connected') {
927
959
  connectedSession = msg.sessionName;
928
960
  createRetries = 0;
@@ -989,8 +1021,8 @@ const MemoTerminalPane = memo(function TerminalPane({
989
1021
  if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
990
1022
  // Skip if container is inside a hidden tab (prevents wrong resize)
991
1023
  if (el.closest('.hidden')) return;
992
- // Skip unreasonably small sizes (layout transient)
993
- if (el.offsetWidth < 50 || el.offsetHeight < 30) return;
1024
+ // Skip unreasonably small sizes xterm crashes if rows/cols go below 2
1025
+ if (el.offsetWidth < 100 || el.offsetHeight < 50) return;
994
1026
  const w = el.offsetWidth;
995
1027
  const h = el.offsetHeight;
996
1028
  if (w === lastW && h === lastH) return;
@@ -998,6 +1030,8 @@ const MemoTerminalPane = memo(function TerminalPane({
998
1030
  lastH = h;
999
1031
  try {
1000
1032
  fit.fit();
1033
+ // Skip if xterm computed unreasonable dimensions
1034
+ if (term.cols < 2 || term.rows < 2) return;
1001
1035
  if (ws?.readyState === WebSocket.OPEN) {
1002
1036
  ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
1003
1037
  }