@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.
- package/CLAUDE.md +1 -0
- package/app/api/code/route.ts +194 -0
- package/app/api/docs/route.ts +85 -0
- package/app/api/docs/sessions/route.ts +54 -0
- package/app/api/terminal-cwd/route.ts +19 -0
- package/components/CodeViewer.tsx +510 -0
- package/components/Dashboard.tsx +33 -14
- package/components/DocTerminal.tsx +168 -0
- package/components/DocsViewer.tsx +254 -0
- package/components/MarkdownContent.tsx +24 -8
- package/components/SettingsModal.tsx +55 -0
- package/components/WebTerminal.tsx +40 -6
- package/lib/settings.ts +2 -0
- package/lib/telegram-bot.ts +403 -4
- package/lib/terminal-standalone.ts +35 -3
- package/next-env.d.ts +1 -1
- package/package.json +2 -1
|
@@ -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
|
-
|
|
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-[
|
|
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 }) =>
|
|
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-
|
|
47
|
-
<table className="text-xs border-collapse
|
|
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-
|
|
51
|
-
td: ({ children }) => <td className="border border-[var(--border)] px-
|
|
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(
|
|
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-[
|
|
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
|
|
993
|
-
if (el.offsetWidth <
|
|
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
|
}
|