@aion0/forge 0.10.77 → 0.10.79
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 +6 -6
- package/app/api/code/route.ts +171 -54
- package/app/api/onboarding/route.ts +32 -0
- package/app/api/skills/local/route.ts +5 -4
- package/app/chat/page.tsx +53 -1
- package/components/CodeViewer.tsx +127 -41
- package/components/DocsViewer.tsx +34 -22
- package/components/HelpTerminal.tsx +9 -5
- package/components/MobileChat.tsx +225 -0
- package/components/MobileView.tsx +22 -2
- package/components/OnboardingWizard.tsx +65 -1
- package/components/ProjectDetail.tsx +33 -7
- package/components/WebTerminal.tsx +19 -8
- package/components/WorkspaceView.tsx +68 -47
- package/lib/agents/index.ts +9 -0
- package/lib/chat/telegram-bridge.ts +15 -0
- package/lib/fileTree.ts +28 -0
- package/lib/help-docs/00-overview.md +2 -0
- package/lib/help-docs/01-settings.md +11 -0
- package/lib/help-docs/02-telegram.md +3 -0
- package/lib/help-docs/07-projects.md +3 -1
- package/lib/projects.ts +15 -5
- package/lib/session-utils.ts +19 -0
- package/lib/telegram-bot.ts +74 -26
- package/lib/terminal-standalone.ts +17 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import MobileChat from './MobileChat';
|
|
4
5
|
|
|
5
6
|
interface Project { name: string; path: string }
|
|
6
7
|
interface SessionInfo { sessionId: string; summary?: string; firstPrompt?: string; modified?: string }
|
|
@@ -24,6 +25,9 @@ export default function MobileView() {
|
|
|
24
25
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
25
26
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
26
27
|
const abortRef = useRef<AbortController | null>(null);
|
|
28
|
+
// Default to the Forge chat agent (same backend as web /chat + Telegram);
|
|
29
|
+
// Terminal tab keeps the original claude-session browse/continue view.
|
|
30
|
+
const [viewMode, setViewMode] = useState<'chat' | 'terminal'>('chat');
|
|
27
31
|
|
|
28
32
|
// Fetch projects
|
|
29
33
|
useEffect(() => {
|
|
@@ -225,9 +229,24 @@ export default function MobileView() {
|
|
|
225
229
|
|
|
226
230
|
return (
|
|
227
231
|
<div className="h-[100dvh] flex flex-col bg-[#0d1117] text-[#e6edf3]">
|
|
232
|
+
{/* Mode toggle — Chat (Forge agent) vs Terminal (claude sessions) */}
|
|
233
|
+
<div className="shrink-0 flex items-center gap-1.5 px-2 py-1.5 bg-[#161b22] border-b border-[#30363d]">
|
|
234
|
+
<span className="text-xs font-bold text-[#7c5bf0] mr-1">Forge</span>
|
|
235
|
+
<button
|
|
236
|
+
onClick={() => setViewMode('chat')}
|
|
237
|
+
className={`text-xs px-2.5 py-1 rounded ${viewMode === 'chat' ? 'bg-[#7c5bf0] text-white' : 'border border-[#30363d] text-[#8b949e] active:bg-[#30363d]'}`}
|
|
238
|
+
>💬 Chat</button>
|
|
239
|
+
<button
|
|
240
|
+
onClick={() => setViewMode('terminal')}
|
|
241
|
+
className={`text-xs px-2.5 py-1 rounded ${viewMode === 'terminal' ? 'bg-[#7c5bf0] text-white' : 'border border-[#30363d] text-[#8b949e] active:bg-[#30363d]'}`}
|
|
242
|
+
>⌨ Terminal</button>
|
|
243
|
+
<a href="/?force=desktop" className="ml-auto text-[9px] px-1.5 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]" title="Switch to desktop view">PC</a>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{viewMode === 'chat' ? <MobileChat /> : (
|
|
247
|
+
<>
|
|
228
248
|
{/* Header */}
|
|
229
249
|
<header className="shrink-0 flex items-center gap-1.5 px-2 py-2 bg-[#161b22] border-b border-[#30363d]">
|
|
230
|
-
<span className="text-xs font-bold text-[#7c5bf0]">Forge</span>
|
|
231
250
|
<select
|
|
232
251
|
value={selectedProject?.path || ''}
|
|
233
252
|
onChange={e => {
|
|
@@ -273,7 +292,6 @@ export default function MobileView() {
|
|
|
273
292
|
{tunnelUrl && (
|
|
274
293
|
<button onClick={closeTunnel} className="text-xs px-1.5 py-1 border border-green-700 rounded text-green-400" title={tunnelUrl}>●</button>
|
|
275
294
|
)}
|
|
276
|
-
<a href="/?force=desktop" className="text-[9px] px-1.5 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]" title="Switch to desktop view">PC</a>
|
|
277
295
|
</header>
|
|
278
296
|
|
|
279
297
|
{/* Session list */}
|
|
@@ -380,6 +398,8 @@ export default function MobileView() {
|
|
|
380
398
|
</div>
|
|
381
399
|
)}
|
|
382
400
|
</div>
|
|
401
|
+
</>
|
|
402
|
+
)}
|
|
383
403
|
</div>
|
|
384
404
|
);
|
|
385
405
|
}
|
|
@@ -50,6 +50,8 @@ interface OnboardingState {
|
|
|
50
50
|
projectRoots: string[];
|
|
51
51
|
};
|
|
52
52
|
detected_cli: Array<{ name: string; path: string; version: string }>;
|
|
53
|
+
/** Deployment shape — 'container' deploys can't run `claude login`. */
|
|
54
|
+
deployment?: 'container' | 'host';
|
|
53
55
|
/** All non-meta connector ids in the template, in template order. */
|
|
54
56
|
template_connectors: Array<{
|
|
55
57
|
id: string;
|
|
@@ -216,6 +218,11 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
216
218
|
const [cliAgentTool, setCliAgentTool] = useState<'claude' | 'codex' | 'aider' | 'opencode'>('claude');
|
|
217
219
|
const [cliAgentPath, setCliAgentPath] = useState('');
|
|
218
220
|
const [cliSetAsDefault, setCliSetAsDefault] = useState(true);
|
|
221
|
+
// Claude auth: 'login' (run `claude login` on host) vs 'token' (paste
|
|
222
|
+
// CLAUDE_CODE_OAUTH_TOKEN — required in containers where login can't reach
|
|
223
|
+
// a keychain). Defaults from detected deployment; user can override.
|
|
224
|
+
const [claudeAuthMode, setClaudeAuthMode] = useState<'login' | 'token'>('login');
|
|
225
|
+
const [claudeOauthToken, setClaudeOauthToken] = useState('');
|
|
219
226
|
|
|
220
227
|
// Section 3: Connector template values (one per ${key})
|
|
221
228
|
const [connectorValues, setConnectorValues] = useState<Record<string, string>>({});
|
|
@@ -354,7 +361,8 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
354
361
|
if (!initializedRef.current) { initializedRef.current = true; return; }
|
|
355
362
|
setDirty(true);
|
|
356
363
|
}, [displayName, displayEmail, apiKey, apiBaseUrl, apiModel, apiProfileId, apiProvider,
|
|
357
|
-
connectorValues, selectedConnectors, selectedPipelines, projectInput, cliAgentId, cliAgentPath
|
|
364
|
+
connectorValues, selectedConnectors, selectedPipelines, projectInput, cliAgentId, cliAgentPath,
|
|
365
|
+
claudeAuthMode, claudeOauthToken]);
|
|
358
366
|
// Inline marketplace sync (so user doesn't have to leave the wizard
|
|
359
367
|
// to populate the pipeline list when it's empty / stale).
|
|
360
368
|
const [syncingMarket, setSyncingMarket] = useState(false);
|
|
@@ -469,6 +477,9 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
469
477
|
setCliAgentTool(first.name as any);
|
|
470
478
|
setCliAgentPath(first.path);
|
|
471
479
|
}
|
|
480
|
+
|
|
481
|
+
// Container deploys can't run `claude login` — default to pasting a token.
|
|
482
|
+
if (s.deployment === 'container') setClaudeAuthMode('token');
|
|
472
483
|
}).catch(() => setState({
|
|
473
484
|
ok: false, onboardingCompleted: false,
|
|
474
485
|
current: { displayName: '', displayEmail: '', apiProfileIds: [], apiProfile: null, chatAgent: '', defaultAgent: '', agents: [], projectRoots: [] },
|
|
@@ -605,6 +616,11 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
605
616
|
tool: cliAgentTool,
|
|
606
617
|
...(cliAgentPath ? { path: cliAgentPath } : {}),
|
|
607
618
|
setAsDefault: cliSetAsDefault,
|
|
619
|
+
// Container claude auth: pasted OAuth token → encrypted agent env.
|
|
620
|
+
// Blank is fine (backend keeps any existing token).
|
|
621
|
+
...(cliAgentTool === 'claude' && claudeAuthMode === 'token' && claudeOauthToken.trim()
|
|
622
|
+
? { env: { CLAUDE_CODE_OAUTH_TOKEN: claudeOauthToken.trim() } }
|
|
623
|
+
: {}),
|
|
608
624
|
};
|
|
609
625
|
}
|
|
610
626
|
const r = await fetch('/api/onboarding', {
|
|
@@ -1132,6 +1148,54 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
1132
1148
|
Use this CLI agent as the default for terminal sessions
|
|
1133
1149
|
</label>
|
|
1134
1150
|
)}
|
|
1151
|
+
|
|
1152
|
+
{/* Claude auth — deployment-aware. `claude login` needs a desktop
|
|
1153
|
+
keychain, which containers lack; there you paste a token from
|
|
1154
|
+
`claude setup-token` run on a machine that CAN log in. */}
|
|
1155
|
+
{cliAgentTool === 'claude' && (
|
|
1156
|
+
<div className="mt-2 pt-2 border-t border-[var(--border)]">
|
|
1157
|
+
<div className="text-[10px] uppercase tracking-wider text-[var(--text-secondary)] mb-1 flex items-center gap-1.5">
|
|
1158
|
+
Claude.ai auth
|
|
1159
|
+
{state.deployment === 'container'
|
|
1160
|
+
? <span className="text-[9px] text-amber-500">📦 container detected</span>
|
|
1161
|
+
: <span className="text-[9px] text-emerald-500">🖥 host detected</span>}
|
|
1162
|
+
</div>
|
|
1163
|
+
<div className="flex gap-1 mb-1.5">
|
|
1164
|
+
{(['login', 'token'] as const).map(m => (
|
|
1165
|
+
<button
|
|
1166
|
+
key={m}
|
|
1167
|
+
onClick={() => setClaudeAuthMode(m)}
|
|
1168
|
+
className={`text-[10px] px-2 py-0.5 rounded ${claudeAuthMode === m ? 'bg-[var(--accent)] text-white' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'}`}
|
|
1169
|
+
>
|
|
1170
|
+
{m === 'login' ? 'claude login' : 'paste token'}
|
|
1171
|
+
</button>
|
|
1172
|
+
))}
|
|
1173
|
+
</div>
|
|
1174
|
+
{claudeAuthMode === 'login' ? (
|
|
1175
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
1176
|
+
After onboarding, open a terminal and run <code>claude login</code> once.
|
|
1177
|
+
{state.deployment === 'container' && (
|
|
1178
|
+
<span className="text-amber-500"> ⚠ This deployment looks like a container — <code>claude login</code> usually can't reach a keychain here. Prefer “paste token”.</span>
|
|
1179
|
+
)}
|
|
1180
|
+
</p>
|
|
1181
|
+
) : (
|
|
1182
|
+
<div>
|
|
1183
|
+
<p className="text-[10px] text-[var(--text-secondary)] mb-1">
|
|
1184
|
+
On a machine where you CAN log in to Claude, run <code>claude setup-token</code> and paste the result. Stored encrypted as <code>CLAUDE_CODE_OAUTH_TOKEN</code>.
|
|
1185
|
+
</p>
|
|
1186
|
+
<Field label="CLAUDE_CODE_OAUTH_TOKEN (leave blank to keep existing)">
|
|
1187
|
+
<input
|
|
1188
|
+
type="password"
|
|
1189
|
+
className={inputCls + ' font-mono'}
|
|
1190
|
+
value={claudeOauthToken}
|
|
1191
|
+
onChange={e => setClaudeOauthToken(e.target.value)}
|
|
1192
|
+
placeholder="sk-ant-oat..."
|
|
1193
|
+
/>
|
|
1194
|
+
</Field>
|
|
1195
|
+
</div>
|
|
1196
|
+
)}
|
|
1197
|
+
</div>
|
|
1198
|
+
)}
|
|
1135
1199
|
</Section>
|
|
1136
1200
|
)}
|
|
1137
1201
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useState, useEffect, useCallback, useRef, memo, lazy, Suspense } from 'react';
|
|
4
4
|
import { useSidebarResize } from '@/hooks/useSidebarResize';
|
|
5
|
+
import { updateTreeChildren } from '@/lib/fileTree';
|
|
5
6
|
|
|
6
7
|
import { TerminalSessionPickerLazy, fetchProjectSessions } from './TerminalLauncher';
|
|
7
8
|
const InlinePipelineView = lazy(() => import('./InlinePipelineView'));
|
|
@@ -65,6 +66,14 @@ interface GitInfo {
|
|
|
65
66
|
log: { hash: string; message: string; author: string; date: string }[];
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
interface FileTreeNodeData {
|
|
70
|
+
name: string;
|
|
71
|
+
path: string;
|
|
72
|
+
type: string;
|
|
73
|
+
children?: FileTreeNodeData[];
|
|
74
|
+
hasChildren?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
export default memo(function ProjectDetail({ projectPath, projectName, hasGit }: { projectPath: string; projectName: string; hasGit: boolean }) {
|
|
69
78
|
const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 208, minWidth: 120, maxWidth: 400 });
|
|
70
79
|
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
|
|
@@ -167,6 +176,14 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
167
176
|
} catch { setFileTree([]); }
|
|
168
177
|
}, [projectPath]);
|
|
169
178
|
|
|
179
|
+
const loadTreeChildren = useCallback(async (path: string) => {
|
|
180
|
+
try {
|
|
181
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&treePath=${encodeURIComponent(path)}`);
|
|
182
|
+
const data = await res.json();
|
|
183
|
+
setFileTree(prev => updateTreeChildren(prev, path, data.tree || []));
|
|
184
|
+
} catch {}
|
|
185
|
+
}, [projectPath]);
|
|
186
|
+
|
|
170
187
|
const openFile = useCallback(async (path: string) => {
|
|
171
188
|
setSelectedFile(path);
|
|
172
189
|
setDiffContent(null);
|
|
@@ -771,7 +788,7 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
771
788
|
{/* File tree */}
|
|
772
789
|
<div className="overflow-y-auto flex-1 p-1">
|
|
773
790
|
{fileTree.map((node: any) => (
|
|
774
|
-
<FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} collapseVersion={treeCollapseVersion} />
|
|
791
|
+
<FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} onLoadChildren={loadTreeChildren} collapseVersion={treeCollapseVersion} />
|
|
775
792
|
))}
|
|
776
793
|
</div>
|
|
777
794
|
</div>
|
|
@@ -1582,14 +1599,15 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
1582
1599
|
});
|
|
1583
1600
|
|
|
1584
1601
|
// Simple file tree node
|
|
1585
|
-
const FileTreeNode = memo(function FileTreeNode({ node, depth, selected, onSelect, collapseVersion }: {
|
|
1586
|
-
node:
|
|
1602
|
+
const FileTreeNode = memo(function FileTreeNode({ node, depth, selected, onSelect, onLoadChildren, collapseVersion }: {
|
|
1603
|
+
node: FileTreeNodeData;
|
|
1587
1604
|
depth: number;
|
|
1588
1605
|
selected: string | null;
|
|
1589
1606
|
onSelect: (path: string) => void;
|
|
1607
|
+
onLoadChildren: (path: string) => Promise<void>;
|
|
1590
1608
|
collapseVersion: number;
|
|
1591
1609
|
}) {
|
|
1592
|
-
const [expanded, setExpanded] = useState(
|
|
1610
|
+
const [expanded, setExpanded] = useState(false);
|
|
1593
1611
|
|
|
1594
1612
|
// When parent bumps collapseVersion, collapse this node
|
|
1595
1613
|
useEffect(() => {
|
|
@@ -1597,18 +1615,26 @@ const FileTreeNode = memo(function FileTreeNode({ node, depth, selected, onSelec
|
|
|
1597
1615
|
}, [collapseVersion]);
|
|
1598
1616
|
|
|
1599
1617
|
if (node.type === 'dir') {
|
|
1618
|
+
const hasChildren = node.hasChildren || (node.children?.length ?? 0) > 0;
|
|
1619
|
+
const toggleExpanded = async () => {
|
|
1620
|
+
const nextExpanded = !expanded;
|
|
1621
|
+
setExpanded(nextExpanded);
|
|
1622
|
+
if (nextExpanded && hasChildren && !node.children) {
|
|
1623
|
+
await onLoadChildren(node.path);
|
|
1624
|
+
}
|
|
1625
|
+
};
|
|
1600
1626
|
return (
|
|
1601
1627
|
<div>
|
|
1602
1628
|
<button
|
|
1603
|
-
onClick={
|
|
1629
|
+
onClick={toggleExpanded}
|
|
1604
1630
|
className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs"
|
|
1605
1631
|
style={{ paddingLeft: depth * 12 + 4 }}
|
|
1606
1632
|
>
|
|
1607
|
-
<span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
|
|
1633
|
+
<span className="text-[10px] text-[var(--text-secondary)] w-3">{hasChildren ? (expanded ? '▾' : '▸') : ''}</span>
|
|
1608
1634
|
<span className="text-[var(--text-primary)]">{node.name}</span>
|
|
1609
1635
|
</button>
|
|
1610
1636
|
{expanded && node.children?.map((child: any) => (
|
|
1611
|
-
<FileTreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} collapseVersion={collapseVersion} />
|
|
1637
|
+
<FileTreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} onLoadChildren={onLoadChildren} collapseVersion={collapseVersion} />
|
|
1612
1638
|
))}
|
|
1613
1639
|
</div>
|
|
1614
1640
|
);
|
|
@@ -209,6 +209,9 @@ function MouseToggle() {
|
|
|
209
209
|
// ─── Pending commands for new terminal panes ────────────────
|
|
210
210
|
|
|
211
211
|
const pendingCommands = new Map<number, string>();
|
|
212
|
+
// Secret env (API keys) to inject via `tmux set-environment` on connect, so the
|
|
213
|
+
// launch command never types `export KEY=value` into the pane (no echo/history).
|
|
214
|
+
const pendingEnv = new Map<number, Record<string, string>>();
|
|
212
215
|
|
|
213
216
|
// ─── Bell notification tracking ─────────────────────────────
|
|
214
217
|
|
|
@@ -439,14 +442,14 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
439
442
|
// Model flag from profile
|
|
440
443
|
const modelFlag = profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
|
|
441
444
|
|
|
442
|
-
// Build env
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const envPrefix =
|
|
445
|
+
// Build env injection from profile (exclude CLAUDE_MODEL — passed via
|
|
446
|
+
// --model). Secret values go via `tmux set-environment` (sent on connect);
|
|
447
|
+
// the prefix only references var NAMES so keys never echo into the pane.
|
|
448
|
+
const envEntries = profileEnv
|
|
449
|
+
? Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
450
|
+
: [];
|
|
451
|
+
const { tmuxEnvPrefix } = await import('@/lib/session-utils');
|
|
452
|
+
const envPrefix = tmuxEnvPrefix(envEntries.map(([k]) => k));
|
|
450
453
|
|
|
451
454
|
// Skip-permissions flag. agent === 'claude' means the agent ID is
|
|
452
455
|
// the base claude — not the resolved path. The check must compare
|
|
@@ -479,6 +482,7 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
479
482
|
const tree = makeTerminal(undefined, projectPath);
|
|
480
483
|
const paneId = firstTerminalId(tree);
|
|
481
484
|
pendingCommands.set(paneId, `${envPrefix}cd "${projectPath}" && ${quotedCmd}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`);
|
|
485
|
+
if (envEntries.length) pendingEnv.set(paneId, Object.fromEntries(envEntries));
|
|
482
486
|
const newTab: TabState = {
|
|
483
487
|
id: nextId++,
|
|
484
488
|
label: agent !== 'claude' ? `${projectName} (${agent})` : projectName,
|
|
@@ -1613,6 +1617,13 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1613
1617
|
const cmd = pendingCommands.get(id);
|
|
1614
1618
|
if (cmd) {
|
|
1615
1619
|
pendingCommands.delete(id);
|
|
1620
|
+
// Inject secret env into the tmux session first (no echo), so the
|
|
1621
|
+
// launch command can pull it via `tmux show-environment`.
|
|
1622
|
+
const penv = pendingEnv.get(id);
|
|
1623
|
+
if (penv && connectedSession && ws?.readyState === WebSocket.OPEN) {
|
|
1624
|
+
pendingEnv.delete(id);
|
|
1625
|
+
ws.send(JSON.stringify({ type: 'setenv', sessionName: connectedSession, env: penv }));
|
|
1626
|
+
}
|
|
1616
1627
|
setTimeout(() => {
|
|
1617
1628
|
if (!disposed && ws?.readyState === WebSocket.OPEN) {
|
|
1618
1629
|
ws.send(JSON.stringify({ type: 'input', data: cmd }));
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState, useEffect, useCallback, useMemo, useRef, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
|
|
4
4
|
import { TerminalSessionPickerLazy, fetchAgentSessions, type PickerSelection } from './TerminalLauncher';
|
|
5
5
|
import { useModelsRegistry } from '@/lib/public-info/use-models-registry';
|
|
6
|
+
import { updateTreeChildren } from '@/lib/fileTree';
|
|
6
7
|
import {
|
|
7
8
|
ReactFlow, Background, Controls, Handle, Position, useReactFlow, ReactFlowProvider,
|
|
8
9
|
type Node, type NodeProps, MarkerType, type NodeChange,
|
|
@@ -559,25 +560,47 @@ function WatchPathPicker({ value, projectPath, onChange }: { value: string; proj
|
|
|
559
560
|
const [search, setSearch] = useState('');
|
|
560
561
|
const [flatFiles, setFlatFiles] = useState<string[]>([]);
|
|
561
562
|
|
|
563
|
+
// Full searchable path list comes from the server's bounded index (files +
|
|
564
|
+
// dirs), computed once. Falls back to walking the loaded tree only if an
|
|
565
|
+
// older server omits the index.
|
|
566
|
+
const buildSearchList = useCallback((data: any) => {
|
|
567
|
+
const fi = data?.fileIndex, di = data?.dirIndex;
|
|
568
|
+
if (Array.isArray(fi) || Array.isArray(di)) {
|
|
569
|
+
return [
|
|
570
|
+
...(di || []).map((d: any) => `${d.path}/`),
|
|
571
|
+
...(fi || []).map((f: any) => f.path),
|
|
572
|
+
];
|
|
573
|
+
}
|
|
574
|
+
const files: string[] = [];
|
|
575
|
+
const walk = (items: any[]) => {
|
|
576
|
+
for (const n of items || []) {
|
|
577
|
+
files.push(n.type === 'dir' ? `${n.path}/` : n.path);
|
|
578
|
+
if (n.children) walk(n.children);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
walk(data?.tree || []);
|
|
582
|
+
return files;
|
|
583
|
+
}, []);
|
|
584
|
+
|
|
562
585
|
const loadTree = useCallback(() => {
|
|
563
586
|
if (!projectPath) return;
|
|
564
587
|
fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`)
|
|
565
588
|
.then(r => r.json())
|
|
566
589
|
.then(data => {
|
|
567
590
|
setTree(data.tree || []);
|
|
568
|
-
|
|
569
|
-
const files: string[] = [];
|
|
570
|
-
const walk = (nodes: any[], prefix = '') => {
|
|
571
|
-
for (const n of nodes || []) {
|
|
572
|
-
const path = prefix ? `${prefix}/${n.name}` : n.name;
|
|
573
|
-
files.push(n.type === 'dir' ? path + '/' : path);
|
|
574
|
-
if (n.children) walk(n.children, path);
|
|
575
|
-
}
|
|
576
|
-
};
|
|
577
|
-
walk(data.tree || []);
|
|
578
|
-
setFlatFiles(files);
|
|
591
|
+
setFlatFiles(buildSearchList(data));
|
|
579
592
|
})
|
|
580
593
|
.catch(() => {});
|
|
594
|
+
}, [buildSearchList, projectPath]);
|
|
595
|
+
|
|
596
|
+
const loadChildren = useCallback(async (path: string) => {
|
|
597
|
+
if (!projectPath) return;
|
|
598
|
+
try {
|
|
599
|
+
const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&treePath=${encodeURIComponent(path)}`);
|
|
600
|
+
const data = await res.json();
|
|
601
|
+
// Tree display only — search already covers the whole repo via the index.
|
|
602
|
+
setTree(prev => updateTreeChildren(prev, path, data.tree || []));
|
|
603
|
+
} catch {}
|
|
581
604
|
}, [projectPath]);
|
|
582
605
|
|
|
583
606
|
const filtered = search ? flatFiles.filter(f => f.toLowerCase().includes(search.toLowerCase())).slice(0, 30) : [];
|
|
@@ -613,7 +636,7 @@ function WatchPathPicker({ value, projectPath, onChange }: { value: string; proj
|
|
|
613
636
|
)) : <div className="px-2 py-1 text-[9px] text-gray-500">No matches</div>
|
|
614
637
|
) : (
|
|
615
638
|
// Tree view (first 2 levels)
|
|
616
|
-
tree.map(n => <PathTreeNode key={n.
|
|
639
|
+
tree.map(n => <PathTreeNode key={n.path} node={n} onSelect={p => { onChange(p); setShowBrowser(false); }} onLoadChildren={loadChildren} />)
|
|
617
640
|
)}
|
|
618
641
|
</div>
|
|
619
642
|
<div className="flex items-center justify-between px-2 py-0.5 border-t border-[#30363d] bg-[#161b22]">
|
|
@@ -626,21 +649,34 @@ function WatchPathPicker({ value, projectPath, onChange }: { value: string; proj
|
|
|
626
649
|
);
|
|
627
650
|
}
|
|
628
651
|
|
|
629
|
-
function PathTreeNode({ node,
|
|
630
|
-
const [expanded, setExpanded] = useState(
|
|
631
|
-
const path =
|
|
652
|
+
function PathTreeNode({ node, onSelect, onLoadChildren, depth = 0 }: { node: any; onSelect: (path: string) => void; onLoadChildren: (path: string) => Promise<void>; depth?: number }) {
|
|
653
|
+
const [expanded, setExpanded] = useState(false);
|
|
654
|
+
const path = node.path;
|
|
632
655
|
const isDir = node.type === 'dir';
|
|
656
|
+
const hasChildren = node.hasChildren || (node.children?.length ?? 0) > 0;
|
|
633
657
|
|
|
634
658
|
if (!isDir && depth > 1) return null; // only show files at top 2 levels
|
|
635
659
|
|
|
660
|
+
const toggleExpanded = async () => {
|
|
661
|
+
if (!isDir) {
|
|
662
|
+
onSelect(path);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const nextExpanded = !expanded;
|
|
666
|
+
setExpanded(nextExpanded);
|
|
667
|
+
if (nextExpanded && hasChildren && !node.children) {
|
|
668
|
+
await onLoadChildren(path);
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
|
|
636
672
|
return (
|
|
637
673
|
<div>
|
|
638
674
|
<div
|
|
639
|
-
onClick={
|
|
675
|
+
onClick={toggleExpanded}
|
|
640
676
|
className="flex items-center px-2 py-0.5 text-[9px] hover:bg-[#161b22] cursor-pointer"
|
|
641
677
|
style={{ paddingLeft: 8 + depth * 12 }}
|
|
642
678
|
>
|
|
643
|
-
<span className="text-gray-500 mr-1 w-3">{isDir ? (expanded ? '▼' : '▶') : ''}</span>
|
|
679
|
+
<span className="text-gray-500 mr-1 w-3">{isDir && hasChildren ? (expanded ? '▼' : '▶') : ''}</span>
|
|
644
680
|
<span className={isDir ? 'text-[var(--accent)]' : 'text-gray-400'}>{isDir ? '📁' : '📄'} {node.name}</span>
|
|
645
681
|
{isDir && (
|
|
646
682
|
<button onClick={e => { e.stopPropagation(); onSelect(path + '/'); }}
|
|
@@ -648,7 +684,7 @@ function PathTreeNode({ node, prefix, onSelect, depth = 0 }: { node: any; prefix
|
|
|
648
684
|
)}
|
|
649
685
|
</div>
|
|
650
686
|
{isDir && expanded && node.children && depth < 2 && (
|
|
651
|
-
node.children.map((c: any) => <PathTreeNode key={c.
|
|
687
|
+
node.children.map((c: any) => <PathTreeNode key={c.path} node={c} onSelect={onSelect} onLoadChildren={onLoadChildren} depth={depth + 1} />)
|
|
652
688
|
)}
|
|
653
689
|
</div>
|
|
654
690
|
);
|
|
@@ -782,29 +818,6 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
782
818
|
const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string; pattern?: string }[]>(
|
|
783
819
|
initial.watch?.targets || []
|
|
784
820
|
);
|
|
785
|
-
const [projectDirs, setProjectDirs] = useState<string[]>([]);
|
|
786
|
-
|
|
787
|
-
useEffect(() => {
|
|
788
|
-
if (!watchEnabled || !projectPath) return;
|
|
789
|
-
fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`)
|
|
790
|
-
.then(r => r.json())
|
|
791
|
-
.then(data => {
|
|
792
|
-
// Collect directories with depth limit (max 2 levels for readability)
|
|
793
|
-
const dirs: string[] = [];
|
|
794
|
-
const walk = (nodes: any[], prefix = '', depth = 0) => {
|
|
795
|
-
for (const n of nodes || []) {
|
|
796
|
-
if (n.type === 'dir') {
|
|
797
|
-
const path = prefix ? `${prefix}/${n.name}` : n.name;
|
|
798
|
-
dirs.push(path);
|
|
799
|
-
if (n.children && depth < 2) walk(n.children, path, depth + 1);
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
};
|
|
803
|
-
walk(data.tree || []);
|
|
804
|
-
setProjectDirs(dirs);
|
|
805
|
-
})
|
|
806
|
-
.catch(() => {});
|
|
807
|
-
}, [watchEnabled, projectPath]);
|
|
808
821
|
|
|
809
822
|
const applyPreset = (p: Omit<AgentConfig, 'id'>) => {
|
|
810
823
|
setLabel(p.label); setIcon(p.icon); setRole(p.role);
|
|
@@ -2296,8 +2309,11 @@ function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId
|
|
|
2296
2309
|
const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
|
|
2297
2310
|
commands.push(profileVarsToReset.map(v => `unset ${v}`).join('; '));
|
|
2298
2311
|
const envWithoutForge = Object.entries(envWithoutModel).filter(([k]) => !k.startsWith('FORGE_'));
|
|
2299
|
-
if (envWithoutForge.length > 0) {
|
|
2300
|
-
|
|
2312
|
+
if (envWithoutForge.length > 0 && msg.sessionName) {
|
|
2313
|
+
ws.send(JSON.stringify({ type: 'setenv', sessionName: msg.sessionName, env: Object.fromEntries(envWithoutForge) }));
|
|
2314
|
+
const { tmuxEnvPrefix } = await import('@/lib/session-utils');
|
|
2315
|
+
const wrap = tmuxEnvPrefix(envWithoutForge.map(([k]) => k));
|
|
2316
|
+
if (wrap) commands.push(wrap.replace(/ && $/, ''));
|
|
2301
2317
|
}
|
|
2302
2318
|
const forgeVars = Object.entries(envWithoutModel).filter(([k]) => k.startsWith('FORGE_'));
|
|
2303
2319
|
if (forgeVars.length > 0) {
|
|
@@ -2541,13 +2557,18 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
2541
2557
|
const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
|
|
2542
2558
|
commands.push(profileVarsToReset.map(v => `unset ${v}`).join('; '));
|
|
2543
2559
|
|
|
2544
|
-
// 2.
|
|
2560
|
+
// 2. Profile vars (secrets) — inject via `tmux set-environment` (no
|
|
2561
|
+
// echo), then reference by NAME in the launch command so API keys
|
|
2562
|
+
// never appear in the pane or shell history.
|
|
2545
2563
|
const envWithoutForge = Object.entries(envWithoutModel).filter(([k]) => !k.startsWith('FORGE_'));
|
|
2546
|
-
if (envWithoutForge.length > 0) {
|
|
2547
|
-
|
|
2564
|
+
if (envWithoutForge.length > 0 && sessionNameRef.current) {
|
|
2565
|
+
ws.send(JSON.stringify({ type: 'setenv', sessionName: sessionNameRef.current, env: Object.fromEntries(envWithoutForge) }));
|
|
2566
|
+
const { tmuxEnvPrefix } = await import('@/lib/session-utils');
|
|
2567
|
+
const wrap = tmuxEnvPrefix(envWithoutForge.map(([k]) => k));
|
|
2568
|
+
if (wrap) commands.push(wrap.replace(/ && $/, ''));
|
|
2548
2569
|
}
|
|
2549
2570
|
|
|
2550
|
-
// 3. Export FORGE vars
|
|
2571
|
+
// 3. Export FORGE vars (not secret — plain export is fine)
|
|
2551
2572
|
const forgeVars = Object.entries(envWithoutModel).filter(([k]) => k.startsWith('FORGE_'));
|
|
2552
2573
|
if (forgeVars.length > 0) {
|
|
2553
2574
|
commands.push(forgeVars.map(([k, v]) => `export ${k}="${v}"`).join('; '));
|
package/lib/agents/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { execFileSync, execSync } from 'node:child_process';
|
|
7
7
|
import { loadSettings } from '../settings';
|
|
8
|
+
import { decryptSecret } from '../crypto';
|
|
8
9
|
|
|
9
10
|
// Cache absolute-path resolution per command — `which <cmd>` is cheap
|
|
10
11
|
// but resolveTerminalLaunch is on the hot path for every chat tool call.
|
|
@@ -311,6 +312,14 @@ export function resolveTerminalLaunch(agentId?: string, scene: 'terminal' | 'tas
|
|
|
311
312
|
if (model === 'default') model = undefined;
|
|
312
313
|
}
|
|
313
314
|
}
|
|
315
|
+
// Decrypt any enc:… env values (e.g. CLAUDE_CODE_OAUTH_TOKEN written by the
|
|
316
|
+
// onboarding wizard). decryptSecret is a no-op on plaintext, so template-
|
|
317
|
+
// derived plaintext env passes through untouched.
|
|
318
|
+
if (env) {
|
|
319
|
+
for (const k of Object.keys(env)) {
|
|
320
|
+
if (typeof env[k] === 'string' && env[k].startsWith('enc:')) env[k] = decryptSecret(env[k]);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
314
323
|
|
|
315
324
|
return {
|
|
316
325
|
// Prefer the user-configured absolute binary path over the bare command
|
|
@@ -104,6 +104,21 @@ async function ensureSession(telegramChatId: number): Promise<string> {
|
|
|
104
104
|
return id;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/** List recent chat sessions (for the /chat picker). Best-effort: [] on error. */
|
|
108
|
+
export async function listChatSessions(limit = 20): Promise<Array<{ id: string; title: string; updated_at?: string }>> {
|
|
109
|
+
try {
|
|
110
|
+
const r = await fetch(`${BASE}/api/sessions?limit=${limit}`);
|
|
111
|
+
if (!r.ok) return [];
|
|
112
|
+
const j = await r.json();
|
|
113
|
+
return Array.isArray(j?.sessions) ? j.sessions : [];
|
|
114
|
+
} catch { return []; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Create a fresh chat session, returns its id (null on failure). */
|
|
118
|
+
export async function newChatSession(title: string): Promise<string | null> {
|
|
119
|
+
try { return await createSession(title); } catch { return null; }
|
|
120
|
+
}
|
|
121
|
+
|
|
107
122
|
async function openSse(sessionId: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {
|
|
108
123
|
const res = await fetch(`${BASE}/api/sessions/${encodeURIComponent(sessionId)}/events`, {
|
|
109
124
|
headers: { accept: 'text/event-stream' },
|
package/lib/fileTree.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Shared helpers for the lazily-loaded file trees served by /api/code.
|
|
2
|
+
// The tree is fetched one directory level at a time; children for a directory
|
|
3
|
+
// are merged in on demand. Kept generic so each consumer can use its own node
|
|
4
|
+
// shape (CodeViewer, ProjectDetail, WorkspaceView) as long as it carries a
|
|
5
|
+
// `path` and an optional `children` array.
|
|
6
|
+
|
|
7
|
+
export interface FileTreeNode {
|
|
8
|
+
path: string;
|
|
9
|
+
children?: FileTreeNode[];
|
|
10
|
+
hasChildren?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Return a new tree with `children` spliced in under the node whose `path`
|
|
15
|
+
* matches. Pure — does not mutate the input. `hasChildren` is recomputed so a
|
|
16
|
+
* directory that turns out to be empty loses its expand affordance.
|
|
17
|
+
*/
|
|
18
|
+
export function updateTreeChildren<T extends { path: string; children?: T[]; hasChildren?: boolean }>(
|
|
19
|
+
nodes: T[],
|
|
20
|
+
path: string,
|
|
21
|
+
children: T[],
|
|
22
|
+
): T[] {
|
|
23
|
+
return nodes.map(node => {
|
|
24
|
+
if (node.path === path) return { ...node, children, hasChildren: children.length > 0 };
|
|
25
|
+
if (node.children) return { ...node, children: updateTreeChildren(node.children, path, children) };
|
|
26
|
+
return node;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -83,3 +83,5 @@ Next to the **Automation** tab in the left-side nav sits a small **Activity** su
|
|
|
83
83
|
- **User menu (▾)** — `⚙ Settings` + `💬 Chat (web) ↗` at the top (Chat opens in a new tab so the dashboard isn't replaced); then a divider, then the periodic-check screens `📊 Monitor` (background watches, processes, queues), `🔐 Login Status` (connector creds), `💰 Usage` (token/cost analytics), `📜 Logs`, `📱 Mobile View ↗`; then `⏻ Logout`.
|
|
84
84
|
|
|
85
85
|
Periodic-check screens (Monitor / Login Status / Usage) live inside the user menu so the top bar only shows things worth glancing at.
|
|
86
|
+
|
|
87
|
+
**Mobile View** (`/mobile`, opened from the user menu or auto on phones) defaults to a **💬 Chat** tab — the Forge chat agent, same backend as the web `/chat` and the Telegram chat. Pick or start a session and talk; replies stream live. Toggle to the **⌨ Terminal** tab to browse/continue Claude Code sessions per project (the original mobile view). `PC` switches to the desktop layout.
|
|
@@ -53,6 +53,17 @@ Each agent entry in `settings.agents` supports:
|
|
|
53
53
|
| `models` | object | Model overrides per context: `terminal`, `task`, `telegram`, `help`, `mobile` |
|
|
54
54
|
| `profile` | string | Linked profile ID — applies that profile's env/model when launching |
|
|
55
55
|
|
|
56
|
+
### Claude authentication (host vs container)
|
|
57
|
+
|
|
58
|
+
The onboarding wizard's **CLI Agent** step detects your deployment shape and adapts how Claude Code authenticates:
|
|
59
|
+
|
|
60
|
+
- **Host** (desktop/laptop): run `claude login` once in a terminal after onboarding — it stores credentials in the OS keychain.
|
|
61
|
+
- **Container** (Docker / k8s): `claude login` can't reach a keychain. Instead, on a machine where you *can* log in, run `claude setup-token`, then paste the resulting token into the wizard. It's saved encrypted as `CLAUDE_CODE_OAUTH_TOKEN` in the agent's `env` and injected at launch.
|
|
62
|
+
|
|
63
|
+
Detection signals (any → container): `FORGE_CONTAINER=1`, `/.dockerenv`, or a containerized cgroup. The wizard pre-selects the right mode but you can override it.
|
|
64
|
+
|
|
65
|
+
Agent `env` secrets (including the OAuth token) are encrypted at rest (`enc:…`) and injected into terminals via `tmux set-environment`, so they never appear in the pane echo or shell history.
|
|
66
|
+
|
|
56
67
|
### CLI Type
|
|
57
68
|
|
|
58
69
|
The `cliType` field determines how Forge interacts with the agent:
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
| `/peek <project>` | Preview running session |
|
|
25
25
|
| `/cancel <id>` | Cancel a task |
|
|
26
26
|
| `/retry <id>` | Retry a failed task |
|
|
27
|
+
| `/chat` | Enter chat mode — pick an existing chat session or start a new one, then every message goes to the Forge chat agent (same as the web `/chat`) |
|
|
28
|
+
| `/endchat` | Leave chat mode |
|
|
27
29
|
| `/tunnel_start <password>` | Start Cloudflare Tunnel (returns URL + code) |
|
|
28
30
|
| `/tunnel_stop` | Stop tunnel |
|
|
29
31
|
| `/tunnel_code <password>` | Get session code for remote login |
|
|
@@ -33,6 +35,7 @@
|
|
|
33
35
|
- Reply to a task message to interact with it
|
|
34
36
|
- Send `"project: instructions"` to quick-create a task
|
|
35
37
|
- Numbered lists — reply with a number to select
|
|
38
|
+
- **Chat mode**: `/chat` → reply with a number to pick a session (`0` = new) → talk normally; the agent streams replies just like the web `/chat`. `/endchat` exits. Other slash-commands still work while in chat mode.
|
|
36
39
|
|
|
37
40
|
## Troubleshooting
|
|
38
41
|
|
|
@@ -38,7 +38,9 @@ Notes:
|
|
|
38
38
|
## Features
|
|
39
39
|
|
|
40
40
|
### Code Tab
|
|
41
|
-
- File tree browser
|
|
41
|
+
- File tree browser — loads lazily (folders expand on demand), so very large repos open instantly
|
|
42
|
+
- File search uses a fast server-built index; in very large repos the index is sampled per directory, and a note appears when results may be partial
|
|
43
|
+
- "Locate in file tree" (on a changed file) reveals it in the tree
|
|
42
44
|
- Syntax-highlighted code viewer
|
|
43
45
|
- Git diff view (click changed files)
|
|
44
46
|
- Git operations: commit, push, pull
|