@aion0/forge 0.4.16 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/RELEASE_NOTES.md +21 -14
- package/app/api/agents/route.ts +17 -0
- package/app/api/delivery/[id]/route.ts +62 -0
- package/app/api/delivery/route.ts +40 -0
- package/app/api/mobile-chat/route.ts +13 -7
- package/app/api/monitor/route.ts +10 -6
- package/app/api/pipelines/[id]/route.ts +16 -3
- package/app/api/tasks/route.ts +2 -1
- package/app/api/workspace/[id]/agents/route.ts +35 -0
- package/app/api/workspace/[id]/memory/route.ts +23 -0
- package/app/api/workspace/[id]/smith/route.ts +22 -0
- package/app/api/workspace/[id]/stream/route.ts +28 -0
- package/app/api/workspace/route.ts +100 -0
- package/app/global-error.tsx +10 -4
- package/app/icon.ico +0 -0
- package/app/layout.tsx +2 -2
- package/app/login/LoginForm.tsx +96 -0
- package/app/login/page.tsx +7 -98
- package/app/page.tsx +2 -2
- package/bin/forge-server.mjs +13 -1
- package/check-forge-status.sh +9 -0
- package/components/ConversationEditor.tsx +411 -0
- package/components/ConversationGraphView.tsx +347 -0
- package/components/ConversationTerminalView.tsx +303 -0
- package/components/Dashboard.tsx +36 -39
- package/components/DashboardWrapper.tsx +9 -0
- package/components/DeliveryFlowEditor.tsx +491 -0
- package/components/DeliveryList.tsx +230 -0
- package/components/DeliveryWorkspace.tsx +589 -0
- package/components/DocTerminal.tsx +10 -2
- package/components/DocsViewer.tsx +10 -2
- package/components/HelpTerminal.tsx +11 -6
- package/components/InlinePipelineView.tsx +111 -0
- package/components/MobileView.tsx +20 -0
- package/components/MonitorPanel.tsx +9 -4
- package/components/NewTaskModal.tsx +32 -0
- package/components/PipelineEditor.tsx +49 -6
- package/components/PipelineView.tsx +482 -64
- package/components/ProjectDetail.tsx +314 -56
- package/components/ProjectManager.tsx +49 -4
- package/components/SessionView.tsx +27 -13
- package/components/SettingsModal.tsx +790 -124
- package/components/SkillsPanel.tsx +31 -8
- package/components/TaskBoard.tsx +3 -0
- package/components/WebTerminal.tsx +257 -43
- package/components/WorkspaceTree.tsx +221 -0
- package/components/WorkspaceView.tsx +2245 -0
- package/install.sh +2 -2
- package/lib/agents/claude-adapter.ts +104 -0
- package/lib/agents/generic-adapter.ts +64 -0
- package/lib/agents/index.ts +242 -0
- package/lib/agents/types.ts +70 -0
- package/lib/artifacts.ts +106 -0
- package/lib/delivery.ts +787 -0
- package/lib/forge-skills/forge-inbox.md +37 -0
- package/lib/forge-skills/forge-send.md +40 -0
- package/lib/forge-skills/forge-status.md +32 -0
- package/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/lib/help-docs/00-overview.md +7 -1
- package/lib/help-docs/01-settings.md +159 -2
- package/lib/help-docs/05-pipelines.md +89 -0
- package/lib/help-docs/07-projects.md +35 -1
- package/lib/help-docs/11-workspace.md +254 -0
- package/lib/help-docs/CLAUDE.md +7 -2
- package/lib/init.ts +60 -10
- package/lib/pipeline.ts +537 -1
- package/lib/settings.ts +115 -22
- package/lib/skills.ts +249 -372
- package/lib/task-manager.ts +113 -33
- package/lib/telegram-bot.ts +33 -1
- package/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/lib/workspace/agent-bus.ts +416 -0
- package/lib/workspace/agent-worker.ts +667 -0
- package/lib/workspace/backends/api-backend.ts +262 -0
- package/lib/workspace/backends/cli-backend.ts +479 -0
- package/lib/workspace/index.ts +82 -0
- package/lib/workspace/manager.ts +136 -0
- package/lib/workspace/orchestrator.ts +1914 -0
- package/lib/workspace/persistence.ts +310 -0
- package/lib/workspace/presets.ts +170 -0
- package/lib/workspace/skill-installer.ts +188 -0
- package/lib/workspace/smith-memory.ts +498 -0
- package/lib/workspace/types.ts +231 -0
- package/lib/workspace/watch-manager.ts +288 -0
- package/lib/workspace-standalone.ts +814 -0
- package/middleware.ts +1 -0
- package/next-env.d.ts +1 -1
- package/package.json +4 -1
- package/src/config/index.ts +12 -1
- package/src/core/db/database.ts +1 -0
- package/start.sh +7 -0
|
@@ -130,15 +130,38 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
130
130
|
fetchRules();
|
|
131
131
|
};
|
|
132
132
|
|
|
133
|
+
const [syncProgress, setSyncProgress] = useState('');
|
|
133
134
|
const sync = async () => {
|
|
134
135
|
setSyncing(true);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
136
|
+
setSyncProgress('');
|
|
137
|
+
try {
|
|
138
|
+
let enrichedTotal = 0;
|
|
139
|
+
let total = 0;
|
|
140
|
+
// Loop: each call enriches a batch of info.json, continue until all done
|
|
141
|
+
for (let round = 0; round < 20; round++) { // safety limit
|
|
142
|
+
setSyncProgress(total > 0 ? `${Math.min(enrichedTotal, total)}/${total}` : '');
|
|
143
|
+
const res = await fetch('/api/skills', {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { 'Content-Type': 'application/json' },
|
|
146
|
+
body: JSON.stringify({ action: 'sync' }),
|
|
147
|
+
});
|
|
148
|
+
const data = await res.json();
|
|
149
|
+
if (data.error) {
|
|
150
|
+
alert(`Sync error: ${data.error}`);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
total = data.total || 0;
|
|
154
|
+
enrichedTotal += data.enriched || 0;
|
|
155
|
+
await fetchSkills();
|
|
156
|
+
// If remaining is 0 or enriched nothing, we're done
|
|
157
|
+
if (!data.remaining || data.enriched === 0) break;
|
|
158
|
+
}
|
|
159
|
+
} catch (err: any) {
|
|
160
|
+
alert(`Sync failed: ${err.message || 'Network error'}`);
|
|
161
|
+
} finally {
|
|
162
|
+
setSyncing(false);
|
|
163
|
+
setSyncProgress('');
|
|
164
|
+
}
|
|
142
165
|
};
|
|
143
166
|
|
|
144
167
|
const install = async (name: string, target: string) => {
|
|
@@ -261,7 +284,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
261
284
|
disabled={syncing}
|
|
262
285
|
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
263
286
|
>
|
|
264
|
-
{syncing ? '
|
|
287
|
+
{syncing ? `Syncing${syncProgress ? ` ${syncProgress}` : '...'}` : 'Sync'}
|
|
265
288
|
</button>
|
|
266
289
|
</div>
|
|
267
290
|
{/* Search — hide on rules tab */}
|
package/components/TaskBoard.tsx
CHANGED
|
@@ -64,6 +64,9 @@ export default function TaskBoard({
|
|
|
64
64
|
<div className="flex items-center gap-2 mb-0.5">
|
|
65
65
|
<span className={`text-[10px] ${STATUS_COLORS[task.status]}`}>●</span>
|
|
66
66
|
<span className="text-xs font-medium truncate">{task.projectName}</span>
|
|
67
|
+
{(task as any).agent && (task as any).agent !== 'claude' && (
|
|
68
|
+
<span className="text-[8px] px-1 rounded bg-green-900/30 text-green-400">{(task as any).agent}</span>
|
|
69
|
+
)}
|
|
67
70
|
<span className={`text-[9px] ml-auto ${STATUS_COLORS[task.status]}`}>
|
|
68
71
|
{task.scheduledAt && task.status === 'queued'
|
|
69
72
|
? `⏰ ${new Date(task.scheduledAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`
|
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
import { useState, useEffect, useRef, useCallback, memo, useImperativeHandle, forwardRef } from 'react';
|
|
4
4
|
import { Terminal } from '@xterm/xterm';
|
|
5
5
|
import { FitAddon } from '@xterm/addon-fit';
|
|
6
|
+
import { WebglAddon } from '@xterm/addon-webgl';
|
|
7
|
+
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
|
8
|
+
import { SearchAddon } from '@xterm/addon-search';
|
|
6
9
|
import '@xterm/xterm/css/xterm.css';
|
|
7
10
|
|
|
8
11
|
// ─── Imperative API for parent components ────────────────────
|
|
9
12
|
|
|
10
13
|
export interface WebTerminalHandle {
|
|
11
14
|
openSessionInTerminal: (sessionId: string, projectPath: string) => void;
|
|
12
|
-
openProjectTerminal: (projectPath: string, projectName: string) => void;
|
|
15
|
+
openProjectTerminal: (projectPath: string, projectName: string, agentId?: string, resumeMode?: boolean, sessionId?: string, profileEnv?: Record<string, string>) => void;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export interface WebTerminalProps {
|
|
@@ -38,6 +41,7 @@ interface TabState {
|
|
|
38
41
|
activeId: number;
|
|
39
42
|
projectPath?: string;
|
|
40
43
|
bellEnabled?: boolean;
|
|
44
|
+
agent?: string; // agent ID (e.g., 'claude', 'codex', 'aider')
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
// ─── Layout persistence ──────────────────────────────────────
|
|
@@ -219,6 +223,9 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
219
223
|
const [allProjects, setAllProjects] = useState<{ name: string; path: string; root: string }[]>([]);
|
|
220
224
|
const [skipPermissions, setSkipPermissions] = useState(false);
|
|
221
225
|
const [expandedRoot, setExpandedRoot] = useState<string | null>(null);
|
|
226
|
+
const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string; detected?: boolean }[]>([]);
|
|
227
|
+
const [selectedAgent, setSelectedAgent] = useState<string>('');
|
|
228
|
+
const [defaultAgentId, setDefaultAgentId] = useState('claude');
|
|
222
229
|
|
|
223
230
|
// Restore shared state from server after mount
|
|
224
231
|
useEffect(() => {
|
|
@@ -321,22 +328,49 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
321
328
|
setTabs(prev => [...prev, newTab]);
|
|
322
329
|
setTimeout(() => setActiveTabId(newTab.id), 0);
|
|
323
330
|
},
|
|
324
|
-
async openProjectTerminal(projectPath: string, projectName: string) {
|
|
325
|
-
|
|
326
|
-
|
|
331
|
+
async openProjectTerminal(projectPath: string, projectName: string, agentId?: string, resumeMode?: boolean, sessionId?: string, profileEnv?: Record<string, string>) {
|
|
332
|
+
const agent = agentId || 'claude';
|
|
333
|
+
// Resolve CLI command — profiles use base agent's binary
|
|
334
|
+
const knownClis = ['claude', 'codex', 'aider'];
|
|
335
|
+
const agentCmd = knownClis.includes(agent) ? agent : 'claude';
|
|
336
|
+
|
|
337
|
+
// Resume flag from user's choice
|
|
338
|
+
let resumeFlag = '';
|
|
339
|
+
if (agentCmd === 'claude') {
|
|
340
|
+
if (sessionId) resumeFlag = ` --resume ${sessionId}`;
|
|
341
|
+
else if (resumeMode) resumeFlag = ' -c';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Model flag from profile
|
|
345
|
+
const modelFlag = profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
|
|
346
|
+
|
|
347
|
+
// Build env exports from profile (exclude CLAUDE_MODEL — passed via --model)
|
|
348
|
+
const envExports = profileEnv
|
|
349
|
+
? Object.entries(profileEnv)
|
|
350
|
+
.filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
351
|
+
.map(([k, v]) => `export ${k}="${v}"`)
|
|
352
|
+
.join(' && ')
|
|
353
|
+
: '';
|
|
354
|
+
const envPrefix = envExports ? envExports + ' && ' : '';
|
|
355
|
+
|
|
356
|
+
// Get skip-permissions flag
|
|
357
|
+
let sf = '';
|
|
327
358
|
try {
|
|
328
|
-
const
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
359
|
+
const agentRes = await fetch('/api/agents');
|
|
360
|
+
const agentData = await agentRes.json();
|
|
361
|
+
const agentConfig = (agentData.agents || []).find((a: any) => a.id === agent);
|
|
362
|
+
if (agentConfig?.skipPermissionsFlag && skipPermissions) {
|
|
363
|
+
sf = ` ${agentConfig.skipPermissionsFlag}`;
|
|
364
|
+
} else if (skipPermissions && agentCmd === 'claude') {
|
|
365
|
+
sf = ' --dangerously-skip-permissions';
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
if (skipPermissions && agentCmd === 'claude') sf = ' --dangerously-skip-permissions';
|
|
369
|
+
}
|
|
334
370
|
|
|
335
|
-
// Use a ref-stable ID so we can set active after state update
|
|
336
371
|
let targetTabId: number | null = null;
|
|
337
372
|
|
|
338
373
|
setTabs(prev => {
|
|
339
|
-
// Check if there's already a tab for this project
|
|
340
374
|
const existing = prev.find(t => t.projectPath === projectPath);
|
|
341
375
|
if (existing) {
|
|
342
376
|
targetTabId = existing.id;
|
|
@@ -344,7 +378,7 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
344
378
|
}
|
|
345
379
|
const tree = makeTerminal(undefined, projectPath);
|
|
346
380
|
const paneId = firstTerminalId(tree);
|
|
347
|
-
pendingCommands.set(paneId,
|
|
381
|
+
pendingCommands.set(paneId, `${envPrefix}cd "${projectPath}" && ${agentCmd}${resumeFlag}${modelFlag}${sf}\n`);
|
|
348
382
|
const newTab: TabState = {
|
|
349
383
|
id: nextId++,
|
|
350
384
|
label: projectName,
|
|
@@ -626,6 +660,9 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
626
660
|
{tab.label}
|
|
627
661
|
</span>
|
|
628
662
|
)}
|
|
663
|
+
{tab.agent && tab.agent !== 'claude' && (
|
|
664
|
+
<span className="text-[8px] text-[var(--accent)] ml-0.5">{tab.agent}</span>
|
|
665
|
+
)}
|
|
629
666
|
<button
|
|
630
667
|
onClick={(e) => { e.stopPropagation(); toggleBell(tab.id); }}
|
|
631
668
|
className={`text-[10px] ml-1 ${tab.bellEnabled ? 'text-yellow-400' : 'text-gray-600 hover:text-gray-400'}`}
|
|
@@ -644,7 +681,8 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
644
681
|
<button
|
|
645
682
|
onClick={() => {
|
|
646
683
|
setShowNewTabModal(true);
|
|
647
|
-
|
|
684
|
+
setSelectedAgent('');
|
|
685
|
+
// Refresh projects + agents when opening modal
|
|
648
686
|
fetch('/api/projects').then(r => r.json())
|
|
649
687
|
.then((p: { name: string; path: string; root: string }[]) => {
|
|
650
688
|
if (!Array.isArray(p)) return;
|
|
@@ -652,6 +690,12 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
652
690
|
setProjectRoots([...new Set(p.map(proj => proj.root))]);
|
|
653
691
|
})
|
|
654
692
|
.catch(() => {});
|
|
693
|
+
fetch('/api/agents').then(r => r.json())
|
|
694
|
+
.then(data => {
|
|
695
|
+
setAvailableAgents((data.agents || []).filter((a: any) => a.enabled));
|
|
696
|
+
setDefaultAgentId(data.defaultAgent || 'claude');
|
|
697
|
+
})
|
|
698
|
+
.catch(() => {});
|
|
655
699
|
}}
|
|
656
700
|
className="px-2 py-1 text-[11px] text-gray-500 hover:text-white hover:bg-[var(--term-border)]"
|
|
657
701
|
title="New tab"
|
|
@@ -805,13 +849,13 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
805
849
|
onClick={() => { addTab(); setShowNewTabModal(false); setExpandedRoot(null); }}
|
|
806
850
|
className="w-full text-left px-3 py-2 rounded hover:bg-[var(--term-border)] text-[12px] text-gray-300 flex items-center gap-2"
|
|
807
851
|
>
|
|
808
|
-
<span className="text-gray-500">▸</span> Terminal
|
|
852
|
+
<span className="text-gray-500">▸</span> Terminal (no agent)
|
|
809
853
|
</button>
|
|
810
854
|
|
|
811
855
|
{/* Project roots */}
|
|
812
856
|
{projectRoots.length > 0 && (
|
|
813
857
|
<div className="mt-2 pt-2 border-t border-[var(--term-border)]">
|
|
814
|
-
<div className="px-3 py-1 text-[9px] text-gray-500 uppercase">
|
|
858
|
+
<div className="px-3 py-1 text-[9px] text-gray-500 uppercase">Agent in Project</div>
|
|
815
859
|
{projectRoots.map(root => {
|
|
816
860
|
const rootName = root.split('/').pop() || root;
|
|
817
861
|
const isExpanded = expandedRoot === root;
|
|
@@ -829,32 +873,63 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
|
|
|
829
873
|
{isExpanded && (
|
|
830
874
|
<div className="ml-4">
|
|
831
875
|
{rootProjects.map(p => (
|
|
832
|
-
<
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
876
|
+
<div key={p.path} className="flex items-center gap-1 px-3 py-1.5 rounded hover:bg-[var(--term-border)]/50 text-[11px]" title={p.path}>
|
|
877
|
+
<span className="text-gray-600 text-[10px]">↳</span>
|
|
878
|
+
<span className="text-gray-300 truncate">{p.name}</span>
|
|
879
|
+
<AgentButtons
|
|
880
|
+
agents={availableAgents}
|
|
881
|
+
defaultAgentId={defaultAgentId}
|
|
882
|
+
onSelect={async (a) => {
|
|
883
|
+
setShowNewTabModal(false); setExpandedRoot(null);
|
|
884
|
+
let cmd: string;
|
|
885
|
+
try {
|
|
886
|
+
// Resolve terminal launch info (reads profile env/model)
|
|
887
|
+
const resolveRes = await fetch(`/api/agents?resolve=${encodeURIComponent(a.id)}`);
|
|
888
|
+
const info = await resolveRes.json();
|
|
889
|
+
const cliCmd = info.cliCmd || 'claude';
|
|
890
|
+
|
|
891
|
+
// Build env exports from profile
|
|
892
|
+
const profileEnv = { ...(info.env || {}), ...(info.model ? { CLAUDE_MODEL: info.model } : {}) };
|
|
893
|
+
const envEntries = Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL');
|
|
894
|
+
const envExports = envEntries.length > 0
|
|
895
|
+
? envEntries.map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
|
|
896
|
+
: '';
|
|
897
|
+
|
|
898
|
+
// Model flag (claude-code only)
|
|
899
|
+
const modelFlag = info.supportsSession && profileEnv.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
|
|
900
|
+
|
|
901
|
+
// Resume flag (claude-code only)
|
|
902
|
+
let resumeFlag = '';
|
|
903
|
+
if (info.supportsSession) {
|
|
904
|
+
try {
|
|
905
|
+
const sRes = await fetch(`/api/claude-sessions/${encodeURIComponent(p.name)}`);
|
|
906
|
+
const sData = await sRes.json();
|
|
907
|
+
if (Array.isArray(sData) && sData.length > 0) resumeFlag = ' -c';
|
|
908
|
+
} catch {}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Skip permissions flag
|
|
912
|
+
let sf = '';
|
|
913
|
+
if (skipPermissions) {
|
|
914
|
+
const agentRes = await fetch('/api/agents');
|
|
915
|
+
const agentData = await agentRes.json();
|
|
916
|
+
const cfg = (agentData.agents || []).find((ag: any) => ag.id === a.id);
|
|
917
|
+
sf = cfg?.skipPermissionsFlag ? ` ${cfg.skipPermissionsFlag}` : (cliCmd === 'claude' ? ' --dangerously-skip-permissions' : '');
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
cmd = `${envExports}cd "${p.path}" && ${cliCmd}${resumeFlag}${modelFlag}${sf}\n`;
|
|
921
|
+
} catch {
|
|
922
|
+
cmd = `cd "${p.path}" && ${a.id}\n`;
|
|
923
|
+
}
|
|
924
|
+
const tree = makeTerminal(undefined, p.path);
|
|
925
|
+
const paneId = firstTerminalId(tree);
|
|
926
|
+
pendingCommands.set(paneId, cmd);
|
|
927
|
+
const newTab: TabState = { id: nextId++, label: p.name || 'Terminal', tree, ratios: {}, activeId: paneId, projectPath: p.path, agent: a.id };
|
|
928
|
+
setTabs(prev => [...prev, newTab]);
|
|
929
|
+
setActiveTabId(newTab.id);
|
|
930
|
+
}}
|
|
931
|
+
/>
|
|
932
|
+
</div>
|
|
858
933
|
))}
|
|
859
934
|
{rootProjects.length === 0 && (
|
|
860
935
|
<div className="px-3 py-1.5 text-[10px] text-gray-600">No projects</div>
|
|
@@ -943,6 +1018,70 @@ export default WebTerminal;
|
|
|
943
1018
|
|
|
944
1019
|
// ─── Pane renderer ───────────────────────────────────────────
|
|
945
1020
|
|
|
1021
|
+
// ─── Agent shortcut buttons (inline with project name) ──────
|
|
1022
|
+
|
|
1023
|
+
function AgentButtons({ agents, defaultAgentId, onSelect }: {
|
|
1024
|
+
agents: { id: string; name: string; detected?: boolean }[];
|
|
1025
|
+
defaultAgentId: string;
|
|
1026
|
+
onSelect: (agent: { id: string; name: string }) => void;
|
|
1027
|
+
}) {
|
|
1028
|
+
const [showMore, setShowMore] = useState(false);
|
|
1029
|
+
const MAX_INLINE = 3;
|
|
1030
|
+
|
|
1031
|
+
const getAbbr = (id: string) =>
|
|
1032
|
+
id === 'claude' ? 'C' : id === 'codex' ? 'X' : id === 'aider' ? 'A' : id.charAt(0).toUpperCase();
|
|
1033
|
+
|
|
1034
|
+
const btnClass = (id: string, detected?: boolean) => {
|
|
1035
|
+
if (detected === false) return 'w-5 h-5 flex items-center justify-center rounded text-[9px] font-bold bg-gray-800/50 text-gray-600 cursor-not-allowed';
|
|
1036
|
+
if (id === defaultAgentId) return 'w-5 h-5 flex items-center justify-center rounded text-[9px] font-bold bg-green-500/30 text-green-400 hover:bg-green-500 hover:text-white';
|
|
1037
|
+
return 'w-5 h-5 flex items-center justify-center rounded text-[9px] font-bold bg-green-900/30 text-green-300/70 hover:bg-green-700/50 hover:text-green-200';
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
const inline = agents.slice(0, MAX_INLINE);
|
|
1041
|
+
const overflow = agents.slice(MAX_INLINE);
|
|
1042
|
+
|
|
1043
|
+
return (
|
|
1044
|
+
<div className="flex items-center gap-0.5 ml-auto shrink-0 relative">
|
|
1045
|
+
{inline.map(a => (
|
|
1046
|
+
<button
|
|
1047
|
+
key={a.id}
|
|
1048
|
+
title={a.detected === false ? `${a.name} (not installed)` : `Open with ${a.name}`}
|
|
1049
|
+
onClick={() => { if (a.detected !== false) onSelect(a); }}
|
|
1050
|
+
className={btnClass(a.id, a.detected)}
|
|
1051
|
+
>
|
|
1052
|
+
{getAbbr(a.id)}
|
|
1053
|
+
</button>
|
|
1054
|
+
))}
|
|
1055
|
+
{overflow.length > 0 && (
|
|
1056
|
+
<>
|
|
1057
|
+
<button
|
|
1058
|
+
title="More agents"
|
|
1059
|
+
onClick={(e) => { e.stopPropagation(); setShowMore(v => !v); }}
|
|
1060
|
+
className="w-5 h-5 flex items-center justify-center rounded text-[9px] bg-gray-700/50 text-gray-400 hover:bg-gray-600 hover:text-white"
|
|
1061
|
+
>…</button>
|
|
1062
|
+
{showMore && (
|
|
1063
|
+
<>
|
|
1064
|
+
<div className="fixed inset-0 z-40" onClick={() => setShowMore(false)} />
|
|
1065
|
+
<div className="absolute right-0 top-6 z-50 bg-[var(--term-bg)] border border-[var(--term-border)] rounded shadow-lg py-1 min-w-[120px]">
|
|
1066
|
+
{overflow.map(a => (
|
|
1067
|
+
<button
|
|
1068
|
+
key={a.id}
|
|
1069
|
+
onClick={() => { if (a.detected !== false) { setShowMore(false); onSelect(a); } }}
|
|
1070
|
+
className={`w-full text-left px-3 py-1 text-[10px] flex items-center gap-2 ${a.detected === false ? 'text-gray-600 cursor-not-allowed' : 'text-gray-300 hover:bg-[var(--term-border)]'}`}
|
|
1071
|
+
>
|
|
1072
|
+
<span className={btnClass(a.id, a.detected) + ' w-4 h-4 text-[8px]'}>{getAbbr(a.id)}</span>
|
|
1073
|
+
{a.name} {a.detected === false ? '(not installed)' : ''}
|
|
1074
|
+
</button>
|
|
1075
|
+
))}
|
|
1076
|
+
</div>
|
|
1077
|
+
</>
|
|
1078
|
+
)}
|
|
1079
|
+
</>
|
|
1080
|
+
)}
|
|
1081
|
+
</div>
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
946
1085
|
function PaneRenderer({
|
|
947
1086
|
node, activeId, onFocus, ratios, setRatios, onSessionConnected, refreshKeys, skipPermissions, canClose, onClosePane,
|
|
948
1087
|
}: {
|
|
@@ -1108,6 +1247,10 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1108
1247
|
onSessionConnected: (paneId: number, sessionName: string) => void;
|
|
1109
1248
|
}) {
|
|
1110
1249
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
1250
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
1251
|
+
const searchAddonRef = useRef<SearchAddon | null>(null);
|
|
1252
|
+
const [showSearch, setShowSearch] = useState(false);
|
|
1253
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
1111
1254
|
const sessionNameRef = useRef(sessionName);
|
|
1112
1255
|
sessionNameRef.current = sessionName;
|
|
1113
1256
|
const projectPathRef = useRef(projectPath);
|
|
@@ -1133,6 +1276,7 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1133
1276
|
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
|
1134
1277
|
|
|
1135
1278
|
const term = new Terminal({
|
|
1279
|
+
allowProposedApi: true,
|
|
1136
1280
|
cursorBlink: true,
|
|
1137
1281
|
fontSize: 13,
|
|
1138
1282
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
@@ -1196,6 +1340,24 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1196
1340
|
if (el.closest('.hidden') || el.offsetWidth < 50 || el.offsetHeight < 30) return;
|
|
1197
1341
|
initDone = true;
|
|
1198
1342
|
term.open(el);
|
|
1343
|
+
// WebGL: GPU-accelerated rendering with canvas fallback
|
|
1344
|
+
try {
|
|
1345
|
+
const webgl = new WebglAddon();
|
|
1346
|
+
webgl.onContextLoss(() => webgl.dispose());
|
|
1347
|
+
term.loadAddon(webgl);
|
|
1348
|
+
} catch {}
|
|
1349
|
+
// Unicode 11: correct width for CJK characters
|
|
1350
|
+
try {
|
|
1351
|
+
const unicode11 = new Unicode11Addon();
|
|
1352
|
+
term.loadAddon(unicode11);
|
|
1353
|
+
term.unicode.activeVersion = '11';
|
|
1354
|
+
} catch {}
|
|
1355
|
+
// Search: Ctrl/Cmd+F to find text in terminal buffer
|
|
1356
|
+
try {
|
|
1357
|
+
const search = new SearchAddon();
|
|
1358
|
+
term.loadAddon(search);
|
|
1359
|
+
searchAddonRef.current = search;
|
|
1360
|
+
} catch {}
|
|
1199
1361
|
try { fit.fit(); } catch {}
|
|
1200
1362
|
connect();
|
|
1201
1363
|
}
|
|
@@ -1366,6 +1528,19 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1366
1528
|
// Calling it both here and in initTerminal() causes duplicate WebSocket
|
|
1367
1529
|
// connections to the same tmux session, resulting in doubled output.
|
|
1368
1530
|
|
|
1531
|
+
term.attachCustomKeyEventHandler((event: KeyboardEvent) => {
|
|
1532
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'f' && event.type === 'keydown') {
|
|
1533
|
+
setShowSearch(true);
|
|
1534
|
+
setTimeout(() => searchInputRef.current?.focus(), 0);
|
|
1535
|
+
return false;
|
|
1536
|
+
}
|
|
1537
|
+
if (event.key === 'Escape' && event.type === 'keydown') {
|
|
1538
|
+
setShowSearch(false);
|
|
1539
|
+
searchAddonRef.current?.clearDecorations();
|
|
1540
|
+
}
|
|
1541
|
+
return true;
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1369
1544
|
term.onData((data) => {
|
|
1370
1545
|
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
|
|
1371
1546
|
// Arm bell on Enter (user submitted a new prompt)
|
|
@@ -1450,5 +1625,44 @@ const MemoTerminalPane = memo(function TerminalPane({
|
|
|
1450
1625
|
};
|
|
1451
1626
|
}, [id, onSessionConnected]);
|
|
1452
1627
|
|
|
1453
|
-
return
|
|
1628
|
+
return (
|
|
1629
|
+
<div className="relative h-full w-full">
|
|
1630
|
+
<div ref={containerRef} className="h-full w-full" />
|
|
1631
|
+
{showSearch && (
|
|
1632
|
+
<div className="absolute top-1 right-2 flex items-center gap-1 px-2 py-1 rounded border border-[var(--term-border)] shadow-lg z-10"
|
|
1633
|
+
style={{ background: 'var(--term-bg, #1a1b26)' }}
|
|
1634
|
+
onClick={e => e.stopPropagation()}>
|
|
1635
|
+
<input
|
|
1636
|
+
ref={searchInputRef}
|
|
1637
|
+
type="text"
|
|
1638
|
+
value={searchQuery}
|
|
1639
|
+
onChange={e => {
|
|
1640
|
+
setSearchQuery(e.target.value);
|
|
1641
|
+
if (e.target.value) searchAddonRef.current?.findNext(e.target.value, { regex: false, caseSensitive: false, decorations: { matchOverviewRuler: '#888', activeMatchColorOverviewRuler: '#ff0' } });
|
|
1642
|
+
else searchAddonRef.current?.clearDecorations();
|
|
1643
|
+
}}
|
|
1644
|
+
onKeyDown={e => {
|
|
1645
|
+
if (e.key === 'Enter') {
|
|
1646
|
+
if (e.shiftKey) searchAddonRef.current?.findPrevious(searchQuery, { regex: false, caseSensitive: false, decorations: { matchOverviewRuler: '#888', activeMatchColorOverviewRuler: '#ff0' } });
|
|
1647
|
+
else searchAddonRef.current?.findNext(searchQuery, { regex: false, caseSensitive: false, decorations: { matchOverviewRuler: '#888', activeMatchColorOverviewRuler: '#ff0' } });
|
|
1648
|
+
}
|
|
1649
|
+
if (e.key === 'Escape') {
|
|
1650
|
+
setShowSearch(false);
|
|
1651
|
+
searchAddonRef.current?.clearDecorations();
|
|
1652
|
+
}
|
|
1653
|
+
}}
|
|
1654
|
+
placeholder="Search..."
|
|
1655
|
+
className="bg-transparent text-[11px] text-[var(--term-fg,#c0caf5)] outline-none w-32 placeholder-gray-600"
|
|
1656
|
+
autoFocus
|
|
1657
|
+
/>
|
|
1658
|
+
<button onClick={() => searchAddonRef.current?.findPrevious(searchQuery, { regex: false, caseSensitive: false, decorations: { matchOverviewRuler: '#888', activeMatchColorOverviewRuler: '#ff0' } })}
|
|
1659
|
+
className="text-[10px] text-gray-500 hover:text-gray-300 px-1" title="Previous (Shift+Enter)">▲</button>
|
|
1660
|
+
<button onClick={() => searchAddonRef.current?.findNext(searchQuery, { regex: false, caseSensitive: false, decorations: { matchOverviewRuler: '#888', activeMatchColorOverviewRuler: '#ff0' } })}
|
|
1661
|
+
className="text-[10px] text-gray-500 hover:text-gray-300 px-1" title="Next (Enter)">▼</button>
|
|
1662
|
+
<button onClick={() => { setShowSearch(false); searchAddonRef.current?.clearDecorations(); }}
|
|
1663
|
+
className="text-[10px] text-gray-500 hover:text-gray-300 px-1" title="Close (Esc)">✕</button>
|
|
1664
|
+
</div>
|
|
1665
|
+
)}
|
|
1666
|
+
</div>
|
|
1667
|
+
);
|
|
1454
1668
|
});
|