@aion0/forge 0.10.78 → 0.10.80
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 +9 -8
- 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/api/tasks/[id]/hook/stop/route.ts +15 -0
- package/app/api/tasks/route.ts +2 -1
- package/cli/mw.mjs +7 -5
- package/cli/mw.ts +8 -6
- package/components/CodeViewer.tsx +127 -41
- package/components/Dashboard.tsx +6 -2
- package/components/DocsViewer.tsx +34 -22
- package/components/HelpTerminal.tsx +9 -5
- package/components/OnboardingWizard.tsx +65 -1
- package/components/ProjectDetail.tsx +33 -7
- package/components/TaskDetail.tsx +28 -1
- package/components/TmuxTaskTerminal.tsx +105 -0
- package/components/WebTerminal.tsx +26 -8
- package/components/WorkspaceView.tsx +68 -47
- package/docs/design_automation_records/Automation Redesign.dc.html +2019 -0
- package/docs/design_automation_records/README.md +232 -0
- package/lib/agents/index.ts +9 -0
- package/lib/chat/agent-loop.ts +6 -0
- package/lib/chat/tool-dispatcher.ts +110 -9
- package/lib/fileTree.ts +28 -0
- package/lib/help-docs/01-settings.md +11 -0
- package/lib/help-docs/05-pipelines.md +31 -0
- package/lib/help-docs/07-projects.md +3 -1
- package/lib/help-docs/25-chat-tools.md +23 -0
- package/lib/pipeline.ts +27 -3
- package/lib/session-utils.ts +19 -0
- package/lib/task-manager.ts +73 -3
- package/lib/task-tmux-backend.ts +625 -0
- package/lib/terminal-standalone.ts +17 -0
- package/lib/workspace/skill-installer.ts +18 -8
- package/package.json +1 -1
- package/proxy.ts +5 -4
- package/src/core/db/database.ts +1 -0
- package/src/types/index.ts +3 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useRef, memo, useCallback, useMemo } from 'react';
|
|
3
|
+
import { useState, useEffect, useRef, memo, useCallback, useMemo, lazy, Suspense } from 'react';
|
|
4
4
|
import MarkdownContent from './MarkdownContent';
|
|
5
5
|
import NewTaskModal from './NewTaskModal';
|
|
6
6
|
import type { Task, TaskLogEntry } from '@/src/types';
|
|
7
7
|
|
|
8
|
+
const TmuxTaskTerminal = lazy(() => import('./TmuxTaskTerminal'));
|
|
9
|
+
|
|
8
10
|
// Bound the rendered log/diff to keep React from choking on huge sessions.
|
|
9
11
|
// Each LogEntry can include MarkdownContent and tool_use payloads (often
|
|
10
12
|
// kilobytes per entry); rendering even ~200 fat entries can take a beat.
|
|
@@ -29,6 +31,7 @@ export default function TaskDetail({
|
|
|
29
31
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
30
32
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
31
33
|
const [tab, setTab] = useState<'log' | 'diff' | 'result'>('log');
|
|
34
|
+
const [showSession, setShowSession] = useState(false);
|
|
32
35
|
const [expandedTools, setExpandedTools] = useState<Set<number>>(new Set());
|
|
33
36
|
const [followUpText, setFollowUpText] = useState('');
|
|
34
37
|
const [editing, setEditing] = useState(false);
|
|
@@ -166,6 +169,15 @@ export default function TaskDetail({
|
|
|
166
169
|
<span className="text-[10px] text-[var(--text-secondary)] font-mono">{task.id}</span>
|
|
167
170
|
</div>
|
|
168
171
|
<div className="flex items-center gap-2">
|
|
172
|
+
{task.backend === 'tmux' && (
|
|
173
|
+
<button
|
|
174
|
+
onClick={() => setShowSession(s => !s)}
|
|
175
|
+
className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${showSession ? 'text-white bg-violet-500 border-violet-500' : 'text-violet-400 border-violet-400/30 hover:bg-violet-400 hover:text-white'}`}
|
|
176
|
+
title="Toggle tmux session panel"
|
|
177
|
+
>
|
|
178
|
+
⌨ Session
|
|
179
|
+
</button>
|
|
180
|
+
)}
|
|
169
181
|
<button onClick={() => setEditing(true)} className="text-[10px] px-2 py-0.5 text-[var(--accent)] border border-[var(--accent)]/30 rounded hover:bg-[var(--accent)] hover:text-white">
|
|
170
182
|
Edit
|
|
171
183
|
</button>
|
|
@@ -191,6 +203,12 @@ export default function TaskDetail({
|
|
|
191
203
|
{task.startedAt && <span>Started: {new Date(task.startedAt).toLocaleString()}</span>}
|
|
192
204
|
{task.completedAt && <span>Completed: {new Date(task.completedAt).toLocaleString()}</span>}
|
|
193
205
|
{task.costUSD != null && <span>Cost: ${task.costUSD.toFixed(4)}</span>}
|
|
206
|
+
{task.backend === 'tmux' && (
|
|
207
|
+
<span className="px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400 font-medium">tmux</span>
|
|
208
|
+
)}
|
|
209
|
+
{task.agent && task.agent !== 'claude' && (
|
|
210
|
+
<span className="font-mono text-[var(--text-secondary)]">{task.agent}</span>
|
|
211
|
+
)}
|
|
194
212
|
</div>
|
|
195
213
|
</div>
|
|
196
214
|
|
|
@@ -318,6 +336,15 @@ export default function TaskDetail({
|
|
|
318
336
|
</div>
|
|
319
337
|
)}
|
|
320
338
|
|
|
339
|
+
{/* Tmux session panel — shown at the bottom when ⌨ Session is toggled */}
|
|
340
|
+
{showSession && task.backend === 'tmux' && (
|
|
341
|
+
<div className="border-t border-violet-500/30 shrink-0" style={{ height: 320 }}>
|
|
342
|
+
<Suspense fallback={<div className="p-3 text-[var(--text-secondary)] text-xs">Loading terminal…</div>}>
|
|
343
|
+
<TmuxTaskTerminal taskId={task.id} />
|
|
344
|
+
</Suspense>
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
|
|
321
348
|
{editing && (
|
|
322
349
|
<NewTaskModal
|
|
323
350
|
editTask={{ id: task.id, projectName: task.projectName, prompt: task.prompt, priority: task.priority, mode: task.mode, scheduledAt: task.scheduledAt }}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
function getWsUrl(): string {
|
|
6
|
+
if (typeof window === 'undefined') return 'ws://localhost:8404';
|
|
7
|
+
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
8
|
+
const webPort = parseInt(window.location.port) || 8403;
|
|
9
|
+
return `${proto}//${window.location.hostname}:${webPort + 1}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function TmuxTaskTerminal({ taskId }: { taskId: string }) {
|
|
13
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const el = containerRef.current;
|
|
17
|
+
if (!el) return;
|
|
18
|
+
|
|
19
|
+
let disposed = false;
|
|
20
|
+
let termRef: import('@xterm/xterm').Terminal | null = null;
|
|
21
|
+
let wsRef: WebSocket | null = null;
|
|
22
|
+
let roRef: ResizeObserver | null = null;
|
|
23
|
+
|
|
24
|
+
Promise.all([
|
|
25
|
+
import('@xterm/xterm'),
|
|
26
|
+
import('@xterm/addon-fit'),
|
|
27
|
+
]).then(([{ Terminal }, { FitAddon }]) => {
|
|
28
|
+
if (disposed) return;
|
|
29
|
+
|
|
30
|
+
const cs = getComputedStyle(document.documentElement);
|
|
31
|
+
const tv = (n: string) => cs.getPropertyValue(n).trim();
|
|
32
|
+
|
|
33
|
+
const term = new Terminal({
|
|
34
|
+
cursorBlink: true,
|
|
35
|
+
fontSize: 13,
|
|
36
|
+
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
37
|
+
scrollback: 10000,
|
|
38
|
+
theme: {
|
|
39
|
+
background: tv('--term-bg') || '#0d1117',
|
|
40
|
+
foreground: tv('--term-fg') || '#c9d1d9',
|
|
41
|
+
cursor: tv('--term-cursor') || '#7c5bf0',
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
termRef = term;
|
|
45
|
+
|
|
46
|
+
const fitAddon = new FitAddon();
|
|
47
|
+
term.loadAddon(fitAddon);
|
|
48
|
+
term.open(el);
|
|
49
|
+
setTimeout(() => { try { fitAddon.fit(); } catch {} }, 50);
|
|
50
|
+
|
|
51
|
+
const ro = new ResizeObserver(() => {
|
|
52
|
+
try { fitAddon.fit(); } catch {}
|
|
53
|
+
if (wsRef?.readyState === WebSocket.OPEN) {
|
|
54
|
+
wsRef.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
roRef = ro;
|
|
58
|
+
ro.observe(el);
|
|
59
|
+
|
|
60
|
+
const sessionName = `fgt-${taskId}`;
|
|
61
|
+
const ws = new WebSocket(getWsUrl());
|
|
62
|
+
wsRef = ws;
|
|
63
|
+
|
|
64
|
+
ws.onopen = () => {
|
|
65
|
+
if (!disposed) {
|
|
66
|
+
ws.send(JSON.stringify({ type: 'attach', sessionName, cols: term.cols, rows: term.rows }));
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
ws.onmessage = (e) => {
|
|
70
|
+
try {
|
|
71
|
+
const msg = JSON.parse(e.data);
|
|
72
|
+
if (msg.type === 'output') term.write(msg.data);
|
|
73
|
+
} catch {}
|
|
74
|
+
};
|
|
75
|
+
ws.onclose = () => {
|
|
76
|
+
if (!disposed) term.write('\r\n\x1b[90m[disconnected — session may have ended]\x1b[0m\r\n');
|
|
77
|
+
};
|
|
78
|
+
ws.onerror = () => {
|
|
79
|
+
if (!disposed) term.write('\r\n\x1b[91m[connection error]\x1b[0m\r\n');
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
term.onData(data => {
|
|
83
|
+
if (wsRef?.readyState === WebSocket.OPEN) {
|
|
84
|
+
wsRef.send(JSON.stringify({ type: 'input', data }));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return () => {
|
|
90
|
+
disposed = true;
|
|
91
|
+
roRef?.disconnect();
|
|
92
|
+
wsRef?.close();
|
|
93
|
+
termRef?.dispose();
|
|
94
|
+
};
|
|
95
|
+
}, [taskId]);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="flex flex-col h-full bg-[#0d1117]">
|
|
99
|
+
<div className="text-[10px] text-[var(--text-secondary)] px-3 py-1 border-b border-[var(--border)] shrink-0 font-mono">
|
|
100
|
+
fgt-{taskId}
|
|
101
|
+
</div>
|
|
102
|
+
<div ref={containerRef} className="flex-1 overflow-hidden p-1" />
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -14,6 +14,7 @@ import '@xterm/xterm/css/xterm.css';
|
|
|
14
14
|
export interface WebTerminalHandle {
|
|
15
15
|
openSessionInTerminal: (sessionId: string, projectPath: string) => void;
|
|
16
16
|
openProjectTerminal: (projectPath: string, projectName: string, agentId?: string, resumeMode?: boolean, sessionId?: string, profileEnv?: Record<string, string>) => void;
|
|
17
|
+
openExistingSession: (sessionName: string, label: string) => void;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export interface WebTerminalProps {
|
|
@@ -209,6 +210,9 @@ function MouseToggle() {
|
|
|
209
210
|
// ─── Pending commands for new terminal panes ────────────────
|
|
210
211
|
|
|
211
212
|
const pendingCommands = new Map<number, string>();
|
|
213
|
+
// Secret env (API keys) to inject via `tmux set-environment` on connect, so the
|
|
214
|
+
// launch command never types `export KEY=value` into the pane (no echo/history).
|
|
215
|
+
const pendingEnv = new Map<number, Record<string, string>>();
|
|
212
216
|
|
|
213
217
|
// ─── Bell notification tracking ─────────────────────────────
|
|
214
218
|
|
|
@@ -439,14 +443,14 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
439
443
|
// Model flag from profile
|
|
440
444
|
const modelFlag = profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
|
|
441
445
|
|
|
442
|
-
// Build env
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const envPrefix =
|
|
446
|
+
// Build env injection from profile (exclude CLAUDE_MODEL — passed via
|
|
447
|
+
// --model). Secret values go via `tmux set-environment` (sent on connect);
|
|
448
|
+
// the prefix only references var NAMES so keys never echo into the pane.
|
|
449
|
+
const envEntries = profileEnv
|
|
450
|
+
? Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
451
|
+
: [];
|
|
452
|
+
const { tmuxEnvPrefix } = await import('@/lib/session-utils');
|
|
453
|
+
const envPrefix = tmuxEnvPrefix(envEntries.map(([k]) => k));
|
|
450
454
|
|
|
451
455
|
// Skip-permissions flag. agent === 'claude' means the agent ID is
|
|
452
456
|
// the base claude — not the resolved path. The check must compare
|
|
@@ -479,6 +483,7 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
479
483
|
const tree = makeTerminal(undefined, projectPath);
|
|
480
484
|
const paneId = firstTerminalId(tree);
|
|
481
485
|
pendingCommands.set(paneId, `${envPrefix}cd "${projectPath}" && ${quotedCmd}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`);
|
|
486
|
+
if (envEntries.length) pendingEnv.set(paneId, Object.fromEntries(envEntries));
|
|
482
487
|
const newTab: TabState = {
|
|
483
488
|
id: nextId++,
|
|
484
489
|
label: agent !== 'claude' ? `${projectName} (${agent})` : projectName,
|
|
@@ -497,6 +502,12 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
497
502
|
if (targetTabId !== null) setActiveTabId(targetTabId);
|
|
498
503
|
}, 0);
|
|
499
504
|
},
|
|
505
|
+
openExistingSession(sessionName: string, label: string) {
|
|
506
|
+
const tree = makeTerminal(sessionName);
|
|
507
|
+
const newTab: TabState = { id: nextId++, label, tree, ratios: {}, activeId: firstTerminalId(tree) };
|
|
508
|
+
setTabs(prev => [...prev, newTab]);
|
|
509
|
+
setTimeout(() => setActiveTabId(newTab.id), 0);
|
|
510
|
+
},
|
|
500
511
|
}), [skipPermissions]);
|
|
501
512
|
|
|
502
513
|
// ─── Tab operations ───────────────────────────────────
|
|
@@ -1613,6 +1624,13 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1613
1624
|
const cmd = pendingCommands.get(id);
|
|
1614
1625
|
if (cmd) {
|
|
1615
1626
|
pendingCommands.delete(id);
|
|
1627
|
+
// Inject secret env into the tmux session first (no echo), so the
|
|
1628
|
+
// launch command can pull it via `tmux show-environment`.
|
|
1629
|
+
const penv = pendingEnv.get(id);
|
|
1630
|
+
if (penv && connectedSession && ws?.readyState === WebSocket.OPEN) {
|
|
1631
|
+
pendingEnv.delete(id);
|
|
1632
|
+
ws.send(JSON.stringify({ type: 'setenv', sessionName: connectedSession, env: penv }));
|
|
1633
|
+
}
|
|
1616
1634
|
setTimeout(() => {
|
|
1617
1635
|
if (!disposed && ws?.readyState === WebSocket.OPEN) {
|
|
1618
1636
|
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('; '));
|