@aion0/forge 0.4.7 → 0.4.9

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/RELEASE_NOTES.md CHANGED
@@ -1,16 +1,11 @@
1
- # Forge v0.4.7
1
+ # Forge v0.4.9
2
2
 
3
3
  Released: 2026-03-22
4
4
 
5
- ## Changes since v0.4.6
6
-
7
- ### Bug Fixes
8
- - fix: serial issue scanning, shell ANSI-C escaping, pipeline navigation
9
- - fix: prevent concurrent startTunnel calls from killing each other (#16)
5
+ ## Changes since v0.4.8
10
6
 
11
7
  ### Other
12
- - improve pipeline
13
- - fix(#17): normalize SQLite datetime strings to ISO 8601 UTC
8
+ - add browser window
14
9
 
15
10
 
16
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.6...v0.4.7
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.8...v0.4.9
@@ -26,11 +26,13 @@ const CODE_EXTS = new Set([
26
26
  '.xml', '.csv', '.lock',
27
27
  ]);
28
28
 
29
+ const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico', '.avif']);
30
+
29
31
  function isCodeFile(name: string): boolean {
30
32
  if (name.startsWith('.') && !name.startsWith('.env') && !name.startsWith('.git')) return false;
31
33
  const ext = extname(name);
32
34
  if (!ext) return !name.includes('.'); // files like Makefile, Dockerfile
33
- return CODE_EXTS.has(ext);
35
+ return CODE_EXTS.has(ext) || IMAGE_EXTS.has(ext);
34
36
  }
35
37
 
36
38
  function scanDir(dir: string, base: string, depth: number = 0): FileNode[] {
@@ -140,6 +142,17 @@ export async function GET(req: Request) {
140
142
  'class', 'jar', 'war',
141
143
  'pyc', 'pyo', 'wasm',
142
144
  ]);
145
+ // Image files — return base64 for preview
146
+ const IMAGE_PREVIEW = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
147
+ if (IMAGE_PREVIEW.has(ext)) {
148
+ if (size > 5_000_000) {
149
+ return NextResponse.json({ binary: true, fileType: ext, size, sizeLabel: `${sizeMB} MB`, message: 'Image too large to preview (> 5 MB)' });
150
+ }
151
+ const data = readFileSync(fullPath);
152
+ const mime = ext === 'svg' ? 'image/svg+xml' : `image/${ext === 'jpg' ? 'jpeg' : ext}`;
153
+ const base64 = `data:${mime};base64,${data.toString('base64')}`;
154
+ return NextResponse.json({ image: true, dataUrl: base64, fileType: ext, size, sizeLabel: sizeKB > 1024 ? `${sizeMB} MB` : `${sizeKB} KB` });
155
+ }
143
156
  if (BINARY_EXTS.has(ext)) {
144
157
  return NextResponse.json({ binary: true, fileType: ext, size, sizeLabel: sizeKB > 1024 ? `${sizeMB} MB` : `${sizeKB} KB` });
145
158
  }
@@ -105,9 +105,24 @@ export async function GET(req: Request) {
105
105
  try {
106
106
  const stat = statSync(fullPath);
107
107
  const size = stat.size;
108
+ const ext = extname(fullPath).replace('.', '').toLowerCase();
108
109
  const sizeKB = Math.round(size / 1024);
109
110
  const sizeMB = (size / (1024 * 1024)).toFixed(1);
110
111
 
112
+ // Binary file types
113
+ const BINARY_EXTS = new Set([
114
+ 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'avif',
115
+ 'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi',
116
+ 'zip', 'gz', 'tar', 'bz2', 'xz', '7z', 'rar',
117
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
118
+ 'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'a',
119
+ 'woff', 'woff2', 'ttf', 'eot', 'otf',
120
+ 'sqlite', 'db', 'sqlite3', 'class', 'jar', 'pyc', 'wasm',
121
+ ]);
122
+ if (BINARY_EXTS.has(ext)) {
123
+ return NextResponse.json({ binary: true, fileType: ext, size, sizeLabel: sizeKB > 1024 ? `${sizeMB} MB` : `${sizeKB} KB` });
124
+ }
125
+
111
126
  if (size > 2_000_000) {
112
127
  return NextResponse.json({ tooLarge: true, size, sizeLabel: `${sizeMB} MB`, message: 'File exceeds 2 MB limit' });
113
128
  }
@@ -115,7 +130,7 @@ export async function GET(req: Request) {
115
130
  return NextResponse.json({ large: true, size, sizeLabel: `${sizeKB} KB` });
116
131
  }
117
132
  const content = readFileSync(fullPath, 'utf-8');
118
- return NextResponse.json({ content });
133
+ return NextResponse.json({ content, language: ext });
119
134
  } catch {
120
135
  return NextResponse.json({ error: 'File not found' }, { status: 404 });
121
136
  }
@@ -189,6 +189,12 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
189
189
  const [editing, setEditing] = useState(false);
190
190
  const [editContent, setEditContent] = useState('');
191
191
  const [saving, setSaving] = useState(false);
192
+ const [browserOpen, setBrowserOpen] = useState(false);
193
+ const [browserUrl, setBrowserUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('forge-browser-url') || '' : '');
194
+ const [browserKey, setBrowserKey] = useState(0);
195
+ const [browserWidth, setBrowserWidth] = useState(640);
196
+ const browserDragRef = useRef<{ startX: number; startW: number } | null>(null);
197
+ const [browserDragging, setBrowserDragging] = useState(false);
192
198
 
193
199
  const handleCodeOpenChange = useCallback((open: boolean) => {
194
200
  setCodeOpen(open);
@@ -420,11 +426,75 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
420
426
  </div>
421
427
  )}
422
428
 
423
- {/* Terminal — top */}
424
- <div className={codeOpen ? 'shrink-0' : 'flex-1'} style={codeOpen ? { height: terminalHeight } : undefined}>
425
- <Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
426
- <WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} />
427
- </Suspense>
429
+ {/* Terminal + Browser main area */}
430
+ <div className={`flex ${codeOpen ? 'shrink-0' : 'flex-1'}`} style={codeOpen ? { height: terminalHeight } : undefined}>
431
+ <div className="flex-1 min-w-0">
432
+ <Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
433
+ <WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} browserOpen={browserOpen} onBrowserToggle={() => { setBrowserOpen(v => !v); if (!browserOpen) setBrowserKey(k => k + 1); }} />
434
+ </Suspense>
435
+ </div>
436
+ {browserOpen && (
437
+ <>
438
+ <div
439
+ onMouseDown={(e) => {
440
+ e.preventDefault();
441
+ browserDragRef.current = { startX: e.clientX, startW: browserWidth };
442
+ setBrowserDragging(true);
443
+ const onMove = (ev: MouseEvent) => {
444
+ if (!browserDragRef.current) return;
445
+ setBrowserWidth(Math.max(320, Math.min(1200, browserDragRef.current.startW - (ev.clientX - browserDragRef.current.startX))));
446
+ };
447
+ const onUp = () => {
448
+ browserDragRef.current = null;
449
+ setBrowserDragging(false);
450
+ window.removeEventListener('mousemove', onMove);
451
+ window.removeEventListener('mouseup', onUp);
452
+ };
453
+ window.addEventListener('mousemove', onMove);
454
+ window.addEventListener('mouseup', onUp);
455
+ }}
456
+ className="w-1 bg-[var(--border)] cursor-col-resize shrink-0 hover:bg-[var(--accent)]/50"
457
+ />
458
+ <div style={{ width: browserWidth }} className="shrink-0 flex flex-col">
459
+ <div className="flex items-center gap-1 px-2 py-1 border-b border-[var(--border)] bg-[var(--bg-tertiary)] shrink-0">
460
+ <input
461
+ type="text"
462
+ defaultValue={browserUrl}
463
+ placeholder="http://localhost:3000"
464
+ onKeyDown={e => {
465
+ if (e.key === 'Enter') {
466
+ const url = (e.target as HTMLInputElement).value.trim();
467
+ if (url) {
468
+ setBrowserUrl(url);
469
+ localStorage.setItem('forge-browser-url', url);
470
+ setBrowserKey(k => k + 1);
471
+ }
472
+ }
473
+ }}
474
+ className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-2 py-0.5 text-[10px] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] min-w-0"
475
+ />
476
+ <button onClick={() => setBrowserKey(k => k + 1)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Refresh">↻</button>
477
+ <button onClick={() => window.open(browserUrl, '_blank')} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] px-1" title="Open in new tab">↗</button>
478
+ <button onClick={() => setBrowserOpen(false)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--red)] px-1" title="Close">✕</button>
479
+ </div>
480
+ <div className="flex-1 relative">
481
+ {browserUrl ? (
482
+ <iframe
483
+ key={browserKey}
484
+ src={browserUrl}
485
+ className="absolute inset-0 w-full h-full border-0 bg-white"
486
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
487
+ />
488
+ ) : (
489
+ <div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
490
+ Enter a URL and press Enter
491
+ </div>
492
+ )}
493
+ {browserDragging && <div className="absolute inset-0 z-10" />}
494
+ </div>
495
+ </div>
496
+ </>
497
+ )}
428
498
  </div>
429
499
 
430
500
  {/* Resize handle */}
@@ -55,20 +55,18 @@ function TreeNode({ node, depth, selected, onSelect }: {
55
55
  }
56
56
 
57
57
  const isSelected = selected === node.path;
58
- const canOpen = node.fileType === 'md' || node.fileType === 'image';
59
58
 
60
59
  return (
61
60
  <button
62
- onClick={() => canOpen && onSelect(node.path)}
61
+ onClick={() => onSelect(node.path)}
63
62
  className={`w-full text-left flex items-center gap-1 px-1 py-0.5 rounded text-xs truncate ${
64
- !canOpen ? 'text-[var(--text-secondary)]/40 cursor-default'
65
- : isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]'
63
+ isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]'
66
64
  : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
67
65
  }`}
68
66
  style={{ paddingLeft: depth * 12 + 16 }}
69
67
  title={node.path}
70
68
  >
71
- {node.fileType === 'image' ? '🖼 ' : ''}{node.name.replace(/\.md$/, '')}
69
+ {node.fileType === 'image' ? '🖼 ' : ''}{node.name}
72
70
  </button>
73
71
  );
74
72
  }
@@ -84,6 +82,20 @@ function flattenTree(nodes: FileNode[]): FileNode[] {
84
82
  return result;
85
83
  }
86
84
 
85
+ const BINARY_EXTS = /\.(png|jpg|jpeg|gif|bmp|ico|webp|avif|mp3|mp4|wav|ogg|webm|mov|avi|zip|gz|tar|bz2|xz|7z|rar|pdf|doc|docx|xls|xlsx|ppt|pptx|exe|dll|so|dylib|bin|woff|woff2|ttf|eot|otf|sqlite|db|class|jar|pyc|wasm|o|a)$/i;
86
+
87
+ function filterTree(nodes: FileNode[]): FileNode[] {
88
+ return nodes.reduce<FileNode[]>((acc, node) => {
89
+ if (node.type === 'dir') {
90
+ const children = filterTree(node.children || []);
91
+ if (children.length > 0) acc.push({ ...node, children });
92
+ } else if (!BINARY_EXTS.test(node.name)) {
93
+ acc.push(node);
94
+ }
95
+ return acc;
96
+ }, []);
97
+ }
98
+
87
99
  // ─── Main Component ──────────────────────────────────────
88
100
 
89
101
  export default function DocsViewer() {
@@ -176,7 +188,9 @@ export default function DocsViewer() {
176
188
  setLoading(true);
177
189
  const res = await fetch(`/api/docs?root=${activeRoot}&file=${encodeURIComponent(path)}`);
178
190
  const data = await res.json();
179
- if (data.tooLarge) {
191
+ if (data.binary) {
192
+ setFileWarning(`${data.fileType?.toUpperCase() || 'Binary'} file — ${data.sizeLabel} — cannot be displayed`);
193
+ } else if (data.tooLarge) {
180
194
  setFileWarning(`File too large (${data.sizeLabel})`);
181
195
  } else {
182
196
  fileContent = data.content || null;
@@ -185,11 +199,15 @@ export default function DocsViewer() {
185
199
  }
186
200
  setContent(fileContent);
187
201
 
202
+ const MAX_TABS = 8;
188
203
  const newTab: DocTab = { id: genTabId(), filePath: path, fileName, rootIdx: activeRoot, isImage: isImg, content: fileContent };
189
204
  setDocTabs(prev => {
190
- // Double-check no duplicate
191
205
  if (prev.find(t => t.filePath === path)) return prev;
192
- const updated = [...prev, newTab];
206
+ let updated = [...prev, newTab];
207
+ // Auto-close oldest tabs if over limit
208
+ while (updated.length > MAX_TABS) {
209
+ updated = updated.slice(1);
210
+ }
193
211
  setActiveDocTabId(newTab.id);
194
212
  persistDocTabs(updated, newTab.id);
195
213
  return updated;
@@ -259,6 +277,7 @@ export default function DocsViewer() {
259
277
  }, [activeRoot, fetchTree]);
260
278
 
261
279
  const [fileWarning, setFileWarning] = useState<string | null>(null);
280
+ const [hideUnsupported, setHideUnsupported] = useState(true);
262
281
 
263
282
  // Fetch file content
264
283
  const isImageFile = (path: string) => /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|avif)$/i.test(path);
@@ -321,9 +340,9 @@ export default function DocsViewer() {
321
340
  }
322
341
 
323
342
  return (
324
- <div className="flex-1 flex flex-col min-h-0">
343
+ <div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
325
344
  {/* Doc content area */}
326
- <div className="flex-1 flex min-h-0">
345
+ <div className="flex-1 flex min-h-0 min-w-0 overflow-hidden">
327
346
  {/* Collapsible sidebar — file tree */}
328
347
  {sidebarOpen && (
329
348
  <aside style={{ width: sidebarWidth }} className="flex flex-col shrink-0 overflow-hidden">
@@ -343,9 +362,16 @@ export default function DocsViewer() {
343
362
  {/* Header with refresh */}
344
363
  <div className="px-3 py-1.5 border-b border-[var(--border)] flex items-center">
345
364
  <span className="text-[10px] text-[var(--text-secondary)] truncate">{roots[activeRoot] || 'Docs'}</span>
365
+ <button
366
+ onClick={() => setHideUnsupported(v => !v)}
367
+ className={`text-[9px] ml-auto shrink-0 px-1 rounded ${hideUnsupported ? 'text-[var(--accent)]' : 'text-[var(--text-secondary)]'} hover:text-[var(--text-primary)]`}
368
+ title={hideUnsupported ? 'Show all files' : 'Hide binary files'}
369
+ >
370
+ {hideUnsupported ? 'Docs' : 'All'}
371
+ </button>
346
372
  <button
347
373
  onClick={() => { fetchTree(activeRoot); if (selectedFile) openFile(selectedFile); }}
348
- className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto shrink-0"
374
+ className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
349
375
  title="Refresh files"
350
376
  >
351
377
 
@@ -384,7 +410,7 @@ export default function DocsViewer() {
384
410
  ))
385
411
  )
386
412
  ) : (
387
- tree.map(node => (
413
+ (hideUnsupported ? filterTree(tree) : tree).map(node => (
388
414
  <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFileInTab} />
389
415
  ))
390
416
  )}
@@ -493,7 +519,7 @@ export default function DocsViewer() {
493
519
  spellCheck={false}
494
520
  />
495
521
  </div>
496
- ) : (
522
+ ) : selectedFile.endsWith('.md') ? (
497
523
  <div className="flex-1 overflow-y-auto px-8 py-6">
498
524
  {loading ? (
499
525
  <div className="text-xs text-[var(--text-secondary)]">Loading...</div>
@@ -503,6 +529,17 @@ export default function DocsViewer() {
503
529
  </div>
504
530
  )}
505
531
  </div>
532
+ ) : (
533
+ <div className="flex-1 overflow-y-auto">
534
+ <pre className="p-4 text-[12px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
535
+ {content.split('\n').map((line, i) => (
536
+ <div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
537
+ <span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
538
+ <span className="flex-1">{line || ' '}</span>
539
+ </div>
540
+ ))}
541
+ </pre>
542
+ </div>
506
543
  )
507
544
  ) : (
508
545
  <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
@@ -59,6 +59,8 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
59
59
  const [fileTree, setFileTree] = useState<any[]>([]);
60
60
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
61
61
  const [fileContent, setFileContent] = useState<string | null>(null);
62
+ const [fileImageUrl, setFileImageUrl] = useState<string | null>(null);
63
+ const [fileBinaryInfo, setFileBinaryInfo] = useState<{ fileType: string; sizeLabel: string; message?: string } | null>(null);
62
64
  const [fileLanguage, setFileLanguage] = useState('');
63
65
  const [fileLoading, setFileLoading] = useState(false);
64
66
  const [showLog, setShowLog] = useState(false);
@@ -117,12 +119,23 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
117
119
  setSelectedFile(path);
118
120
  setDiffContent(null);
119
121
  setDiffFile(null);
122
+ setFileContent(null);
123
+ setFileImageUrl(null);
124
+ setFileBinaryInfo(null);
120
125
  setFileLoading(true);
121
126
  try {
122
127
  const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&file=${encodeURIComponent(path)}`);
123
128
  const data = await res.json();
124
- setFileContent(data.content || null);
125
- setFileLanguage(data.language || '');
129
+ if (data.image) {
130
+ setFileImageUrl(data.dataUrl);
131
+ } else if (data.binary) {
132
+ setFileBinaryInfo({ fileType: data.fileType, sizeLabel: data.sizeLabel, message: data.message });
133
+ } else if (data.tooLarge) {
134
+ setFileBinaryInfo({ fileType: '', sizeLabel: data.sizeLabel, message: data.message });
135
+ } else {
136
+ setFileContent(data.content || null);
137
+ setFileLanguage(data.language || '');
138
+ }
126
139
  } catch { setFileContent(null); }
127
140
  setFileLoading(false);
128
141
  }, [projectPath]);
@@ -529,6 +542,23 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
529
542
  </>
530
543
  ) : fileLoading ? (
531
544
  <div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading...</div>
545
+ ) : selectedFile && fileImageUrl ? (
546
+ <>
547
+ <div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
548
+ <div className="flex-1 flex items-center justify-center p-4 overflow-auto">
549
+ <img src={fileImageUrl} alt={selectedFile} className="max-w-full max-h-full object-contain rounded" />
550
+ </div>
551
+ </>
552
+ ) : selectedFile && fileBinaryInfo ? (
553
+ <>
554
+ <div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
555
+ <div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
556
+ <span className="text-2xl">📄</span>
557
+ <span className="text-xs">{fileBinaryInfo.fileType ? fileBinaryInfo.fileType.toUpperCase() + ' file' : 'File'} — {fileBinaryInfo.sizeLabel}</span>
558
+ {fileBinaryInfo.message && <span className="text-[10px]">{fileBinaryInfo.message}</span>}
559
+ <span className="text-[10px]">Binary file cannot be displayed</span>
560
+ </div>
561
+ </>
532
562
  ) : selectedFile && fileContent !== null ? (
533
563
  <>
534
564
  <div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
@@ -18,7 +18,7 @@ export default function TabBar({ tabs, activeId, onActivate, onClose }: TabBarPr
18
18
  if (tabs.length === 0) return null;
19
19
 
20
20
  return (
21
- <div className="flex items-center border-b border-[var(--border)] bg-[var(--bg-tertiary)] overflow-x-auto shrink-0">
21
+ <div className="flex items-center border-b border-[var(--border)] bg-[var(--bg-tertiary)] overflow-x-auto shrink-0 min-w-0 max-w-full">
22
22
  {tabs.map(tab => (
23
23
  <div
24
24
  key={tab.id}
@@ -15,6 +15,8 @@ export interface WebTerminalHandle {
15
15
  export interface WebTerminalProps {
16
16
  onActiveSession?: (sessionName: string | null) => void;
17
17
  onCodeOpenChange?: (open: boolean) => void;
18
+ browserOpen?: boolean;
19
+ onBrowserToggle?: () => void;
18
20
  }
19
21
 
20
22
  // ─── Types ───────────────────────────────────────────────────
@@ -164,7 +166,7 @@ let globalDragging = false;
164
166
 
165
167
  // ─── Main component ─────────────────────────────────────────
166
168
 
167
- const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange }, ref) {
169
+ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange, browserOpen, onBrowserToggle }, ref) {
168
170
  const [tabs, setTabs] = useState<TabState[]>(() => {
169
171
  const tree = makeTerminal();
170
172
  return [{ id: nextId++, label: 'Terminal 1', tree, ratios: {}, activeId: firstTerminalId(tree) }];
@@ -634,8 +636,17 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
634
636
  Code
635
637
  </button>
636
638
  )}
639
+ {onBrowserToggle && (
640
+ <button
641
+ onClick={onBrowserToggle}
642
+ className={`text-[11px] px-3 py-1 rounded font-bold ${browserOpen ? 'text-white bg-blue-500 hover:bg-blue-400' : 'text-blue-400 border border-blue-500 hover:bg-blue-500 hover:text-white'}`}
643
+ title={browserOpen ? 'Close browser' : 'Open browser'}
644
+ >
645
+ Browser
646
+ </button>
647
+ )}
637
648
  {activeTab && countTerminals(activeTab.tree) > 1 && (
638
- <button onClick={onClosePane} className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-red-400 hover:bg-[var(--term-border)] rounded">
649
+ <button onClick={onClosePane} className="text-[10px] px-2 py-0.5 text-[var(--accent)] hover:text-red-400 hover:bg-[var(--term-border)] rounded font-medium">
639
650
  Close Pane
640
651
  </button>
641
652
  )}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {