@aion0/forge 0.3.5 → 0.3.7
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 +15 -0
- package/app/api/help/route.ts +78 -0
- package/app/api/logs/route.ts +100 -0
- package/app/api/settings/route.ts +2 -0
- package/bin/forge-server.mjs +9 -0
- package/components/Dashboard.tsx +31 -1
- package/components/HelpDialog.tsx +169 -0
- package/components/HelpTerminal.tsx +130 -0
- package/components/LogViewer.tsx +194 -0
- package/components/ProjectManager.tsx +26 -8
- package/lib/auth.ts +2 -0
- package/lib/cloudflared.ts +71 -25
- package/lib/help-docs/00-overview.md +34 -0
- package/lib/help-docs/01-settings.md +37 -0
- package/lib/help-docs/02-telegram.md +41 -0
- package/lib/help-docs/03-tunnel.md +31 -0
- package/lib/help-docs/04-tasks.md +52 -0
- package/lib/help-docs/05-pipelines.md +73 -0
- package/lib/help-docs/06-skills.md +43 -0
- package/lib/help-docs/07-projects.md +39 -0
- package/lib/help-docs/08-rules.md +53 -0
- package/lib/help-docs/09-issue-autofix.md +51 -0
- package/lib/help-docs/10-troubleshooting.md +82 -0
- package/lib/init.ts +3 -0
- package/lib/logger.ts +73 -0
- package/lib/password.ts +1 -1
- package/lib/skills.ts +6 -0
- package/lib/task-manager.ts +2 -0
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -47,6 +47,21 @@ forge watch <id> # live stream task output
|
|
|
47
47
|
- npm package: `@aion0/forge`
|
|
48
48
|
- GitHub: `github.com/aiwatching/forge`
|
|
49
49
|
|
|
50
|
+
### Help Docs Rule
|
|
51
|
+
When adding or changing a feature, check if `lib/help-docs/` needs updating. Each file covers one module:
|
|
52
|
+
- `00-overview.md` — install, start, data paths
|
|
53
|
+
- `01-settings.md` — all settings fields
|
|
54
|
+
- `02-telegram.md` — bot setup and commands
|
|
55
|
+
- `03-tunnel.md` — remote access
|
|
56
|
+
- `04-tasks.md` — background tasks
|
|
57
|
+
- `05-pipelines.md` — DAG workflows
|
|
58
|
+
- `06-skills.md` — marketplace
|
|
59
|
+
- `07-projects.md` — project management
|
|
60
|
+
- `08-rules.md` — CLAUDE.md templates
|
|
61
|
+
- `09-issue-autofix.md` — GitHub issue scanner
|
|
62
|
+
- `10-troubleshooting.md` — common issues
|
|
63
|
+
If a feature change affects user-facing behavior, update the corresponding help doc in the same commit.
|
|
64
|
+
|
|
50
65
|
### Architecture
|
|
51
66
|
- `forge-server.mjs` starts: Next.js + terminal-standalone + telegram-standalone
|
|
52
67
|
- `pnpm dev` / `start.sh dev` starts: Next.js (init.ts spawns terminal + telegram)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { getConfigDir } from '@/lib/dirs';
|
|
5
|
+
import { loadSettings } from '@/lib/settings';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
const HELP_DIR = join(getConfigDir(), 'help');
|
|
9
|
+
const SOURCE_HELP_DIR = join(process.cwd(), 'lib', 'help-docs');
|
|
10
|
+
|
|
11
|
+
/** Ensure help docs are copied to ~/.forge/help/ */
|
|
12
|
+
function ensureHelpDocs() {
|
|
13
|
+
if (!existsSync(HELP_DIR)) mkdirSync(HELP_DIR, { recursive: true });
|
|
14
|
+
if (existsSync(SOURCE_HELP_DIR)) {
|
|
15
|
+
for (const file of readdirSync(SOURCE_HELP_DIR)) {
|
|
16
|
+
if (!file.endsWith('.md')) continue;
|
|
17
|
+
const src = join(SOURCE_HELP_DIR, file);
|
|
18
|
+
const dest = join(HELP_DIR, file);
|
|
19
|
+
// Always overwrite to keep docs up to date
|
|
20
|
+
writeFileSync(dest, readFileSync(src));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Check if any agent CLI is available */
|
|
26
|
+
function detectAgent(): { name: string; path: string } | null {
|
|
27
|
+
const settings = loadSettings();
|
|
28
|
+
if (settings.claudePath) {
|
|
29
|
+
try {
|
|
30
|
+
execSync(`"${settings.claudePath}" --version`, { timeout: 5000, stdio: 'pipe' });
|
|
31
|
+
return { name: 'claude', path: settings.claudePath };
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
for (const agent of ['claude', 'codex', 'aider']) {
|
|
35
|
+
try {
|
|
36
|
+
const path = execSync(`which ${agent}`, { encoding: 'utf-8', timeout: 3000, stdio: 'pipe' }).trim();
|
|
37
|
+
if (path) return { name: agent, path };
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// GET /api/help
|
|
44
|
+
export async function GET(req: Request) {
|
|
45
|
+
const { searchParams } = new URL(req.url);
|
|
46
|
+
const action = searchParams.get('action') || 'status';
|
|
47
|
+
|
|
48
|
+
if (action === 'status') {
|
|
49
|
+
const agent = detectAgent();
|
|
50
|
+
ensureHelpDocs();
|
|
51
|
+
const docs = existsSync(HELP_DIR)
|
|
52
|
+
? readdirSync(HELP_DIR).filter(f => f.endsWith('.md')).sort()
|
|
53
|
+
: [];
|
|
54
|
+
return NextResponse.json({ agent, docsCount: docs.length, helpDir: HELP_DIR });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (action === 'docs') {
|
|
58
|
+
ensureHelpDocs();
|
|
59
|
+
const docs = existsSync(HELP_DIR)
|
|
60
|
+
? readdirSync(HELP_DIR).filter(f => f.endsWith('.md')).sort().map(f => ({
|
|
61
|
+
name: f,
|
|
62
|
+
title: f.replace(/^\d+-/, '').replace(/\.md$/, '').replace(/-/g, ' '),
|
|
63
|
+
}))
|
|
64
|
+
: [];
|
|
65
|
+
return NextResponse.json({ docs });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (action === 'doc') {
|
|
69
|
+
const name = searchParams.get('name');
|
|
70
|
+
if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
|
|
71
|
+
ensureHelpDocs();
|
|
72
|
+
const file = join(HELP_DIR, name);
|
|
73
|
+
if (!existsSync(file)) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
74
|
+
return NextResponse.json({ content: readFileSync(file, 'utf-8') });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
78
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { existsSync, statSync, openSync, readSync, closeSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { getDataDir } from '@/lib/dirs';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const LOG_FILE = join(getDataDir(), 'forge.log');
|
|
8
|
+
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB — auto-rotate above this
|
|
9
|
+
|
|
10
|
+
/** Read last N bytes from file (efficient tail) */
|
|
11
|
+
function tailFile(filePath: string, maxBytes: number): string {
|
|
12
|
+
const stat = statSync(filePath);
|
|
13
|
+
const size = stat.size;
|
|
14
|
+
const readSize = Math.min(size, maxBytes);
|
|
15
|
+
const buf = Buffer.alloc(readSize);
|
|
16
|
+
const fd = openSync(filePath, 'r');
|
|
17
|
+
readSync(fd, buf, 0, readSize, size - readSize);
|
|
18
|
+
closeSync(fd);
|
|
19
|
+
// Skip partial first line
|
|
20
|
+
const str = buf.toString('utf-8');
|
|
21
|
+
const firstNewline = str.indexOf('\n');
|
|
22
|
+
return firstNewline > 0 ? str.slice(firstNewline + 1) : str;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Rotate log if too large: forge.log → forge.log.old, start fresh */
|
|
26
|
+
function rotateIfNeeded() {
|
|
27
|
+
if (!existsSync(LOG_FILE)) return;
|
|
28
|
+
const stat = statSync(LOG_FILE);
|
|
29
|
+
if (stat.size > MAX_LOG_SIZE) {
|
|
30
|
+
const oldFile = LOG_FILE + '.old';
|
|
31
|
+
try { renameSync(LOG_FILE, oldFile); } catch {}
|
|
32
|
+
writeFileSync(LOG_FILE, `[forge] Log rotated at ${new Date().toISOString()} (previous: ${(stat.size / 1024 / 1024).toFixed(1)}MB)\n`, 'utf-8');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// GET /api/logs?lines=200&search=keyword
|
|
37
|
+
export async function GET(req: Request) {
|
|
38
|
+
const { searchParams } = new URL(req.url);
|
|
39
|
+
const lines = Math.min(parseInt(searchParams.get('lines') || '200'), 1000);
|
|
40
|
+
const search = searchParams.get('search') || '';
|
|
41
|
+
|
|
42
|
+
rotateIfNeeded();
|
|
43
|
+
|
|
44
|
+
if (!existsSync(LOG_FILE)) {
|
|
45
|
+
return NextResponse.json({ lines: [], total: 0, size: 0 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const stat = statSync(LOG_FILE);
|
|
50
|
+
// Read last 512KB max (enough for ~5000 lines)
|
|
51
|
+
const raw = tailFile(LOG_FILE, 512 * 1024);
|
|
52
|
+
let allLines = raw.split('\n').filter(Boolean);
|
|
53
|
+
|
|
54
|
+
if (search) {
|
|
55
|
+
allLines = allLines.filter(l => l.toLowerCase().includes(search.toLowerCase()));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = allLines.slice(-lines);
|
|
59
|
+
|
|
60
|
+
return NextResponse.json({
|
|
61
|
+
lines: result,
|
|
62
|
+
total: allLines.length,
|
|
63
|
+
size: stat.size,
|
|
64
|
+
file: LOG_FILE,
|
|
65
|
+
});
|
|
66
|
+
} catch (e: any) {
|
|
67
|
+
return NextResponse.json({ error: e.message }, { status: 500 });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// POST /api/logs — actions
|
|
72
|
+
export async function POST(req: Request) {
|
|
73
|
+
const body = await req.json();
|
|
74
|
+
|
|
75
|
+
if (body.action === 'clear') {
|
|
76
|
+
try {
|
|
77
|
+
writeFileSync(LOG_FILE, `[forge] Log cleared at ${new Date().toISOString()}\n`, 'utf-8');
|
|
78
|
+
return NextResponse.json({ ok: true });
|
|
79
|
+
} catch (e: any) {
|
|
80
|
+
return NextResponse.json({ error: e.message }, { status: 500 });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (body.action === 'processes') {
|
|
85
|
+
try {
|
|
86
|
+
const out = execSync("ps aux | grep -E 'next-server|telegram-standalone|terminal-standalone|cloudflared' | grep -v grep", {
|
|
87
|
+
encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
88
|
+
}).trim();
|
|
89
|
+
const processes = out.split('\n').filter(Boolean).map(line => {
|
|
90
|
+
const parts = line.trim().split(/\s+/);
|
|
91
|
+
return { pid: parts[1], cpu: parts[2], mem: parts[3], cmd: parts.slice(10).join(' ').slice(0, 80) };
|
|
92
|
+
});
|
|
93
|
+
return NextResponse.json({ processes });
|
|
94
|
+
} catch {
|
|
95
|
+
return NextResponse.json({ processes: [] });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
100
|
+
}
|
|
@@ -56,6 +56,8 @@ export async function PUT(req: Request) {
|
|
|
56
56
|
// Remove internal fields
|
|
57
57
|
delete (updated as any)._secretStatus;
|
|
58
58
|
|
|
59
|
+
const changed = Object.keys(updated).filter(k => JSON.stringify((updated as any)[k]) !== JSON.stringify((settings as any)[k]) && !['telegramTunnelPassword', 'telegramBotToken'].includes(k));
|
|
60
|
+
if (changed.length > 0) console.log(`[settings] Updated: ${changed.join(', ')}`);
|
|
59
61
|
saveSettings(updated);
|
|
60
62
|
restartTelegramBot();
|
|
61
63
|
return NextResponse.json({ ok: true });
|
package/bin/forge-server.mjs
CHANGED
|
@@ -72,6 +72,15 @@ const LOG_FILE = join(DATA_DIR, 'forge.log');
|
|
|
72
72
|
|
|
73
73
|
process.chdir(ROOT);
|
|
74
74
|
|
|
75
|
+
// ── Add timestamps to all console output ──
|
|
76
|
+
const origLog = console.log;
|
|
77
|
+
const origError = console.error;
|
|
78
|
+
const origWarn = console.warn;
|
|
79
|
+
const ts = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
80
|
+
console.log = (...args) => origLog(`[${ts()}]`, ...args);
|
|
81
|
+
console.error = (...args) => origError(`[${ts()}]`, ...args);
|
|
82
|
+
console.warn = (...args) => origWarn(`[${ts()}]`, ...args);
|
|
83
|
+
|
|
75
84
|
// ── Migrate old layout (~/.forge/*) to new (~/.forge/data/*) ──
|
|
76
85
|
if (!getArg('--dir')) {
|
|
77
86
|
const oldSettings = join(homedir(), '.forge', 'settings.yaml');
|
package/components/Dashboard.tsx
CHANGED
|
@@ -18,6 +18,8 @@ const CodeViewer = lazy(() => import('./CodeViewer'));
|
|
|
18
18
|
const ProjectManager = lazy(() => import('./ProjectManager'));
|
|
19
19
|
const PreviewPanel = lazy(() => import('./PreviewPanel'));
|
|
20
20
|
const PipelineView = lazy(() => import('./PipelineView'));
|
|
21
|
+
const HelpDialog = lazy(() => import('./HelpDialog'));
|
|
22
|
+
const LogViewer = lazy(() => import('./LogViewer'));
|
|
21
23
|
const SkillsPanel = lazy(() => import('./SkillsPanel'));
|
|
22
24
|
|
|
23
25
|
interface UsageSummary {
|
|
@@ -41,12 +43,13 @@ interface ProjectInfo {
|
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export default function Dashboard({ user }: { user: any }) {
|
|
44
|
-
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview' | 'pipelines' | 'skills'>('terminal');
|
|
46
|
+
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'preview' | 'pipelines' | 'skills' | 'logs'>('terminal');
|
|
45
47
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
46
48
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
47
49
|
const [showNewTask, setShowNewTask] = useState(false);
|
|
48
50
|
const [showSettings, setShowSettings] = useState(false);
|
|
49
51
|
const [showMonitor, setShowMonitor] = useState(false);
|
|
52
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
50
53
|
const [usage, setUsage] = useState<UsageSummary[]>([]);
|
|
51
54
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
|
52
55
|
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
@@ -262,6 +265,15 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
262
265
|
+ New Task
|
|
263
266
|
</button>
|
|
264
267
|
)}
|
|
268
|
+
{/* Help */}
|
|
269
|
+
<button
|
|
270
|
+
onClick={() => setShowHelp(v => !v)}
|
|
271
|
+
className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
|
|
272
|
+
showHelp
|
|
273
|
+
? 'border-[var(--accent)] text-[var(--accent)]'
|
|
274
|
+
: 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
|
|
275
|
+
}`}
|
|
276
|
+
>?</button>
|
|
265
277
|
{/* Preview + Tunnel */}
|
|
266
278
|
<button
|
|
267
279
|
onClick={() => setViewMode('preview')}
|
|
@@ -416,6 +428,12 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
416
428
|
>
|
|
417
429
|
Settings
|
|
418
430
|
</button>
|
|
431
|
+
<button
|
|
432
|
+
onClick={() => { setViewMode('logs'); setShowUserMenu(false); }}
|
|
433
|
+
className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
|
434
|
+
>
|
|
435
|
+
Logs
|
|
436
|
+
</button>
|
|
419
437
|
<div className="border-t border-[var(--border)] my-1" />
|
|
420
438
|
<button
|
|
421
439
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
|
@@ -562,6 +580,13 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
562
580
|
</Suspense>
|
|
563
581
|
)}
|
|
564
582
|
|
|
583
|
+
{/* Logs */}
|
|
584
|
+
{viewMode === 'logs' && (
|
|
585
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
586
|
+
<LogViewer />
|
|
587
|
+
</Suspense>
|
|
588
|
+
)}
|
|
589
|
+
|
|
565
590
|
{/* Docs — always mounted to keep terminal session alive */}
|
|
566
591
|
<div className={viewMode === 'docs' ? 'flex-1 min-h-0 flex' : 'hidden'}>
|
|
567
592
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
@@ -597,6 +622,11 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
597
622
|
{showSettings && (
|
|
598
623
|
<SettingsModal onClose={() => { setShowSettings(false); fetchData(); refreshDisplayName(); }} />
|
|
599
624
|
)}
|
|
625
|
+
{showHelp && (
|
|
626
|
+
<Suspense fallback={null}>
|
|
627
|
+
<HelpDialog onClose={() => setShowHelp(false)} />
|
|
628
|
+
</Suspense>
|
|
629
|
+
)}
|
|
600
630
|
</div>
|
|
601
631
|
);
|
|
602
632
|
}
|
|
@@ -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
|
+
}
|