@aion0/forge 0.1.10 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,510 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
4
+ import type { WebTerminalHandle, WebTerminalProps } from './WebTerminal';
5
+
6
+ const WebTerminal = lazy(() => import('./WebTerminal'));
7
+
8
+ interface FileNode {
9
+ name: string;
10
+ path: string;
11
+ type: 'file' | 'dir';
12
+ children?: FileNode[];
13
+ }
14
+
15
+ // ─── File Tree ───────────────────────────────────────────
16
+
17
+ type GitStatusMap = Map<string, string>; // path → status
18
+ type GitRepoMap = Map<string, { branch: string; remote: string }>; // dir name → repo info
19
+
20
+ function TreeNode({ node, depth, selected, onSelect, gitMap, repoMap }: {
21
+ node: FileNode;
22
+ depth: number;
23
+ selected: string | null;
24
+ onSelect: (path: string) => void;
25
+ gitMap: GitStatusMap;
26
+ repoMap: GitRepoMap;
27
+ }) {
28
+ // Auto-expand if selected file is under this directory
29
+ const containsSelected = selected ? selected.startsWith(node.path + '/') : false;
30
+ const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
31
+ const expanded = manualExpanded ?? (depth < 1 || containsSelected);
32
+
33
+ if (node.type === 'dir') {
34
+ const dirHasChanges = node.children?.some(c => hasGitChanges(c, gitMap));
35
+ const repo = repoMap.get(node.name);
36
+ return (
37
+ <div>
38
+ <button
39
+ onClick={() => setManualExpanded(v => v === null ? !expanded : !v)}
40
+ className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs group"
41
+ style={{ paddingLeft: depth * 12 + 4 }}
42
+ title={repo ? `${repo.branch} · ${repo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}` : undefined}
43
+ >
44
+ <span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
45
+ <span className={dirHasChanges ? 'text-yellow-400' : 'text-[var(--text-primary)]'}>{node.name}</span>
46
+ {repo && (
47
+ <span className="text-[8px] text-[var(--accent)] opacity-60 group-hover:opacity-100 ml-auto shrink-0">{repo.branch}</span>
48
+ )}
49
+ </button>
50
+ {expanded && node.children?.map(child => (
51
+ <TreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} gitMap={gitMap} repoMap={repoMap} />
52
+ ))}
53
+ </div>
54
+ );
55
+ }
56
+
57
+ const isSelected = selected === node.path;
58
+ const gitStatus = gitMap.get(node.path);
59
+ const gitColor = gitStatus
60
+ ? gitStatus.includes('M') ? 'text-yellow-400'
61
+ : gitStatus.includes('D') ? 'text-red-400'
62
+ : 'text-green-400' // A, ?, new
63
+ : '';
64
+
65
+ return (
66
+ <button
67
+ onClick={() => onSelect(node.path)}
68
+ className={`w-full text-left flex items-center gap-1 px-1 py-0.5 rounded text-xs truncate ${
69
+ isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]'
70
+ : gitColor ? `hover:bg-[var(--bg-tertiary)] ${gitColor}`
71
+ : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
72
+ }`}
73
+ style={{ paddingLeft: depth * 12 + 16 }}
74
+ title={node.path}
75
+ >
76
+ {node.name}
77
+ </button>
78
+ );
79
+ }
80
+
81
+ function hasGitChanges(node: FileNode, gitMap: GitStatusMap): boolean {
82
+ if (node.type === 'file') return gitMap.has(node.path);
83
+ return node.children?.some(c => hasGitChanges(c, gitMap)) || false;
84
+ }
85
+
86
+ function flattenTree(nodes: FileNode[]): FileNode[] {
87
+ const result: FileNode[] = [];
88
+ for (const node of nodes) {
89
+ if (node.type === 'file') result.push(node);
90
+ if (node.children) result.push(...flattenTree(node.children));
91
+ }
92
+ return result;
93
+ }
94
+
95
+ const LANG_MAP: Record<string, string> = {
96
+ ts: 'TypeScript', tsx: 'TypeScript (JSX)', js: 'JavaScript', jsx: 'JavaScript (JSX)',
97
+ py: 'Python', go: 'Go', rs: 'Rust', java: 'Java', kt: 'Kotlin',
98
+ css: 'CSS', scss: 'SCSS', html: 'HTML', json: 'JSON', yaml: 'YAML', yml: 'YAML',
99
+ md: 'Markdown', sh: 'Shell', sql: 'SQL', toml: 'TOML', xml: 'XML',
100
+ };
101
+
102
+ // ─── Simple syntax highlighting ──────────────────────────
103
+
104
+ const KEYWORDS = new Set([
105
+ 'import', 'export', 'from', 'const', 'let', 'var', 'function', 'return',
106
+ 'if', 'else', 'for', 'while', 'switch', 'case', 'break', 'continue',
107
+ 'class', 'extends', 'new', 'this', 'super', 'typeof', 'instanceof',
108
+ 'try', 'catch', 'finally', 'throw', 'async', 'await', 'yield',
109
+ 'default', 'interface', 'type', 'enum', 'implements', 'readonly',
110
+ 'public', 'private', 'protected', 'static', 'abstract',
111
+ 'true', 'false', 'null', 'undefined', 'void',
112
+ 'def', 'self', 'None', 'True', 'False', 'class', 'lambda', 'with', 'as', 'in', 'not', 'and', 'or',
113
+ 'func', 'package', 'struct', 'go', 'defer', 'select', 'chan', 'map', 'range',
114
+ ]);
115
+
116
+ function highlightLine(line: string, lang: string): React.ReactNode {
117
+ if (!line) return ' ';
118
+
119
+ // Comments
120
+ const commentIdx = lang === 'py' ? line.indexOf('#') :
121
+ line.indexOf('//');
122
+ if (commentIdx === 0 || (commentIdx > 0 && /^\s*$/.test(line.slice(0, commentIdx)))) {
123
+ return <span className="text-gray-500 italic">{line}</span>;
124
+ }
125
+
126
+ // Tokenize with regex
127
+ const parts: React.ReactNode[] = [];
128
+ let lastIdx = 0;
129
+
130
+ const regex = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d+\.?\d*\b)|(\/\/.*$|#.*$)|(\b[A-Z_][A-Z_0-9]+\b)|(\b\w+\b)/g;
131
+ let match;
132
+
133
+ while ((match = regex.exec(line)) !== null) {
134
+ // Text before match
135
+ if (match.index > lastIdx) {
136
+ parts.push(line.slice(lastIdx, match.index));
137
+ }
138
+
139
+ if (match[1]) {
140
+ // String
141
+ parts.push(<span key={match.index} className="text-green-400">{match[0]}</span>);
142
+ } else if (match[2]) {
143
+ // Number
144
+ parts.push(<span key={match.index} className="text-orange-300">{match[0]}</span>);
145
+ } else if (match[3]) {
146
+ // Comment
147
+ parts.push(<span key={match.index} className="text-gray-500 italic">{match[0]}</span>);
148
+ } else if (match[4]) {
149
+ // CONSTANT
150
+ parts.push(<span key={match.index} className="text-cyan-300">{match[0]}</span>);
151
+ } else if (match[5] && KEYWORDS.has(match[5])) {
152
+ // Keyword
153
+ parts.push(<span key={match.index} className="text-purple-400">{match[0]}</span>);
154
+ } else {
155
+ parts.push(match[0]);
156
+ }
157
+ lastIdx = match.index + match[0].length;
158
+ }
159
+
160
+ if (lastIdx < line.length) {
161
+ parts.push(line.slice(lastIdx));
162
+ }
163
+
164
+ return parts.length > 0 ? <>{parts}</> : line;
165
+ }
166
+
167
+ // ─── Main Component ──────────────────────────────────────
168
+
169
+ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObject<WebTerminalHandle | null> }) {
170
+ const [currentDir, setCurrentDir] = useState<string | null>(null);
171
+ const [dirName, setDirName] = useState('');
172
+ const [tree, setTree] = useState<FileNode[]>([]);
173
+ const [gitBranch, setGitBranch] = useState('');
174
+ const [gitChanges, setGitChanges] = useState<{ path: string; status: string }[]>([]);
175
+ const [gitRepos, setGitRepos] = useState<{ name: string; branch: string; remote: string; changes: { path: string; status: string }[] }[]>([]);
176
+ const [showGit, setShowGit] = useState(false);
177
+ const [selectedFile, setSelectedFile] = useState<string | null>(null);
178
+ const [content, setContent] = useState<string | null>(null);
179
+ const [language, setLanguage] = useState('');
180
+ const [loading, setLoading] = useState(false);
181
+ const [search, setSearch] = useState('');
182
+ const [diffContent, setDiffContent] = useState<string | null>(null);
183
+ const [diffFile, setDiffFile] = useState<string | null>(null);
184
+ const [viewMode, setViewMode] = useState<'file' | 'diff'>('file');
185
+ const [sidebarOpen, setSidebarOpen] = useState(true);
186
+ const [codeOpen, setCodeOpen] = useState(false);
187
+
188
+ const handleCodeOpenChange = useCallback((open: boolean) => {
189
+ setCodeOpen(open);
190
+ }, []);
191
+ const [terminalHeight, setTerminalHeight] = useState(300);
192
+ const [activeSession, setActiveSession] = useState<string | null>(null);
193
+ const dragRef = useRef<{ startY: number; startH: number } | null>(null);
194
+ const lastDirRef = useRef<string | null>(null);
195
+
196
+ // When active terminal session changes, query its cwd
197
+ useEffect(() => {
198
+ if (!activeSession) return;
199
+ let cancelled = false;
200
+
201
+ const fetchCwd = async () => {
202
+ try {
203
+ const res = await fetch(`/api/terminal-cwd?session=${encodeURIComponent(activeSession)}`);
204
+ const data = await res.json();
205
+ if (!cancelled && data.path && data.path !== lastDirRef.current) {
206
+ lastDirRef.current = data.path;
207
+ setCurrentDir(data.path);
208
+ setSelectedFile(null);
209
+ setContent(null);
210
+ }
211
+ } catch {}
212
+ };
213
+
214
+ fetchCwd();
215
+ // Poll cwd every 5s (user might cd to a different directory)
216
+ const timer = setInterval(fetchCwd, 5000);
217
+ return () => { cancelled = true; clearInterval(timer); };
218
+ }, [activeSession]);
219
+
220
+ // Fetch file tree when directory changes
221
+ useEffect(() => {
222
+ if (!currentDir) return;
223
+ const fetchDir = () => {
224
+ fetch(`/api/code?dir=${encodeURIComponent(currentDir)}`)
225
+ .then(r => r.json())
226
+ .then(data => {
227
+ setTree(data.tree || []);
228
+ setDirName(data.dirName || currentDir.split('/').pop() || '');
229
+ setGitBranch(data.gitBranch || '');
230
+ setGitChanges(data.gitChanges || []);
231
+ setGitRepos(data.gitRepos || []);
232
+ })
233
+ .catch(() => setTree([]));
234
+ };
235
+ fetchDir();
236
+ }, [currentDir]);
237
+
238
+ // Build git status map for tree coloring
239
+ const gitMap: GitStatusMap = new Map(gitChanges.map(g => [g.path, g.status]));
240
+ const repoMap: GitRepoMap = new Map(gitRepos.filter(r => r.name !== '.').map(r => [r.name, { branch: r.branch, remote: r.remote }]));
241
+
242
+ const openFile = useCallback(async (path: string) => {
243
+ if (!currentDir) return;
244
+ setSelectedFile(path);
245
+ setViewMode('file');
246
+ setLoading(true);
247
+ const res = await fetch(`/api/code?dir=${encodeURIComponent(currentDir)}&file=${encodeURIComponent(path)}`);
248
+ const data = await res.json();
249
+ setContent(data.content || null);
250
+ setLanguage(data.language || '');
251
+ setLoading(false);
252
+ }, [currentDir]);
253
+
254
+ const openDiff = useCallback(async (path: string) => {
255
+ if (!currentDir) return;
256
+ setDiffFile(path);
257
+ setViewMode('diff');
258
+ setLoading(true);
259
+ const res = await fetch(`/api/code?dir=${encodeURIComponent(currentDir)}&diff=${encodeURIComponent(path)}`);
260
+ const data = await res.json();
261
+ setDiffContent(data.diff || null);
262
+ setLoading(false);
263
+ }, [currentDir]);
264
+
265
+ // Open file and auto-expand its parent dirs in tree
266
+ const locateFile = useCallback((path: string) => {
267
+ setSearch(''); // clear search so tree is visible
268
+ openFile(path);
269
+ }, [openFile]);
270
+
271
+ const allFiles = flattenTree(tree);
272
+ const filtered = search
273
+ ? allFiles.filter(f => f.name.toLowerCase().includes(search.toLowerCase()) || f.path.toLowerCase().includes(search.toLowerCase()))
274
+ : null;
275
+
276
+ const onDragStart = (e: React.MouseEvent) => {
277
+ e.preventDefault();
278
+ dragRef.current = { startY: e.clientY, startH: terminalHeight };
279
+ const onMove = (ev: MouseEvent) => {
280
+ if (!dragRef.current) return;
281
+ const delta = ev.clientY - dragRef.current.startY;
282
+ setTerminalHeight(Math.max(100, Math.min(600, dragRef.current.startH + delta)));
283
+ };
284
+ const onUp = () => {
285
+ dragRef.current = null;
286
+ window.removeEventListener('mousemove', onMove);
287
+ window.removeEventListener('mouseup', onUp);
288
+ };
289
+ window.addEventListener('mousemove', onMove);
290
+ window.addEventListener('mouseup', onUp);
291
+ };
292
+
293
+ const handleActiveSession = useCallback((session: string | null) => {
294
+ setActiveSession(session);
295
+ }, []);
296
+
297
+ return (
298
+ <div className="flex-1 flex flex-col min-h-0">
299
+ {/* Terminal — top */}
300
+ <div className={codeOpen ? 'shrink-0' : 'flex-1'} style={codeOpen ? { height: terminalHeight } : undefined}>
301
+ <Suspense fallback={<div className="h-full flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading...</div>}>
302
+ <WebTerminal ref={terminalRef} onActiveSession={handleActiveSession} onCodeOpenChange={handleCodeOpenChange} />
303
+ </Suspense>
304
+ </div>
305
+
306
+ {/* Resize handle */}
307
+ {codeOpen && (
308
+ <div
309
+ onMouseDown={onDragStart}
310
+ className="h-1 bg-[var(--border)] cursor-row-resize hover:bg-[var(--accent)]/50 shrink-0"
311
+ />
312
+ )}
313
+
314
+ {/* File browser + code viewer — bottom */}
315
+ {codeOpen && <div className="flex-1 flex min-h-0">
316
+ {/* Sidebar */}
317
+ {sidebarOpen && (
318
+ <aside className="w-56 border-r border-[var(--border)] flex flex-col shrink-0">
319
+ {/* Directory name + git */}
320
+ <div className="px-3 py-2 border-b border-[var(--border)]">
321
+ <div className="flex items-center gap-2">
322
+ <span className="text-[11px] font-semibold text-[var(--text-primary)] truncate">
323
+ {dirName || 'No directory'}
324
+ </span>
325
+ {gitBranch && (
326
+ <span className="text-[9px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded shrink-0">
327
+ {gitBranch}
328
+ </span>
329
+ )}
330
+ </div>
331
+ {gitRepos.find(r => r.name === '.')?.remote && (
332
+ <div className="text-[9px] text-[var(--text-secondary)] truncate mt-0.5" title={gitRepos.find(r => r.name === '.')!.remote}>
333
+ {gitRepos.find(r => r.name === '.')!.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}
334
+ </div>
335
+ )}
336
+ {gitChanges.length > 0 && (
337
+ <button
338
+ onClick={() => setShowGit(v => !v)}
339
+ className="text-[10px] text-yellow-500 hover:text-yellow-400 mt-1 block"
340
+ >
341
+ {gitChanges.length} changes {showGit ? '▾' : '▸'}
342
+ </button>
343
+ )}
344
+ </div>
345
+
346
+ {/* Git changes — grouped by repo */}
347
+ {showGit && gitChanges.length > 0 && (
348
+ <div className="border-b border-[var(--border)] max-h-48 overflow-y-auto">
349
+ {gitRepos.map(repo => (
350
+ <div key={repo.name}>
351
+ {/* Repo header — only show if multiple repos */}
352
+ {gitRepos.length > 1 && (
353
+ <div className="px-2 py-1 text-[9px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0" title={repo.remote}>
354
+ <div className="flex items-center gap-1.5">
355
+ <span className="font-semibold text-[var(--text-primary)]">{repo.name}</span>
356
+ <span className="text-[var(--accent)]">{repo.branch}</span>
357
+ <span className="ml-auto">{repo.changes.length}</span>
358
+ </div>
359
+ {repo.remote && (
360
+ <div className="text-[8px] truncate mt-0.5">{repo.remote.replace(/^https?:\/\//, '').replace(/\.git$/, '')}</div>
361
+ )}
362
+ </div>
363
+ )}
364
+ {repo.changes.map(g => (
365
+ <div
366
+ key={g.path}
367
+ className={`flex items-center px-2 py-1 text-xs hover:bg-[var(--bg-tertiary)] ${
368
+ diffFile === g.path && viewMode === 'diff' ? 'bg-[var(--accent)]/10' : ''
369
+ }`}
370
+ >
371
+ <span className={`text-[10px] font-mono w-4 shrink-0 ${
372
+ g.status.includes('M') ? 'text-yellow-500' :
373
+ g.status.includes('A') || g.status.includes('?') ? 'text-green-500' :
374
+ g.status.includes('D') ? 'text-red-500' :
375
+ 'text-[var(--text-secondary)]'
376
+ }`}>
377
+ {g.status.includes('?') ? '+' : g.status[0]}
378
+ </span>
379
+ <button
380
+ onClick={() => openDiff(g.path)}
381
+ className="flex-1 text-left truncate text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-1 group relative"
382
+ title={`${g.path}${gitRepos.length > 1 ? ` (${repo.name} · ${repo.branch})` : ''}`}
383
+ >
384
+ {gitRepos.length > 1 ? g.path.replace(repo.name + '/', '') : g.path}
385
+ </button>
386
+ <button
387
+ onClick={(e) => { e.stopPropagation(); locateFile(g.path); }}
388
+ className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)] hover:bg-[var(--accent)]/10 px-1.5 py-0.5 rounded shrink-0"
389
+ title="Locate in file tree"
390
+ >
391
+ file
392
+ </button>
393
+ </div>
394
+ ))}
395
+ </div>
396
+ ))}
397
+ </div>
398
+ )}
399
+
400
+ {/* Search */}
401
+ <div className="p-2 border-b border-[var(--border)]">
402
+ <input
403
+ type="text"
404
+ placeholder="Search..."
405
+ value={search}
406
+ onChange={e => setSearch(e.target.value)}
407
+ 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)]"
408
+ />
409
+ </div>
410
+
411
+ {/* Tree */}
412
+ <div className="flex-1 overflow-y-auto p-1">
413
+ {!currentDir ? (
414
+ <div className="text-xs text-[var(--text-secondary)] p-2">Open a terminal to see files</div>
415
+ ) : filtered ? (
416
+ filtered.length === 0 ? (
417
+ <div className="text-xs text-[var(--text-secondary)] p-2">No matches</div>
418
+ ) : (
419
+ filtered.map(f => (
420
+ <button
421
+ key={f.path}
422
+ onClick={() => { openFile(f.path); setSearch(''); }}
423
+ className={`w-full text-left px-2 py-1 rounded text-xs truncate ${
424
+ selectedFile === f.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
425
+ }`}
426
+ title={f.path}
427
+ >
428
+ <span className="text-[var(--text-primary)]">{f.name}</span>
429
+ <span className="text-[9px] text-[var(--text-secondary)] ml-1">{f.path.split('/').slice(0, -1).join('/')}</span>
430
+ </button>
431
+ ))
432
+ )
433
+ ) : (
434
+ tree.map(node => (
435
+ <TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} gitMap={gitMap} repoMap={repoMap} />
436
+ ))
437
+ )}
438
+ </div>
439
+ </aside>
440
+ )}
441
+
442
+ {/* Code viewer */}
443
+ <main className="flex-1 flex flex-col min-w-0">
444
+ <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0 flex items-center gap-2">
445
+ <button
446
+ onClick={() => setSidebarOpen(v => !v)}
447
+ className="text-[10px] px-1.5 py-0.5 text-gray-400 hover:text-white hover:bg-[var(--bg-tertiary)] rounded"
448
+ >
449
+ {sidebarOpen ? '◀' : '▶'}
450
+ </button>
451
+ {viewMode === 'diff' && diffFile ? (
452
+ <>
453
+ <span className="text-xs font-semibold text-yellow-400 truncate">{diffFile}</span>
454
+ <span className="text-[9px] text-[var(--text-secondary)] ml-auto">diff</span>
455
+ </>
456
+ ) : selectedFile ? (
457
+ <>
458
+ <span className="text-xs font-semibold text-[var(--text-primary)] truncate">{selectedFile}</span>
459
+ {language && (
460
+ <span className="text-[9px] text-[var(--text-secondary)] ml-auto">{LANG_MAP[language] || language}</span>
461
+ )}
462
+ </>
463
+ ) : (
464
+ <span className="text-xs text-[var(--text-secondary)]">{dirName || 'Code'}</span>
465
+ )}
466
+ </div>
467
+
468
+ {loading ? (
469
+ <div className="flex-1 flex items-center justify-center">
470
+ <div className="text-xs text-[var(--text-secondary)]">Loading...</div>
471
+ </div>
472
+ ) : viewMode === 'diff' && diffContent ? (
473
+ <div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
474
+ <pre className="p-4 text-[12px] leading-[1.5] font-mono whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
475
+ {diffContent.split('\n').map((line, i) => {
476
+ const color = line.startsWith('+') ? 'text-green-400 bg-green-900/20'
477
+ : line.startsWith('-') ? 'text-red-400 bg-red-900/20'
478
+ : line.startsWith('@@') ? 'text-cyan-400'
479
+ : line.startsWith('diff') || line.startsWith('index') ? 'text-[var(--text-secondary)]'
480
+ : 'text-[var(--text-primary)]';
481
+ return (
482
+ <div key={i} className={`${color} px-2`}>
483
+ {line || ' '}
484
+ </div>
485
+ );
486
+ })}
487
+ </pre>
488
+ </div>
489
+ ) : selectedFile && content !== null ? (
490
+ <div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
491
+ <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 }}>
492
+ {content.split('\n').map((line, i) => (
493
+ <div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
494
+ <span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
495
+ <span className="flex-1">{highlightLine(line, language)}</span>
496
+ </div>
497
+ ))}
498
+ </pre>
499
+ </div>
500
+ ) : (
501
+ <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
502
+ <p className="text-xs">{currentDir ? 'Select a file to view' : 'Terminal will show files for its working directory'}</p>
503
+ </div>
504
+ )}
505
+ </main>
506
+ </div>}
507
+
508
+ </div>
509
+ );
510
+ }
@@ -11,6 +11,8 @@ import type { Task } from '@/src/types';
11
11
  import type { WebTerminalHandle } from './WebTerminal';
12
12
 
13
13
  const WebTerminal = lazy(() => import('./WebTerminal'));
14
+ const DocsViewer = lazy(() => import('./DocsViewer'));
15
+ const CodeViewer = lazy(() => import('./CodeViewer'));
14
16
 
15
17
  interface UsageSummary {
16
18
  provider: string;
@@ -33,7 +35,7 @@ interface ProjectInfo {
33
35
  }
34
36
 
35
37
  export default function Dashboard({ user }: { user: any }) {
36
- const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal'>('tasks');
38
+ const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs'>('terminal');
37
39
  const [tasks, setTasks] = useState<Task[]>([]);
38
40
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
39
41
  const [showNewTask, setShowNewTask] = useState(false);
@@ -78,34 +80,44 @@ export default function Dashboard({ user }: { user: any }) {
78
80
  {/* View mode toggle */}
79
81
  <div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
80
82
  <button
81
- onClick={() => setViewMode('tasks')}
83
+ onClick={() => setViewMode('terminal')}
82
84
  className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
83
- viewMode === 'tasks'
85
+ viewMode === 'terminal'
84
86
  ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
85
87
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
86
88
  }`}
87
89
  >
88
- Tasks
90
+ Vibe Coding
89
91
  </button>
90
92
  <button
91
- onClick={() => setViewMode('sessions')}
93
+ onClick={() => setViewMode('docs')}
92
94
  className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
93
- viewMode === 'sessions'
95
+ viewMode === 'docs'
94
96
  ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
95
97
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
96
98
  }`}
97
99
  >
98
- Sessions
100
+ Docs
99
101
  </button>
100
102
  <button
101
- onClick={() => setViewMode('terminal')}
103
+ onClick={() => setViewMode('tasks')}
102
104
  className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
103
- viewMode === 'terminal'
105
+ viewMode === 'tasks'
106
+ ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
107
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
108
+ }`}
109
+ >
110
+ Tasks
111
+ </button>
112
+ <button
113
+ onClick={() => setViewMode('sessions')}
114
+ className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
115
+ viewMode === 'sessions'
104
116
  ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
105
117
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
106
118
  }`}
107
119
  >
108
- Terminal
120
+ Sessions
109
121
  </button>
110
122
  </div>
111
123
 
@@ -239,10 +251,17 @@ export default function Dashboard({ user }: { user: any }) {
239
251
  />
240
252
  ) : null}
241
253
 
242
- {/* Terminal — always mounted, hidden when not active to keep sessions alive */}
243
- <div className={`flex-1 min-h-0 ${viewMode === 'terminal' ? '' : 'hidden'}`}>
244
- <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading terminal...</div>}>
245
- <WebTerminal ref={terminalRef} />
254
+ {/* Docs — always mounted to keep terminal session alive */}
255
+ <div className={`flex-1 min-h-0 flex ${viewMode === 'docs' ? '' : 'hidden'}`}>
256
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
257
+ <DocsViewer />
258
+ </Suspense>
259
+ </div>
260
+
261
+ {/* Code — terminal + file browser, always mounted to keep terminal sessions alive */}
262
+ <div className={`flex-1 min-h-0 flex ${viewMode === 'terminal' ? '' : 'hidden'}`}>
263
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
264
+ <CodeViewer terminalRef={terminalRef} />
246
265
  </Suspense>
247
266
  </div>
248
267
  </div>