@aion0/forge 0.1.9 → 0.2.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.
@@ -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,12 @@ 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
+ codeOpen?: boolean;
17
+ onToggleCode?: () => void;
18
+ }
19
+
14
20
  // ─── Types ───────────────────────────────────────────────────
15
21
 
16
22
  interface TmuxSession {
@@ -156,7 +162,7 @@ let globalDragging = false;
156
162
 
157
163
  // ─── Main component ─────────────────────────────────────────
158
164
 
159
- const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, ref) {
165
+ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, codeOpen, onToggleCode }, ref) {
160
166
  const [tabs, setTabs] = useState<TabState[]>(() => {
161
167
  const tree = makeTerminal();
162
168
  return [{ id: nextId++, label: 'Terminal 1', tree, ratios: {}, activeId: firstTerminalId(tree) }];
@@ -208,6 +214,13 @@ 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 changes
218
+ useEffect(() => {
219
+ if (!onActiveSession || !activeTab) return;
220
+ const sessions = collectSessionNames(activeTab.tree);
221
+ onActiveSession(sessions[0] || null);
222
+ }, [activeTabId, activeTab, onActiveSession]);
223
+
211
224
  // ─── Imperative handle for parent ─────────────────────
212
225
 
213
226
  useImperativeHandle(ref, () => ({
@@ -478,6 +491,7 @@ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, r
478
491
 
479
492
  {/* Toolbar */}
480
493
  <div className="flex items-center gap-1 px-2 ml-auto">
494
+ <span className="text-[9px] text-gray-600 mr-2">Shift+drag to copy</span>
481
495
  <button onClick={() => onSplit('vertical')} className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded">
482
496
  Split Right
483
497
  </button>
@@ -500,11 +514,20 @@ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, r
500
514
  if (!activeTab) return;
501
515
  setRefreshKeys(prev => ({ ...prev, [activeTab.activeId]: (prev[activeTab.activeId] || 0) + 1 }));
502
516
  }}
503
- className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded"
517
+ className="text-[11px] px-3 py-1 text-black bg-yellow-400 hover:bg-yellow-300 rounded font-bold"
504
518
  title="Refresh terminal (fix garbled display)"
505
519
  >
506
520
  Refresh
507
521
  </button>
522
+ {onToggleCode && (
523
+ <button
524
+ onClick={onToggleCode}
525
+ className={`text-[11px] px-3 py-1 rounded font-bold ${codeOpen ? 'text-white bg-red-500 hover:bg-red-400' : 'text-red-400 border border-red-500 hover:bg-red-500 hover:text-white'}`}
526
+ title={codeOpen ? 'Hide code panel' : 'Show code panel'}
527
+ >
528
+ Code
529
+ </button>
530
+ )}
508
531
  {activeTab && countTerminals(activeTab.tree) > 1 && (
509
532
  <button onClick={onClosePane} className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-red-400 hover:bg-[#2a2a4a] rounded">
510
533
  Close Pane
@@ -828,7 +851,7 @@ const MemoTerminalPane = memo(function TerminalPane({
828
851
  background: '#1a1a2e',
829
852
  foreground: '#e0e0e0',
830
853
  cursor: '#7c5bf0',
831
- selectionBackground: '#7c5bf044',
854
+ selectionBackground: '#7c5bf066',
832
855
  black: '#1a1a2e',
833
856
  red: '#ff6b6b',
834
857
  green: '#69db7c',
@@ -917,11 +940,11 @@ const MemoTerminalPane = memo(function TerminalPane({
917
940
  };
918
941
 
919
942
  ws.onmessage = (event) => {
920
- if (disposed) return;
943
+ if (disposed || !initDone) return;
921
944
  try {
922
945
  const msg = JSON.parse(event.data);
923
946
  if (msg.type === 'output') {
924
- term.write(msg.data);
947
+ try { term.write(msg.data); } catch {};
925
948
  } else if (msg.type === 'connected') {
926
949
  connectedSession = msg.sessionName;
927
950
  createRetries = 0;
@@ -988,8 +1011,8 @@ const MemoTerminalPane = memo(function TerminalPane({
988
1011
  if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
989
1012
  // Skip if container is inside a hidden tab (prevents wrong resize)
990
1013
  if (el.closest('.hidden')) return;
991
- // Skip unreasonably small sizes (layout transient)
992
- if (el.offsetWidth < 50 || el.offsetHeight < 30) return;
1014
+ // Skip unreasonably small sizes xterm crashes if rows/cols go below 2
1015
+ if (el.offsetWidth < 100 || el.offsetHeight < 50) return;
993
1016
  const w = el.offsetWidth;
994
1017
  const h = el.offsetHeight;
995
1018
  if (w === lastW && h === lastH) return;
@@ -997,6 +1020,8 @@ const MemoTerminalPane = memo(function TerminalPane({
997
1020
  lastH = h;
998
1021
  try {
999
1022
  fit.fit();
1023
+ // Skip if xterm computed unreasonable dimensions
1024
+ if (term.cols < 2 || term.rows < 2) return;
1000
1025
  if (ws?.readyState === WebSocket.OPEN) {
1001
1026
  ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
1002
1027
  }