@aion0/forge 0.3.4 → 0.3.6

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,169 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, lazy, Suspense } from 'react';
4
+
5
+ interface DocItem {
6
+ name: string;
7
+ title: string;
8
+ }
9
+
10
+ const HelpTerminal = lazy(() => import('./HelpTerminal'));
11
+
12
+ export default function HelpDialog({ onClose }: { onClose: () => void }) {
13
+ const [docs, setDocs] = useState<DocItem[]>([]);
14
+ const [agent, setAgent] = useState<{ name: string } | null | undefined>(undefined); // undefined = loading
15
+ const [viewDoc, setViewDoc] = useState<string | null>(null);
16
+ const [docContent, setDocContent] = useState('');
17
+ const [search, setSearch] = useState('');
18
+ const [tab, setTab] = useState<'docs' | 'chat'>('docs');
19
+ const [position, setPosition] = useState({ x: Math.max(0, window.innerWidth - 520), y: 50 });
20
+ const [size, setSize] = useState({ w: 500, h: 560 });
21
+ const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
22
+ const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
23
+
24
+ useEffect(() => {
25
+ fetch('/api/help?action=status').then(r => r.json())
26
+ .then(data => setAgent(data.agent || null)).catch(() => setAgent(null));
27
+ fetch('/api/help?action=docs').then(r => r.json())
28
+ .then(data => setDocs(data.docs || [])).catch(() => {});
29
+ }, []);
30
+
31
+ const loadDoc = async (name: string) => {
32
+ setViewDoc(name);
33
+ try {
34
+ const res = await fetch(`/api/help?action=doc&name=${encodeURIComponent(name)}`);
35
+ const data = await res.json();
36
+ setDocContent(data.content || '');
37
+ } catch { setDocContent('Failed to load'); }
38
+ };
39
+
40
+ // Drag
41
+ const onDragStart = (e: React.MouseEvent) => {
42
+ e.preventDefault();
43
+ dragRef.current = { startX: e.clientX, startY: e.clientY, origX: position.x, origY: position.y };
44
+ const onMove = (ev: MouseEvent) => {
45
+ if (!dragRef.current) return;
46
+ setPosition({
47
+ x: Math.max(0, dragRef.current.origX + ev.clientX - dragRef.current.startX),
48
+ y: Math.max(0, dragRef.current.origY + ev.clientY - dragRef.current.startY),
49
+ });
50
+ };
51
+ const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
52
+ window.addEventListener('mousemove', onMove);
53
+ window.addEventListener('mouseup', onUp);
54
+ };
55
+
56
+ // Resize
57
+ const onResizeStart = (e: React.MouseEvent) => {
58
+ e.preventDefault();
59
+ e.stopPropagation();
60
+ resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h };
61
+ const onMove = (ev: MouseEvent) => {
62
+ if (!resizeRef.current) return;
63
+ setSize({
64
+ w: Math.max(350, resizeRef.current.origW + ev.clientX - resizeRef.current.startX),
65
+ h: Math.max(300, resizeRef.current.origH + ev.clientY - resizeRef.current.startY),
66
+ });
67
+ };
68
+ const onUp = () => { resizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
69
+ window.addEventListener('mousemove', onMove);
70
+ window.addEventListener('mouseup', onUp);
71
+ };
72
+
73
+ const filtered = search ? docs.filter(d => d.title.toLowerCase().includes(search.toLowerCase())) : docs;
74
+
75
+ return (
76
+ <div
77
+ className="fixed z-50 bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-2xl flex flex-col overflow-hidden"
78
+ style={{ left: position.x, top: position.y, width: size.w, height: size.h }}
79
+ >
80
+ {/* Title bar */}
81
+ <div
82
+ className="flex items-center gap-2 px-3 py-2 bg-[var(--bg-tertiary)] border-b border-[var(--border)] cursor-move shrink-0 select-none"
83
+ onMouseDown={onDragStart}
84
+ >
85
+ <span className="text-[11px] font-semibold text-[var(--text-primary)]">Forge Help</span>
86
+ <div className="ml-auto flex items-center gap-1">
87
+ <button
88
+ onClick={() => { setTab('docs'); setViewDoc(null); }}
89
+ className={`text-[9px] px-2 py-0.5 rounded ${tab === 'docs' ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'text-[var(--text-secondary)]'}`}
90
+ >Docs</button>
91
+ {agent && (
92
+ <button
93
+ onClick={() => setTab('chat')}
94
+ className={`text-[9px] px-2 py-0.5 rounded ${tab === 'chat' ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'text-[var(--text-secondary)]'}`}
95
+ >AI Chat</button>
96
+ )}
97
+ <button onClick={onClose} className="text-[var(--text-secondary)] hover:text-[var(--red)] ml-1 text-sm leading-none">✕</button>
98
+ </div>
99
+ </div>
100
+
101
+ {tab === 'chat' ? (
102
+ /* Embedded terminal */
103
+ <div className="flex-1 min-h-0">
104
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading terminal...</div>}>
105
+ <HelpTerminal />
106
+ </Suspense>
107
+ </div>
108
+ ) : viewDoc ? (
109
+ /* Doc view */
110
+ <>
111
+ <div className="px-3 py-1.5 border-b border-[var(--border)] flex items-center gap-2 shrink-0">
112
+ <button onClick={() => setViewDoc(null)} className="text-[10px] text-[var(--accent)]">← Back</button>
113
+ <span className="text-[10px] text-[var(--text-primary)] font-semibold truncate">
114
+ {docs.find(d => d.name === viewDoc)?.title || viewDoc}
115
+ </span>
116
+ </div>
117
+ <div className="flex-1 overflow-y-auto p-3">
118
+ <pre className="text-[11px] text-[var(--text-primary)] whitespace-pre-wrap break-words font-mono leading-relaxed">
119
+ {docContent}
120
+ </pre>
121
+ </div>
122
+ </>
123
+ ) : (
124
+ /* Doc list */
125
+ <>
126
+ <div className="px-3 py-2 border-b border-[var(--border)] shrink-0">
127
+ <input
128
+ type="text"
129
+ value={search}
130
+ onChange={e => setSearch(e.target.value)}
131
+ placeholder="Search help topics..."
132
+ className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
133
+ autoFocus
134
+ />
135
+ </div>
136
+ {!agent && agent !== undefined && (
137
+ <div className="px-3 py-2 bg-[var(--yellow)]/10 border-b border-[var(--border)] shrink-0">
138
+ <p className="text-[9px] text-[var(--text-secondary)]">
139
+ Install Claude Code for AI help: <code className="text-[var(--accent)]">npm i -g @anthropic-ai/claude-code</code>
140
+ </p>
141
+ </div>
142
+ )}
143
+ <div className="flex-1 overflow-y-auto">
144
+ {filtered.map(doc => (
145
+ <button
146
+ key={doc.name}
147
+ onClick={() => loadDoc(doc.name)}
148
+ className="w-full text-left px-3 py-2.5 border-b border-[var(--border)]/30 hover:bg-[var(--bg-tertiary)] text-[11px] text-[var(--text-primary)] capitalize"
149
+ >
150
+ {doc.title}
151
+ </button>
152
+ ))}
153
+ </div>
154
+ <div className="px-3 py-2 border-t border-[var(--border)] shrink-0">
155
+ <a href="https://github.com/aiwatching/forge" target="_blank" rel="noopener noreferrer"
156
+ className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--accent)]">GitHub →</a>
157
+ </div>
158
+ </>
159
+ )}
160
+
161
+ {/* Resize handle */}
162
+ <div
163
+ onMouseDown={onResizeStart}
164
+ className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize"
165
+ style={{ background: 'linear-gradient(135deg, transparent 50%, var(--border) 50%)' }}
166
+ />
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } 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-forge-help';
9
+
10
+ function getWsUrl() {
11
+ if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '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
+ const webPort = parseInt(window.location.port) || 3000;
18
+ return `${wsProtocol}//${wsHost}:${webPort + 1}`;
19
+ }
20
+
21
+ export default function HelpTerminal() {
22
+ const containerRef = useRef<HTMLDivElement>(null);
23
+ const [connected, setConnected] = useState(false);
24
+
25
+ useEffect(() => {
26
+ if (!containerRef.current) return;
27
+
28
+ let disposed = false;
29
+ const cs = getComputedStyle(document.documentElement);
30
+ const tv = (name: string) => cs.getPropertyValue(name).trim();
31
+ const term = new Terminal({
32
+ cursorBlink: true,
33
+ fontSize: 12,
34
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
35
+ scrollback: 3000,
36
+ logger: { trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
37
+ theme: {
38
+ background: tv('--term-bg') || '#1a1a2e',
39
+ foreground: tv('--term-fg') || '#e0e0e0',
40
+ cursor: tv('--term-cursor') || '#7c5bf0',
41
+ selectionBackground: (tv('--term-cursor') || '#7c5bf0') + '44',
42
+ },
43
+ });
44
+ const fit = new FitAddon();
45
+ term.loadAddon(fit);
46
+ term.open(containerRef.current);
47
+ try { fit.fit(); } catch {}
48
+
49
+ const wsUrl = getWsUrl();
50
+ let ws: WebSocket | null = null;
51
+ let reconnectTimer = 0;
52
+ let isNewSession = false;
53
+
54
+ function connect() {
55
+ if (disposed) return;
56
+ const socket = new WebSocket(wsUrl);
57
+ ws = socket;
58
+
59
+ socket.onopen = () => {
60
+ if (disposed) { socket.close(); return; }
61
+ socket.send(JSON.stringify({ type: 'attach', sessionName: SESSION_NAME, cols: term.cols, rows: term.rows }));
62
+ };
63
+
64
+ socket.onmessage = (event) => {
65
+ if (disposed) return;
66
+ try {
67
+ const msg = JSON.parse(event.data);
68
+ if (msg.type === 'output') {
69
+ try { term.write(msg.data); } catch {}
70
+ } else if (msg.type === 'connected') {
71
+ setConnected(true);
72
+ if (isNewSession) {
73
+ isNewSession = false;
74
+ setTimeout(() => {
75
+ if (socket.readyState === WebSocket.OPEN) {
76
+ socket.send(JSON.stringify({ type: 'input', data: `cd ~/.forge/help 2>/dev/null && claude\n` }));
77
+ }
78
+ }, 300);
79
+ }
80
+ } else if (msg.type === 'error') {
81
+ isNewSession = true;
82
+ if (socket.readyState === WebSocket.OPEN) {
83
+ socket.send(JSON.stringify({ type: 'create', cols: term.cols, rows: term.rows, sessionName: SESSION_NAME }));
84
+ }
85
+ }
86
+ } catch {}
87
+ };
88
+
89
+ socket.onclose = () => {
90
+ if (disposed) return;
91
+ setConnected(false);
92
+ reconnectTimer = window.setTimeout(connect, 3000);
93
+ };
94
+ socket.onerror = () => {};
95
+ }
96
+
97
+ connect();
98
+
99
+ term.onData((data) => {
100
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
101
+ });
102
+
103
+ const resizeObserver = new ResizeObserver(() => {
104
+ const el = containerRef.current;
105
+ if (!el || el.offsetWidth < 50 || el.offsetHeight < 30) return;
106
+ try {
107
+ fit.fit();
108
+ if (term.cols < 2 || term.rows < 2) return;
109
+ if (ws?.readyState === WebSocket.OPEN) {
110
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
111
+ }
112
+ } catch {}
113
+ });
114
+ resizeObserver.observe(containerRef.current);
115
+
116
+ return () => {
117
+ disposed = true;
118
+ clearTimeout(reconnectTimer);
119
+ ws?.close();
120
+ resizeObserver.disconnect();
121
+ term.dispose();
122
+ };
123
+ }, []);
124
+
125
+ return (
126
+ <div className="h-full flex flex-col">
127
+ <div ref={containerRef} className="flex-1" />
128
+ </div>
129
+ );
130
+ }