@aion0/forge 0.1.10 → 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, () => ({
@@ -501,11 +514,20 @@ const WebTerminal = forwardRef<WebTerminalHandle>(function WebTerminal(_props, r
501
514
  if (!activeTab) return;
502
515
  setRefreshKeys(prev => ({ ...prev, [activeTab.activeId]: (prev[activeTab.activeId] || 0) + 1 }));
503
516
  }}
504
- 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"
505
518
  title="Refresh terminal (fix garbled display)"
506
519
  >
507
520
  Refresh
508
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
+ )}
509
531
  {activeTab && countTerminals(activeTab.tree) > 1 && (
510
532
  <button onClick={onClosePane} className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-red-400 hover:bg-[#2a2a4a] rounded">
511
533
  Close Pane
@@ -918,11 +940,11 @@ const MemoTerminalPane = memo(function TerminalPane({
918
940
  };
919
941
 
920
942
  ws.onmessage = (event) => {
921
- if (disposed) return;
943
+ if (disposed || !initDone) return;
922
944
  try {
923
945
  const msg = JSON.parse(event.data);
924
946
  if (msg.type === 'output') {
925
- term.write(msg.data);
947
+ try { term.write(msg.data); } catch {};
926
948
  } else if (msg.type === 'connected') {
927
949
  connectedSession = msg.sessionName;
928
950
  createRetries = 0;
@@ -989,8 +1011,8 @@ const MemoTerminalPane = memo(function TerminalPane({
989
1011
  if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
990
1012
  // Skip if container is inside a hidden tab (prevents wrong resize)
991
1013
  if (el.closest('.hidden')) return;
992
- // Skip unreasonably small sizes (layout transient)
993
- 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;
994
1016
  const w = el.offsetWidth;
995
1017
  const h = el.offsetHeight;
996
1018
  if (w === lastW && h === lastH) return;
@@ -998,6 +1020,8 @@ const MemoTerminalPane = memo(function TerminalPane({
998
1020
  lastH = h;
999
1021
  try {
1000
1022
  fit.fit();
1023
+ // Skip if xterm computed unreasonable dimensions
1024
+ if (term.cols < 2 || term.rows < 2) return;
1001
1025
  if (ws?.readyState === WebSocket.OPEN) {
1002
1026
  ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
1003
1027
  }
package/lib/settings.ts CHANGED
@@ -7,6 +7,7 @@ const SETTINGS_FILE = join(homedir(), '.forge', 'settings.yaml');
7
7
 
8
8
  export interface Settings {
9
9
  projectRoots: string[]; // Multiple project directories
10
+ docRoots: string[]; // Markdown document directories (e.g. Obsidian vaults)
10
11
  claudePath: string; // Path to claude binary
11
12
  telegramBotToken: string; // Telegram Bot API token
12
13
  telegramChatId: string; // Telegram chat ID to send notifications to
@@ -18,6 +19,7 @@ export interface Settings {
18
19
 
19
20
  const defaults: Settings = {
20
21
  projectRoots: [],
22
+ docRoots: [],
21
23
  claudePath: '',
22
24
  telegramBotToken: '',
23
25
  telegramChatId: '',