@aion0/forge 0.5.21 → 0.5.23
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/.forge/agent-context.json +1 -1
- package/.forge/mcp.json +1 -1
- package/RELEASE_NOTES.md +6 -10
- package/app/api/plugins/route.ts +75 -0
- package/components/Dashboard.tsx +1 -0
- package/components/PipelineEditor.tsx +135 -9
- package/components/PluginsPanel.tsx +472 -0
- package/components/ProjectDetail.tsx +36 -98
- package/components/SessionView.tsx +4 -4
- package/components/SettingsModal.tsx +166 -67
- package/components/SkillsPanel.tsx +14 -5
- package/components/TerminalLauncher.tsx +398 -0
- package/components/WebTerminal.tsx +84 -84
- package/components/WorkspaceView.tsx +256 -76
- package/lib/agents/index.ts +7 -4
- package/lib/builtin-plugins/docker.yaml +70 -0
- package/lib/builtin-plugins/http.yaml +66 -0
- package/lib/builtin-plugins/jenkins.yaml +92 -0
- package/lib/builtin-plugins/llm-vision.yaml +85 -0
- package/lib/builtin-plugins/playwright.yaml +111 -0
- package/lib/builtin-plugins/shell-command.yaml +60 -0
- package/lib/builtin-plugins/slack.yaml +48 -0
- package/lib/builtin-plugins/webhook.yaml +56 -0
- package/lib/forge-mcp-server.ts +116 -2
- package/lib/pipeline.ts +62 -5
- package/lib/plugins/executor.ts +347 -0
- package/lib/plugins/registry.ts +228 -0
- package/lib/plugins/types.ts +103 -0
- package/lib/project-sessions.ts +7 -2
- package/lib/session-utils.ts +7 -3
- package/lib/terminal-standalone.ts +6 -34
- package/lib/workspace/agent-worker.ts +1 -1
- package/lib/workspace/orchestrator.ts +443 -136
- package/lib/workspace/presets.ts +5 -3
- package/lib/workspace/session-monitor.ts +14 -10
- package/lib/workspace/types.ts +3 -1
- package/lib/workspace-standalone.ts +38 -21
- package/package.json +1 -1
- package/qa/.forge/agent-context.json +1 -1
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TerminalLauncher — unified terminal session picker and open utilities.
|
|
5
|
+
*
|
|
6
|
+
* Two main exports:
|
|
7
|
+
* 1. TerminalSessionPicker — dialog component for choosing how to open a terminal.
|
|
8
|
+
* Shows: "Current Session" (highlighted), "New Session", expandable list of other sessions.
|
|
9
|
+
*
|
|
10
|
+
* 2. openWorkspaceTerminal / buildProjectTerminalConfig — helpers to open a terminal
|
|
11
|
+
* correctly depending on context (workspace smith vs project/VibeCoding).
|
|
12
|
+
*
|
|
13
|
+
* Workspace smiths:
|
|
14
|
+
* - Need FORGE env vars injected via the forge launch script.
|
|
15
|
+
* - Must go through open_terminal API → daemon creates tmux → FloatingTerminal attaches.
|
|
16
|
+
*
|
|
17
|
+
* Project / VibeCoding:
|
|
18
|
+
* - Build profileEnv client-side from agent profile.
|
|
19
|
+
* - FloatingTerminal creates a new tmux session and runs the CLI.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useState, useEffect } from 'react';
|
|
23
|
+
|
|
24
|
+
// ─── Types ────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface SessionInfo {
|
|
27
|
+
id: string;
|
|
28
|
+
modified: string; // ISO string
|
|
29
|
+
size: number; // bytes
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Selection result from TerminalSessionPicker.
|
|
34
|
+
* mode='current' → open with currentSessionId (resume)
|
|
35
|
+
* mode='new' → open a fresh session (no --resume)
|
|
36
|
+
* mode='session' → open with a specific sessionId (resume)
|
|
37
|
+
*/
|
|
38
|
+
export type PickerSelection =
|
|
39
|
+
| { mode: 'current'; sessionId: string }
|
|
40
|
+
| { mode: 'new' }
|
|
41
|
+
| { mode: 'session'; sessionId: string };
|
|
42
|
+
|
|
43
|
+
// ─── Session Fetchers ─────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fetch sessions for a workspace agent (workDir-scoped, via workspace API).
|
|
47
|
+
* Used by workspace smith Open Terminal.
|
|
48
|
+
*/
|
|
49
|
+
export async function fetchAgentSessions(workspaceId: string, agentId: string): Promise<SessionInfo[]> {
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(`/api/workspace/${workspaceId}/smith`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({ action: 'sessions', agentId }),
|
|
55
|
+
});
|
|
56
|
+
const data = await res.json();
|
|
57
|
+
return data.sessions || [];
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fetch sessions for a project (project-level, via claude-sessions API).
|
|
65
|
+
* Used by ProjectDetail terminal button and VibeCoding / SessionView.
|
|
66
|
+
*/
|
|
67
|
+
export async function fetchProjectSessions(projectName: string): Promise<SessionInfo[]> {
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(`/api/claude-sessions/${encodeURIComponent(projectName)}`);
|
|
70
|
+
const data = await res.json();
|
|
71
|
+
if (!Array.isArray(data)) return [];
|
|
72
|
+
return data.map((s: any) => ({
|
|
73
|
+
id: s.sessionId || s.id || '',
|
|
74
|
+
modified: s.modified || '',
|
|
75
|
+
size: s.fileSize || s.size || 0,
|
|
76
|
+
}));
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Formatting helpers ───────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function formatRelativeTime(iso: string): string {
|
|
85
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
86
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
87
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
88
|
+
return new Date(iso).toLocaleDateString();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatSize(bytes: number): string {
|
|
92
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
93
|
+
if (bytes < 1_048_576) return `${(bytes / 1024).toFixed(0)}KB`;
|
|
94
|
+
return `${(bytes / 1_048_576).toFixed(1)}MB`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── SessionItem ──────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function SessionItem({ session, onSelect }: {
|
|
100
|
+
session: SessionInfo;
|
|
101
|
+
onSelect: () => void;
|
|
102
|
+
}) {
|
|
103
|
+
const [expanded, setExpanded] = useState(false);
|
|
104
|
+
const [copied, setCopied] = useState(false);
|
|
105
|
+
|
|
106
|
+
const copyId = (e: React.MouseEvent) => {
|
|
107
|
+
e.stopPropagation();
|
|
108
|
+
navigator.clipboard.writeText(session.id).then(() => {
|
|
109
|
+
setCopied(true);
|
|
110
|
+
setTimeout(() => setCopied(false), 1500);
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="rounded border border-[#21262d] hover:border-[#30363d] hover:bg-[#161b22] transition-colors">
|
|
116
|
+
<div className="flex items-center gap-2 px-3 py-1.5 cursor-pointer" onClick={() => setExpanded(!expanded)}>
|
|
117
|
+
<span className="text-[8px] text-gray-600">{expanded ? '▼' : '▶'}</span>
|
|
118
|
+
<span className="text-[9px] text-gray-400 font-mono">{session.id.slice(0, 8)}</span>
|
|
119
|
+
<span className="text-[8px] text-gray-600">{formatRelativeTime(session.modified)}</span>
|
|
120
|
+
<span className="text-[8px] text-gray-600">{formatSize(session.size)}</span>
|
|
121
|
+
<button
|
|
122
|
+
onClick={e => { e.stopPropagation(); onSelect(); }}
|
|
123
|
+
className="ml-auto text-[8px] px-1.5 py-0.5 rounded bg-[#238636]/20 text-[#3fb950] hover:bg-[#238636]/40"
|
|
124
|
+
>
|
|
125
|
+
Resume
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
{expanded && (
|
|
129
|
+
<div className="px-3 pb-2 flex items-center gap-1.5">
|
|
130
|
+
<code className="text-[8px] text-gray-500 font-mono bg-[#161b22] px-1.5 py-0.5 rounded border border-[#21262d] select-all flex-1 overflow-hidden text-ellipsis">
|
|
131
|
+
{session.id}
|
|
132
|
+
</code>
|
|
133
|
+
<button
|
|
134
|
+
onClick={copyId}
|
|
135
|
+
className="text-[8px] px-1.5 py-0.5 rounded bg-[#30363d] text-gray-400 hover:text-white hover:bg-[#484f58] shrink-0"
|
|
136
|
+
>
|
|
137
|
+
{copied ? '✓' : 'Copy'}
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── TerminalSessionPicker ────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Unified dialog for choosing how to open a terminal session.
|
|
149
|
+
*
|
|
150
|
+
* Props:
|
|
151
|
+
* agentLabel — Display name for the agent / project (shown in title).
|
|
152
|
+
* currentSessionId — Bound/fixed session to show as "Current Session". null → no current.
|
|
153
|
+
* sessions — List of all available sessions (pre-fetched or lazy). If null, loading spinner shown.
|
|
154
|
+
* supportsSession — Whether the agent supports claude --resume. Default true.
|
|
155
|
+
* onSelect — Called with the picker result when user chooses an option.
|
|
156
|
+
* onCancel — Called when user dismisses without selecting.
|
|
157
|
+
*/
|
|
158
|
+
export function TerminalSessionPicker({
|
|
159
|
+
agentLabel,
|
|
160
|
+
currentSessionId,
|
|
161
|
+
sessions,
|
|
162
|
+
supportsSession = true,
|
|
163
|
+
onSelect,
|
|
164
|
+
onCancel,
|
|
165
|
+
}: {
|
|
166
|
+
agentLabel: string;
|
|
167
|
+
currentSessionId: string | null;
|
|
168
|
+
sessions: SessionInfo[] | null; // null = loading
|
|
169
|
+
supportsSession?: boolean;
|
|
170
|
+
onSelect: (selection: PickerSelection) => void;
|
|
171
|
+
onCancel: () => void;
|
|
172
|
+
}) {
|
|
173
|
+
const [showAll, setShowAll] = useState(false);
|
|
174
|
+
|
|
175
|
+
const isClaude = supportsSession !== false;
|
|
176
|
+
|
|
177
|
+
// Other sessions = all sessions except the current one
|
|
178
|
+
const otherSessions = sessions?.filter(s => s.id !== currentSessionId) ?? [];
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div
|
|
182
|
+
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
183
|
+
style={{ background: 'rgba(0,0,0,0.75)' }}
|
|
184
|
+
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
|
185
|
+
>
|
|
186
|
+
<div
|
|
187
|
+
className="w-80 rounded-lg border border-[#30363d] p-4 shadow-xl"
|
|
188
|
+
style={{ background: '#0d1117' }}
|
|
189
|
+
>
|
|
190
|
+
<div className="text-sm font-bold text-white mb-3">⌨️ {agentLabel}</div>
|
|
191
|
+
|
|
192
|
+
<div className="space-y-2">
|
|
193
|
+
{/* Current Session — shown first, highlighted if exists */}
|
|
194
|
+
{isClaude && currentSessionId && (
|
|
195
|
+
<button
|
|
196
|
+
onClick={() => onSelect({ mode: 'current', sessionId: currentSessionId })}
|
|
197
|
+
className="w-full text-left px-3 py-2 rounded border border-[#3fb950]/60 hover:border-[#3fb950] hover:bg-[#161b22] transition-colors"
|
|
198
|
+
>
|
|
199
|
+
<div className="text-xs text-white font-semibold flex items-center gap-1.5">
|
|
200
|
+
<span className="text-[#3fb950]">●</span> Current Session
|
|
201
|
+
</div>
|
|
202
|
+
<div className="text-[9px] text-gray-500 font-mono mt-0.5">
|
|
203
|
+
{currentSessionId.slice(0, 16)}…
|
|
204
|
+
</div>
|
|
205
|
+
</button>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{/* New Session */}
|
|
209
|
+
<button
|
|
210
|
+
onClick={() => onSelect({ mode: 'new' })}
|
|
211
|
+
className="w-full text-left px-3 py-2 rounded border border-[#30363d] hover:border-[#58a6ff] hover:bg-[#161b22] transition-colors"
|
|
212
|
+
>
|
|
213
|
+
<div className="text-xs text-white font-semibold">
|
|
214
|
+
{isClaude ? 'New Session' : 'Open Terminal'}
|
|
215
|
+
</div>
|
|
216
|
+
<div className="text-[9px] text-gray-500">
|
|
217
|
+
{isClaude ? 'Start fresh claude session' : 'Launch terminal'}
|
|
218
|
+
</div>
|
|
219
|
+
</button>
|
|
220
|
+
|
|
221
|
+
{/* Loading indicator */}
|
|
222
|
+
{isClaude && sessions === null && (
|
|
223
|
+
<div className="text-[9px] text-gray-600 text-center py-1">Loading sessions…</div>
|
|
224
|
+
)}
|
|
225
|
+
|
|
226
|
+
{/* Toggle for other sessions */}
|
|
227
|
+
{isClaude && otherSessions.length > 0 && (
|
|
228
|
+
<button
|
|
229
|
+
onClick={() => setShowAll(!showAll)}
|
|
230
|
+
className="w-full text-[9px] text-gray-500 hover:text-white py-1"
|
|
231
|
+
>
|
|
232
|
+
{showAll ? '▼' : '▶'} Other sessions ({otherSessions.length})
|
|
233
|
+
</button>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{/* Other sessions list */}
|
|
237
|
+
{showAll && otherSessions.map(s => (
|
|
238
|
+
<SessionItem
|
|
239
|
+
key={s.id}
|
|
240
|
+
session={s}
|
|
241
|
+
onSelect={() => onSelect({ mode: 'session', sessionId: s.id })}
|
|
242
|
+
/>
|
|
243
|
+
))}
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<button
|
|
247
|
+
onClick={onCancel}
|
|
248
|
+
className="w-full mt-3 text-[9px] text-gray-500 hover:text-white"
|
|
249
|
+
>
|
|
250
|
+
Cancel
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── TerminalSessionPicker with lazy fetch ─────────────────
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Higher-level picker that fetches sessions automatically.
|
|
261
|
+
* Accepts a `fetchSessions` async function — result populates the session list.
|
|
262
|
+
*/
|
|
263
|
+
export function TerminalSessionPickerLazy({
|
|
264
|
+
agentLabel,
|
|
265
|
+
currentSessionId,
|
|
266
|
+
fetchSessions,
|
|
267
|
+
supportsSession = true,
|
|
268
|
+
onSelect,
|
|
269
|
+
onCancel,
|
|
270
|
+
}: {
|
|
271
|
+
agentLabel: string;
|
|
272
|
+
currentSessionId: string | null;
|
|
273
|
+
fetchSessions: () => Promise<SessionInfo[]>;
|
|
274
|
+
supportsSession?: boolean;
|
|
275
|
+
onSelect: (selection: PickerSelection) => void;
|
|
276
|
+
onCancel: () => void;
|
|
277
|
+
}) {
|
|
278
|
+
const [sessions, setSessions] = useState<SessionInfo[] | null>(null);
|
|
279
|
+
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
if (!supportsSession) {
|
|
282
|
+
setSessions([]);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
fetchSessions().then(setSessions).catch(() => setSessions([]));
|
|
286
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<TerminalSessionPicker
|
|
290
|
+
agentLabel={agentLabel}
|
|
291
|
+
currentSessionId={currentSessionId}
|
|
292
|
+
sessions={sessions}
|
|
293
|
+
supportsSession={supportsSession}
|
|
294
|
+
onSelect={onSelect}
|
|
295
|
+
onCancel={onCancel}
|
|
296
|
+
/>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ─── Workspace Terminal Open ──────────────────────────────
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Result from resolving how to open a workspace terminal.
|
|
304
|
+
* When tmuxSession is set → FloatingTerminal should attach (existingSession=tmuxSession).
|
|
305
|
+
* When tmuxSession is null → daemon couldn't create session; fall back to dialog or skip.
|
|
306
|
+
*/
|
|
307
|
+
export interface WorkspaceTerminalInfo {
|
|
308
|
+
tmuxSession: string | null;
|
|
309
|
+
cliCmd?: string;
|
|
310
|
+
cliType?: string;
|
|
311
|
+
supportsSession?: boolean;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Ask the orchestrator to create/find the tmux session for a workspace agent.
|
|
316
|
+
* Returns tmuxSession name that FloatingTerminal can attach to.
|
|
317
|
+
* This is the ONLY correct way to open a workspace terminal — ensures FORGE env vars
|
|
318
|
+
* are injected via the forge launch script (not client-side profileEnv).
|
|
319
|
+
*/
|
|
320
|
+
export async function resolveWorkspaceTerminal(
|
|
321
|
+
workspaceId: string,
|
|
322
|
+
agentId: string,
|
|
323
|
+
): Promise<WorkspaceTerminalInfo> {
|
|
324
|
+
try {
|
|
325
|
+
const res = await fetch(`/api/workspace/${workspaceId}/smith`, {
|
|
326
|
+
method: 'POST',
|
|
327
|
+
headers: { 'Content-Type': 'application/json' },
|
|
328
|
+
body: JSON.stringify({ action: 'open_terminal', agentId }),
|
|
329
|
+
});
|
|
330
|
+
const data = await res.json();
|
|
331
|
+
return {
|
|
332
|
+
tmuxSession: data.tmuxSession || null,
|
|
333
|
+
cliCmd: data.cliCmd,
|
|
334
|
+
cliType: data.cliType,
|
|
335
|
+
supportsSession: data.supportsSession ?? true,
|
|
336
|
+
};
|
|
337
|
+
} catch {
|
|
338
|
+
return { tmuxSession: null };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Resolve agent info for a workspace agent (resolveOnly — no session created).
|
|
344
|
+
* Used to get cliCmd, cliType, env, model, supportsSession without side effects.
|
|
345
|
+
*/
|
|
346
|
+
export async function resolveWorkspaceAgentInfo(
|
|
347
|
+
workspaceId: string,
|
|
348
|
+
agentId: string,
|
|
349
|
+
): Promise<{ cliCmd?: string; cliType?: string; env?: Record<string, string>; model?: string; supportsSession?: boolean }> {
|
|
350
|
+
try {
|
|
351
|
+
const res = await fetch(`/api/workspace/${workspaceId}/smith`, {
|
|
352
|
+
method: 'POST',
|
|
353
|
+
headers: { 'Content-Type': 'application/json' },
|
|
354
|
+
body: JSON.stringify({ action: 'open_terminal', agentId, resolveOnly: true }),
|
|
355
|
+
});
|
|
356
|
+
return await res.json();
|
|
357
|
+
} catch {
|
|
358
|
+
return {};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ─── Project Terminal Config ──────────────────────────────
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Result for opening a project terminal.
|
|
366
|
+
* FloatingTerminal uses profileEnv + resumeSessionId when creating a new tmux session.
|
|
367
|
+
*/
|
|
368
|
+
export interface ProjectTerminalConfig {
|
|
369
|
+
profileEnv: Record<string, string>;
|
|
370
|
+
resumeSessionId?: string;
|
|
371
|
+
cliCmd?: string;
|
|
372
|
+
cliType?: string;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Build config for opening a project terminal (VibeCoding / ProjectDetail).
|
|
377
|
+
* Agent env and model are resolved server-side via /api/agents?resolve=<agentId>.
|
|
378
|
+
* FORGE vars are NOT included here — project terminals don't use workspace context.
|
|
379
|
+
*/
|
|
380
|
+
export async function buildProjectTerminalConfig(
|
|
381
|
+
agentId: string,
|
|
382
|
+
resumeSessionId?: string,
|
|
383
|
+
): Promise<ProjectTerminalConfig> {
|
|
384
|
+
try {
|
|
385
|
+
const res = await fetch(`/api/agents?resolve=${encodeURIComponent(agentId)}`);
|
|
386
|
+
const info = await res.json();
|
|
387
|
+
const profileEnv: Record<string, string> = { ...(info.env || {}) };
|
|
388
|
+
if (info.model) profileEnv.CLAUDE_MODEL = info.model;
|
|
389
|
+
return {
|
|
390
|
+
profileEnv,
|
|
391
|
+
resumeSessionId,
|
|
392
|
+
cliCmd: info.cliCmd,
|
|
393
|
+
cliType: info.cliType,
|
|
394
|
+
};
|
|
395
|
+
} catch {
|
|
396
|
+
return { profileEnv: {}, resumeSessionId };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useRef, useCallback, memo, useImperativeHandle, forwardRef } from 'react';
|
|
4
|
+
import { TerminalSessionPickerLazy, fetchProjectSessions } from './TerminalLauncher';
|
|
4
5
|
import { Terminal } from '@xterm/xterm';
|
|
5
6
|
import { FitAddon } from '@xterm/addon-fit';
|
|
6
7
|
import { WebglAddon } from '@xterm/addon-webgl';
|
|
@@ -219,6 +220,7 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
219
220
|
const [refreshKeys, setRefreshKeys] = useState<Record<number, number>>({});
|
|
220
221
|
const [tabCodeOpen, setTabCodeOpen] = useState<Record<number, boolean>>({});
|
|
221
222
|
const [showNewTabModal, setShowNewTabModal] = useState(false);
|
|
223
|
+
const [vibePickerInfo, setVibePickerInfo] = useState<{ projectPath: string; projectName: string; agentId: string; profileEnv?: Record<string, string>; supportsSession: boolean; currentSessionId: string | null } | null>(null);
|
|
222
224
|
const [projectRoots, setProjectRoots] = useState<string[]>([]);
|
|
223
225
|
const [allProjects, setAllProjects] = useState<{ name: string; path: string; root: string }[]>([]);
|
|
224
226
|
const [skipPermissions, setSkipPermissions] = useState(false);
|
|
@@ -332,24 +334,42 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
332
334
|
},
|
|
333
335
|
async openProjectTerminal(projectPath: string, projectName: string, agentId?: string, resumeMode?: boolean, sessionId?: string, profileEnv?: Record<string, string>) {
|
|
334
336
|
const agent = agentId || 'claude';
|
|
335
|
-
// Resolve CLI command — profiles use base agent's binary
|
|
336
|
-
const knownClis = ['claude', 'codex', 'aider'];
|
|
337
|
-
const agentCmd = knownClis.includes(agent) ? agent : 'claude';
|
|
338
337
|
|
|
339
|
-
//
|
|
338
|
+
// Resolve agent info via API — get correct cliCmd, cliType, supportsSession
|
|
339
|
+
let agentCmd = 'claude';
|
|
340
|
+
let supportsSession = true;
|
|
341
|
+
let agentSkipFlag = '';
|
|
342
|
+
try {
|
|
343
|
+
const resolveRes = await fetch(`/api/agents?resolve=${encodeURIComponent(agent)}`);
|
|
344
|
+
const info = await resolveRes.json();
|
|
345
|
+
agentCmd = info.cliCmd || 'claude';
|
|
346
|
+
supportsSession = info.supportsSession ?? true;
|
|
347
|
+
// Merge profile env if not already provided
|
|
348
|
+
if (!profileEnv && (info.env || info.model)) {
|
|
349
|
+
const pe: Record<string, string> = { ...(info.env || {}) };
|
|
350
|
+
if (info.model) pe.CLAUDE_MODEL = info.model;
|
|
351
|
+
profileEnv = pe;
|
|
352
|
+
}
|
|
353
|
+
// Get skip-permissions flag from agent config
|
|
354
|
+
const agentsRes = await fetch('/api/agents');
|
|
355
|
+
const agentsData = await agentsRes.json();
|
|
356
|
+
const agentConfig = (agentsData.agents || []).find((a: any) => a.id === agent);
|
|
357
|
+
agentSkipFlag = agentConfig?.skipPermissionsFlag || '';
|
|
358
|
+
} catch {}
|
|
359
|
+
|
|
360
|
+
// Resume flag: explicit sessionId > fixedSession > -c (only for session-capable agents)
|
|
340
361
|
let resumeFlag = '';
|
|
341
|
-
if (
|
|
362
|
+
if (supportsSession) {
|
|
342
363
|
if (sessionId) resumeFlag = ` --resume ${sessionId}`;
|
|
343
364
|
else if (resumeMode) resumeFlag = ' -c';
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
resolveFixedSession(projectPath).then(fixedId => {
|
|
365
|
+
// Override with fixedSession if no explicit sessionId
|
|
366
|
+
if (!sessionId && projectPath) {
|
|
367
|
+
try {
|
|
368
|
+
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
369
|
+
const fixedId = await resolveFixedSession(projectPath);
|
|
350
370
|
if (fixedId) resumeFlag = ` --resume ${fixedId}`;
|
|
351
|
-
}
|
|
352
|
-
|
|
371
|
+
} catch {}
|
|
372
|
+
}
|
|
353
373
|
}
|
|
354
374
|
|
|
355
375
|
// Model flag from profile
|
|
@@ -364,25 +384,13 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
364
384
|
: '';
|
|
365
385
|
const envPrefix = envExports ? envExports + ' && ' : '';
|
|
366
386
|
|
|
367
|
-
//
|
|
387
|
+
// Skip-permissions flag
|
|
368
388
|
let sf = '';
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const agentData = await agentRes.json();
|
|
372
|
-
const agentConfig = (agentData.agents || []).find((a: any) => a.id === agent);
|
|
373
|
-
if (agentConfig?.skipPermissionsFlag && skipPermissions) {
|
|
374
|
-
sf = ` ${agentConfig.skipPermissionsFlag}`;
|
|
375
|
-
} else if (skipPermissions && agentCmd === 'claude') {
|
|
376
|
-
sf = ' --dangerously-skip-permissions';
|
|
377
|
-
}
|
|
378
|
-
} catch {
|
|
379
|
-
if (skipPermissions && agentCmd === 'claude') sf = ' --dangerously-skip-permissions';
|
|
389
|
+
if (skipPermissions) {
|
|
390
|
+
sf = agentSkipFlag ? ` ${agentSkipFlag}` : (agentCmd === 'claude' ? ' --dangerously-skip-permissions' : '');
|
|
380
391
|
}
|
|
381
392
|
|
|
382
|
-
//
|
|
383
|
-
if (fixedSessionPending) await fixedSessionPending;
|
|
384
|
-
|
|
385
|
-
// MCP + env vars for claude-code agents
|
|
393
|
+
// MCP config for claude-code agents
|
|
386
394
|
let mcpFlag = '';
|
|
387
395
|
if (agentCmd === 'claude' && projectPath) {
|
|
388
396
|
try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {}
|
|
@@ -391,7 +399,8 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
391
399
|
let targetTabId: number | null = null;
|
|
392
400
|
|
|
393
401
|
setTabs(prev => {
|
|
394
|
-
|
|
402
|
+
// Reuse existing tab only if same project AND same agent
|
|
403
|
+
const existing = prev.find(t => t.projectPath === projectPath && (!t.agent || t.agent === agent));
|
|
395
404
|
if (existing) {
|
|
396
405
|
targetTabId = existing.id;
|
|
397
406
|
return prev;
|
|
@@ -401,11 +410,12 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
401
410
|
pendingCommands.set(paneId, `${envPrefix}cd "${projectPath}" && ${agentCmd}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`);
|
|
402
411
|
const newTab: TabState = {
|
|
403
412
|
id: nextId++,
|
|
404
|
-
label: projectName,
|
|
413
|
+
label: agent !== 'claude' ? `${projectName} (${agentCmd})` : projectName,
|
|
405
414
|
tree,
|
|
406
415
|
ratios: {},
|
|
407
416
|
activeId: paneId,
|
|
408
417
|
projectPath,
|
|
418
|
+
agent,
|
|
409
419
|
};
|
|
410
420
|
targetTabId = newTab.id;
|
|
411
421
|
return [...prev, newTab];
|
|
@@ -900,59 +910,31 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
900
910
|
agents={availableAgents}
|
|
901
911
|
defaultAgentId={defaultAgentId}
|
|
902
912
|
onSelect={async (a) => {
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
} catch {}
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
// Skip permissions flag
|
|
932
|
-
let sf = '';
|
|
933
|
-
if (skipPermissions) {
|
|
934
|
-
const agentRes = await fetch('/api/agents');
|
|
935
|
-
const agentData = await agentRes.json();
|
|
936
|
-
const cfg = (agentData.agents || []).find((ag: any) => ag.id === a.id);
|
|
937
|
-
sf = cfg?.skipPermissionsFlag ? ` ${cfg.skipPermissionsFlag}` : (cliCmd === 'claude' ? ' --dangerously-skip-permissions' : '');
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
// MCP + env vars
|
|
941
|
-
let mcpFlag = '';
|
|
942
|
-
if (cliCmd === 'claude') {
|
|
943
|
-
try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(p.path); } catch {}
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
cmd = `${envExports}cd "${p.path}" && ${cliCmd}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`;
|
|
947
|
-
} catch {
|
|
948
|
-
cmd = `cd "${p.path}" && ${a.id}\n`;
|
|
949
|
-
}
|
|
950
|
-
const tree = makeTerminal(undefined, p.path);
|
|
951
|
-
const paneId = firstTerminalId(tree);
|
|
952
|
-
pendingCommands.set(paneId, cmd);
|
|
953
|
-
const newTab: TabState = { id: nextId++, label: p.name || 'Terminal', tree, ratios: {}, activeId: paneId, projectPath: p.path, agent: a.id };
|
|
954
|
-
setTabs(prev => [...prev, newTab]);
|
|
955
|
-
setActiveTabId(newTab.id);
|
|
913
|
+
setShowNewTabModal(false); setExpandedRoot(null);
|
|
914
|
+
try {
|
|
915
|
+
const resolveRes = await fetch(`/api/agents?resolve=${encodeURIComponent(a.id)}`);
|
|
916
|
+
const info = await resolveRes.json();
|
|
917
|
+
const profileEnv: Record<string, string> = { ...(info.env || {}) };
|
|
918
|
+
if (info.model) profileEnv.CLAUDE_MODEL = info.model;
|
|
919
|
+
let currentSessionId: string | null = null;
|
|
920
|
+
if (info.supportsSession) {
|
|
921
|
+
try {
|
|
922
|
+
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
923
|
+
currentSessionId = await resolveFixedSession(p.path) || null;
|
|
924
|
+
} catch {}
|
|
925
|
+
}
|
|
926
|
+
setVibePickerInfo({
|
|
927
|
+
projectPath: p.path, projectName: p.name, agentId: a.id,
|
|
928
|
+
profileEnv: Object.keys(profileEnv).length > 0 ? profileEnv : undefined,
|
|
929
|
+
supportsSession: info.supportsSession ?? true,
|
|
930
|
+
currentSessionId,
|
|
931
|
+
});
|
|
932
|
+
} catch {
|
|
933
|
+
// Fallback: open directly without picker
|
|
934
|
+
window.dispatchEvent(new CustomEvent('forge:open-terminal', {
|
|
935
|
+
detail: { projectPath: p.path, projectName: p.name, agentId: a.id },
|
|
936
|
+
}));
|
|
937
|
+
}
|
|
956
938
|
}}
|
|
957
939
|
/>
|
|
958
940
|
</div>
|
|
@@ -1019,6 +1001,24 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
1019
1001
|
</div>
|
|
1020
1002
|
)}
|
|
1021
1003
|
|
|
1004
|
+
{/* VibeCoding Terminal Session Picker */}
|
|
1005
|
+
{vibePickerInfo && (
|
|
1006
|
+
<TerminalSessionPickerLazy
|
|
1007
|
+
agentLabel={vibePickerInfo.projectName}
|
|
1008
|
+
currentSessionId={vibePickerInfo.currentSessionId}
|
|
1009
|
+
supportsSession={vibePickerInfo.supportsSession}
|
|
1010
|
+
fetchSessions={() => fetchProjectSessions(vibePickerInfo.projectName)}
|
|
1011
|
+
onSelect={(sel) => {
|
|
1012
|
+
const info = vibePickerInfo;
|
|
1013
|
+
setVibePickerInfo(null);
|
|
1014
|
+
const detail: any = { projectPath: info.projectPath, projectName: info.projectName, agentId: info.agentId, profileEnv: info.profileEnv };
|
|
1015
|
+
if (sel.mode !== 'new') { detail.resumeMode = true; detail.sessionId = sel.sessionId; }
|
|
1016
|
+
window.dispatchEvent(new CustomEvent('forge:open-terminal', { detail }));
|
|
1017
|
+
}}
|
|
1018
|
+
onCancel={() => setVibePickerInfo(null)}
|
|
1019
|
+
/>
|
|
1020
|
+
)}
|
|
1021
|
+
|
|
1022
1022
|
{/* Terminal panes — render all tabs, hide inactive */}
|
|
1023
1023
|
{tabs.map(tab => (
|
|
1024
1024
|
<div key={tab.id} className={`flex-1 min-h-0 ${tab.id === activeTabId ? '' : 'hidden'}`}>
|