@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.
Files changed (93) hide show
  1. package/README.md +27 -2
  2. package/RELEASE_NOTES.md +21 -14
  3. package/app/api/agents/route.ts +17 -0
  4. package/app/api/delivery/[id]/route.ts +62 -0
  5. package/app/api/delivery/route.ts +40 -0
  6. package/app/api/mobile-chat/route.ts +13 -7
  7. package/app/api/monitor/route.ts +10 -6
  8. package/app/api/pipelines/[id]/route.ts +16 -3
  9. package/app/api/tasks/route.ts +2 -1
  10. package/app/api/workspace/[id]/agents/route.ts +35 -0
  11. package/app/api/workspace/[id]/memory/route.ts +23 -0
  12. package/app/api/workspace/[id]/smith/route.ts +22 -0
  13. package/app/api/workspace/[id]/stream/route.ts +28 -0
  14. package/app/api/workspace/route.ts +100 -0
  15. package/app/global-error.tsx +10 -4
  16. package/app/icon.ico +0 -0
  17. package/app/layout.tsx +2 -2
  18. package/app/login/LoginForm.tsx +96 -0
  19. package/app/login/page.tsx +7 -98
  20. package/app/page.tsx +2 -2
  21. package/bin/forge-server.mjs +13 -1
  22. package/check-forge-status.sh +9 -0
  23. package/components/ConversationEditor.tsx +411 -0
  24. package/components/ConversationGraphView.tsx +347 -0
  25. package/components/ConversationTerminalView.tsx +303 -0
  26. package/components/Dashboard.tsx +36 -39
  27. package/components/DashboardWrapper.tsx +9 -0
  28. package/components/DeliveryFlowEditor.tsx +491 -0
  29. package/components/DeliveryList.tsx +230 -0
  30. package/components/DeliveryWorkspace.tsx +589 -0
  31. package/components/DocTerminal.tsx +10 -2
  32. package/components/DocsViewer.tsx +10 -2
  33. package/components/HelpTerminal.tsx +11 -6
  34. package/components/InlinePipelineView.tsx +111 -0
  35. package/components/MobileView.tsx +20 -0
  36. package/components/MonitorPanel.tsx +9 -4
  37. package/components/NewTaskModal.tsx +32 -0
  38. package/components/PipelineEditor.tsx +49 -6
  39. package/components/PipelineView.tsx +482 -64
  40. package/components/ProjectDetail.tsx +314 -56
  41. package/components/ProjectManager.tsx +49 -4
  42. package/components/SessionView.tsx +27 -13
  43. package/components/SettingsModal.tsx +790 -124
  44. package/components/SkillsPanel.tsx +31 -8
  45. package/components/TaskBoard.tsx +3 -0
  46. package/components/WebTerminal.tsx +257 -43
  47. package/components/WorkspaceTree.tsx +221 -0
  48. package/components/WorkspaceView.tsx +2245 -0
  49. package/install.sh +2 -2
  50. package/lib/agents/claude-adapter.ts +104 -0
  51. package/lib/agents/generic-adapter.ts +64 -0
  52. package/lib/agents/index.ts +242 -0
  53. package/lib/agents/types.ts +70 -0
  54. package/lib/artifacts.ts +106 -0
  55. package/lib/delivery.ts +787 -0
  56. package/lib/forge-skills/forge-inbox.md +37 -0
  57. package/lib/forge-skills/forge-send.md +40 -0
  58. package/lib/forge-skills/forge-status.md +32 -0
  59. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  60. package/lib/help-docs/00-overview.md +7 -1
  61. package/lib/help-docs/01-settings.md +159 -2
  62. package/lib/help-docs/05-pipelines.md +89 -0
  63. package/lib/help-docs/07-projects.md +35 -1
  64. package/lib/help-docs/11-workspace.md +254 -0
  65. package/lib/help-docs/CLAUDE.md +7 -2
  66. package/lib/init.ts +60 -10
  67. package/lib/pipeline.ts +537 -1
  68. package/lib/settings.ts +115 -22
  69. package/lib/skills.ts +249 -372
  70. package/lib/task-manager.ts +113 -33
  71. package/lib/telegram-bot.ts +33 -1
  72. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  73. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  74. package/lib/workspace/agent-bus.ts +416 -0
  75. package/lib/workspace/agent-worker.ts +667 -0
  76. package/lib/workspace/backends/api-backend.ts +262 -0
  77. package/lib/workspace/backends/cli-backend.ts +479 -0
  78. package/lib/workspace/index.ts +82 -0
  79. package/lib/workspace/manager.ts +136 -0
  80. package/lib/workspace/orchestrator.ts +1914 -0
  81. package/lib/workspace/persistence.ts +310 -0
  82. package/lib/workspace/presets.ts +170 -0
  83. package/lib/workspace/skill-installer.ts +188 -0
  84. package/lib/workspace/smith-memory.ts +498 -0
  85. package/lib/workspace/types.ts +231 -0
  86. package/lib/workspace/watch-manager.ts +288 -0
  87. package/lib/workspace-standalone.ts +814 -0
  88. package/middleware.ts +1 -0
  89. package/next-env.d.ts +1 -1
  90. package/package.json +4 -1
  91. package/src/config/index.ts +12 -1
  92. package/src/core/db/database.ts +1 -0
  93. 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
- await fetch('/api/skills', {
136
- method: 'POST',
137
- headers: { 'Content-Type': 'application/json' },
138
- body: JSON.stringify({ action: 'sync' }),
139
- });
140
- await fetchSkills();
141
- setSyncing(false);
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 ? 'Syncing...' : 'Sync'}
287
+ {syncing ? `Syncing${syncProgress ? ` ${syncProgress}` : '...'}` : 'Sync'}
265
288
  </button>
266
289
  </div>
267
290
  {/* Search — hide on rules tab */}
@@ -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
- // Check for existing sessions to use -c
326
- let hasSession = false;
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 sRes = await fetch(`/api/claude-sessions/${encodeURIComponent(projectName)}`);
329
- const sData = await sRes.json();
330
- hasSession = Array.isArray(sData) ? sData.length > 0 : false;
331
- } catch {}
332
- const sf = skipPermissions ? ' --dangerously-skip-permissions' : '';
333
- const resumeFlag = hasSession ? ' -c' : '';
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, `cd "${projectPath}" && claude${resumeFlag}${sf}\n`);
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
- // Refresh projects list when opening modal
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">Claude in Project</div>
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
- <button
833
- key={p.path}
834
- onClick={async () => {
835
- setShowNewTabModal(false); setExpandedRoot(null);
836
- // Pre-check sessions before creating tab
837
- let hasSession = false;
838
- try {
839
- const sRes = await fetch(`/api/claude-sessions/${encodeURIComponent(p.name)}`);
840
- const sData = await sRes.json();
841
- hasSession = Array.isArray(sData) ? sData.length > 0 : (Array.isArray(sData.sessions) && sData.sessions.length > 0);
842
- } catch {}
843
- const skipFlag = skipPermissions ? ' --dangerously-skip-permissions' : '';
844
- const resumeFlag = hasSession ? ' -c' : '';
845
- const tree = makeTerminal(undefined, p.path);
846
- const paneId = firstTerminalId(tree);
847
- pendingCommands.set(paneId, `cd "${p.path}" && claude${resumeFlag}${skipFlag}\n`);
848
- const tabNum = tabs.length + 1;
849
- const newTab: TabState = { id: nextId++, label: p.name || `Terminal ${tabNum}`, tree, ratios: {}, activeId: paneId, projectPath: p.path };
850
- setTabs(prev => [...prev, newTab]);
851
- setActiveTabId(newTab.id);
852
- }}
853
- className="w-full text-left px-3 py-1.5 rounded hover:bg-[var(--term-border)] text-[11px] text-gray-300 flex items-center gap-2 truncate"
854
- title={p.path}
855
- >
856
- <span className="text-gray-600 text-[10px]">↳</span> {p.name}
857
- </button>
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 <div ref={containerRef} className="h-full w-full" />;
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
  });