@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.
- package/CLAUDE.md +44 -0
- package/app/api/code/route.ts +160 -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 +474 -0
- package/components/Dashboard.tsx +34 -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 +32 -7
- package/lib/settings.ts +2 -0
- package/lib/telegram-bot.ts +469 -49
- 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,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(
|
|
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-[
|
|
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: '#
|
|
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
|
|
992
|
-
if (el.offsetWidth <
|
|
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
|
}
|