From e5b2c963098bd34bf00b54aad6002336ff5517d4 Mon Sep 17 00:00:00 2001 From: MikeeBuilds Date: Mon, 22 Dec 2025 16:25:41 -0500 Subject: [PATCH 1/7] style: expand kanban columns and improve dashboard layout spacing --- desktop/src/App.tsx | 49 ++++++++++--- desktop/src/components/ActivityFeed.tsx | 97 +++++++++++++++++++++++++ desktop/src/components/AgentCard.tsx | 77 ++++++++++++++++++++ desktop/src/components/KanbanBoard.tsx | 20 ++--- desktop/src/lib/api.ts | 4 + squadron/brain.py | 15 +++- squadron/services/event_bus.py | 8 ++ squadron/swarm/agent.py | 39 ++++++++++ squadron/swarm/overseer.py | 10 +-- 9 files changed, 290 insertions(+), 29 deletions(-) create mode 100644 desktop/src/components/ActivityFeed.tsx create mode 100644 desktop/src/components/AgentCard.tsx diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 064075e..5542549 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,27 +1,36 @@ import { useState, useEffect } from 'react' import { LayoutDashboard, Terminal, Activity, Map, Lightbulb, FileClock, Settings, Plus, Github, GitBranch } from 'lucide-react' import { cn } from '@/lib/utils' -import { getSystemStatus, type SystemStatus } from '@/lib/api' +import { getSystemStatus, getAgents, type SystemStatus, type Agent } from '@/lib/api' import { KanbanBoard } from '@/components/KanbanBoard' import { TaskWizard } from '@/components/TaskWizard' +import { AgentCard } from '@/components/AgentCard' +import { ActivityFeed } from '@/components/ActivityFeed' + export default function App() { const [activeTab, setActiveTab] = useState('kanban') const [systemStatus, setSystemStatus] = useState(null) + const [agents, setAgents] = useState([]) const [isWizardOpen, setIsWizardOpen] = useState(false) - const [kanbanKey, setKanbanKey] = useState(0) // Used to force refresh Kanban after creation + const [kanbanKey, setKanbanKey] = useState(0) + useEffect(() => { const fetchStatus = async () => { const status = await getSystemStatus() setSystemStatus(status) + + const latestAgents = await getAgents() + setAgents(latestAgents) } fetchStatus() - const interval = setInterval(fetchStatus, 5000) // Poll every 5s + const interval = setInterval(fetchStatus, 3000) // Poll faster (3s) for agent status return () => clearInterval(interval) }, []) + const handleTaskCreated = () => { setKanbanKey(prev => prev + 1) } @@ -75,11 +84,11 @@ export default function App() { {/* Main Content */} -
-
+
+
-

{activeTab.charAt(0).toUpperCase() + activeTab.slice(1).replace('-', ' ')}

-

+

{activeTab.charAt(0).toUpperCase() + activeTab.slice(1).replace('-', ' ')}

+

{systemStatus?.status === 'online' ? 'System Online' : (systemStatus?.status || 'Connecting...')} @@ -87,17 +96,35 @@ export default function App() { {systemStatus && ( <> | - {systemStatus.agents_online} Agents Online + {systemStatus.agents_online} Agents Online | - {systemStatus.missions_active} Active Missions + {systemStatus.missions_active} Active Missions )}

- {activeTab === 'kanban' && } - {activeTab === 'terminals' &&
Agent Terminals placeholder - Coming soon in Issue 7
} + + {activeTab === 'kanban' && ( +
+ {/* Agent Status Grid */} +
+ {agents.map(agent => ( + + ))} +
+ + +
+ )} + + {activeTab === 'terminals' && ( +
+ +
+ )} + ([]) + const scrollRef = useRef(null) + + useEffect(() => { + // Connect to SSE endpoint + const eventSource = new EventSource('http://127.0.0.1:8000/activity') + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + setEvents(prev => [...prev.slice(-49), data]) // Keep last 50 events + } catch (err) { + console.error('Failed to parse event:', err) + } + } + + return () => eventSource.close() + }, []) + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [events]) + + return ( +
+
+

+ + Neural Activity Stream +

+
+
+ Live +
+
+ +
+ {events.length === 0 ? ( +
+ + Monitoring events... +
+ ) : ( + events.map((event, i) => ( +
+
+ {event.type === 'agent_thought' && } + {event.type === 'tool_call' && } + {event.type === 'agent_complete' && } + {event.type === 'error' && } + {(event.type === 'agent_start' || event.type === 'tool_result') && } +
+ +
+
+ {event.agent} + + {new Date(event.timestamp).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })} + +
+
+ {event.type === 'agent_thought' && thought: } + {event.type === 'tool_call' && executing {event.data.tool}()...} + {event.type === 'tool_result' && tool result captured.} + {event.data.thought || event.data.task || event.data.summary || event.data.error || (event.type === 'tool_result' ? 'Success' : '')} +
+
+
+ )) + )} +
+
+ ) +} diff --git a/desktop/src/components/AgentCard.tsx b/desktop/src/components/AgentCard.tsx new file mode 100644 index 0000000..3f3cc2d --- /dev/null +++ b/desktop/src/components/AgentCard.tsx @@ -0,0 +1,77 @@ +import { Terminal, BrainCircuit, Activity, Cpu } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { Agent } from '@/lib/api' + +interface AgentCardProps { + agent: Agent +} + +export function AgentCard({ agent }: AgentCardProps) { + const isThinking = agent.status === 'active' && !agent.current_tool + // const isExecuting = !!agent.current_tool // Reserved for future visual effects + + + return ( +
+
+
+
+ {agent.name === 'Marcus' && } + {agent.name === 'Caleb' && } + {agent.name === 'Sentinel' && } +
+
+

{agent.name}

+

{agent.role}

+
+
+ +
+ + {agent.status} +
+
+ +
+ {/* Thought / Status Area */} +
+ {agent.status === 'active' ? ( +
+
+ + {isThinking ? 'Thinking' : 'Executing'} +
+

+ "{agent.current_thought || 'Processing task...'}" +

+
+ ) : ( +
+ Idle Standby +
+ )} +
+ + {/* Current Tool Indicator */} + {agent.current_tool && ( +
+ + tool: + {agent.current_tool}() +
+ )} +
+ + {/* Decorative background pulse */} + {agent.status === 'active' && ( +
+ )} +
+ ) +} diff --git a/desktop/src/components/KanbanBoard.tsx b/desktop/src/components/KanbanBoard.tsx index e6153e7..bca93d9 100644 --- a/desktop/src/components/KanbanBoard.tsx +++ b/desktop/src/components/KanbanBoard.tsx @@ -122,7 +122,7 @@ export function KanbanBoard() { } return ( -
+
{columns.map(col => ( - t.status === col.id)} - /> +
+ t.status === col.id)} + /> +
))} {activeTask ? ( -
+
) : null} @@ -150,4 +151,5 @@ export function KanbanBoard() {
) + } diff --git a/desktop/src/lib/api.ts b/desktop/src/lib/api.ts index 0e6425d..fdd2395 100644 --- a/desktop/src/lib/api.ts +++ b/desktop/src/lib/api.ts @@ -30,8 +30,12 @@ export interface Agent { name: string; role: string; status: 'active' | 'idle'; + current_thought?: string; + current_tool?: string; + current_task?: string; } + export const getSystemStatus = async (): Promise => { try { const response = await api.get('/system/status'); diff --git a/squadron/brain.py b/squadron/brain.py index 01ee0e1..574984a 100644 --- a/squadron/brain.py +++ b/squadron/brain.py @@ -12,7 +12,9 @@ import os import asyncio # Tool Imports +from squadron.services.event_bus import emit_tool_call, emit_tool_result, emit_error from squadron.skills.browser.tool import browse_website + from squadron.skills.ssh.tool import ssh_command from squadron.skills.fs_tool.tool import read_file, write_file, list_dir from squadron.skills.shell_tool.tool import run_command @@ -580,6 +582,10 @@ def execute(self, decision: dict) -> dict: try: logger.info(f"🔧 Executing {tool_name} with {args}") + + agent_name = getattr(agent_profile, 'name', 'autonomous') + emit_tool_call(agent_name, tool_name, args) + result = tool_info["func"](**args) # Handle structured tool output (dict) vs legacy simple string @@ -587,12 +593,19 @@ def execute(self, decision: dict) -> dict: # Capture files for next turn if "files" in result: self.last_files = result["files"] + + emit_tool_result(agent_name, tool_name, result["text"]) return result else: - return {"text": f"Tool Output: {result}", "files": []} + text_result = f"Tool Output: {result}" + emit_tool_result(agent_name, tool_name, text_result) + return {"text": text_result, "files": []} except Exception as e: + agent_name = getattr(agent_profile, 'name', 'autonomous') + emit_error(agent_name, str(e)) return {"text": f"Tool Error: {e}", "files": []} + # Fallback: Try to extract any text-like content from the decision fallback_content = ( diff --git a/squadron/services/event_bus.py b/squadron/services/event_bus.py index 20caa23..bffb9bc 100644 --- a/squadron/services/event_bus.py +++ b/squadron/services/event_bus.py @@ -113,8 +113,16 @@ def emit_tool_call(agent: str, tool_name: str, args: dict): "data": {"tool": tool_name, "args": safe_args} }) +def emit_agent_thought(agent: str, thought: str): + """Emit when an agent has a thought or reasoning step.""" + event_bus.publish({ + "type": "agent_thought", + "agent": agent, + "data": {"thought": thought[:500]} + }) def emit_tool_result(agent: str, tool_name: str, result: str, success: bool = True): + """Emit after a tool finishes executing.""" event_bus.publish({ "type": "tool_result", diff --git a/squadron/swarm/agent.py b/squadron/swarm/agent.py index 4ce5ee7..ff8f104 100644 --- a/squadron/swarm/agent.py +++ b/squadron/swarm/agent.py @@ -3,6 +3,8 @@ from typing import Optional from squadron.brain import SquadronBrain from squadron.services.model_factory import ModelFactory +from squadron.services.event_bus import emit_agent_start, emit_agent_thought, emit_agent_complete + logger = logging.getLogger('SwarmAgent') @@ -13,6 +15,9 @@ def __init__(self, name: str, role: str, system_prompt: str, tools: list = None) self.system_prompt = system_prompt self.task_history = [] # Track past tasks self._current_task = None # Current task being processed + self._current_thought = None + self._current_tool = None + # Each Agent gets its own Brain self.brain = SquadronBrain() @@ -65,10 +70,27 @@ def _build_context_block(self, ctx): profile = AgentProfile(self.name, self.system_prompt, context) + emit_agent_start(self.name, task) + # 1. Think + # We might want to capture thoughts more granularly in the future + self._current_thought = f"Analyzing task: {task[:50]}..." + emit_agent_thought(self.name, self._current_thought) + decision = self.brain.think(task, profile) + # Update thought based on decision + if decision.get("action") == "tool": + self._current_thought = f"Decided to use {decision.get('tool_name')}" + self._current_tool = decision.get("tool_name") + else: + self._current_thought = "Formulating response..." + self._current_tool = None + + emit_agent_thought(self.name, self._current_thought) + # 2. Execute + # Note: brain.execute will now handle tool_call/result emission if we update it result = self.brain.execute(decision) # 3. Log to history @@ -83,10 +105,27 @@ def _build_context_block(self, ctx): if len(self.task_history) > 100: self.task_history = self.task_history[-100:] + emit_agent_complete(self.name, result["text"][:200]) + self._current_task = None + self._current_thought = None + self._current_tool = None + logger.info(f" [{self.name}] Result: {result['text'][:50]}...") return result + def get_status(self) -> dict: + """Returns the current status of the agent.""" + return { + "name": self.name, + "role": self.role, + "status": "active" if self._current_task else "idle", + "current_task": self._current_task, + "current_thought": self._current_thought, + "current_tool": self._current_tool + } + + def get_history(self, limit: int = 10) -> list: """Get recent task history for this agent.""" return self.task_history[-limit:] diff --git a/squadron/swarm/overseer.py b/squadron/swarm/overseer.py index 8efec0b..12ccbe0 100644 --- a/squadron/swarm/overseer.py +++ b/squadron/swarm/overseer.py @@ -249,14 +249,8 @@ def get_activity_log(self, limit: int = 50) -> list: def get_agent_status(self) -> list: """Return agent status for dashboard.""" - return [ - { - "name": name, - "role": agent.role, - "status": "active" if hasattr(agent, '_current_task') else "idle" - } - for name, agent in self.agents.items() - ] + return [agent.get_status() for agent in self.agents.values()] + def _log_activity(self, event_type: str, data: dict): """Log activity for dashboard streaming.""" From b44196bbee47cbbeda86cdf8521c3b25853dcf13 Mon Sep 17 00:00:00 2001 From: MikeeBuilds Date: Mon, 22 Dec 2025 16:27:19 -0500 Subject: [PATCH 2/7] feat: collapsible sidebar and layout tuning for standard displays --- desktop/src/App.tsx | 170 ++++++++++++++++--------- desktop/src/components/KanbanBoard.tsx | 3 +- 2 files changed, 111 insertions(+), 62 deletions(-) diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 5542549..ad067ba 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { LayoutDashboard, Terminal, Activity, Map, Lightbulb, FileClock, Settings, Plus, Github, GitBranch } from 'lucide-react' +import { LayoutDashboard, Terminal, Activity, Map, Lightbulb, FileClock, Settings, Plus, Github, GitBranch, ChevronLeft, Menu } from 'lucide-react' import { cn } from '@/lib/utils' import { getSystemStatus, getAgents, type SystemStatus, type Agent } from '@/lib/api' import { KanbanBoard } from '@/components/KanbanBoard' @@ -7,14 +7,13 @@ import { TaskWizard } from '@/components/TaskWizard' import { AgentCard } from '@/components/AgentCard' import { ActivityFeed } from '@/components/ActivityFeed' - export default function App() { const [activeTab, setActiveTab] = useState('kanban') const [systemStatus, setSystemStatus] = useState(null) const [agents, setAgents] = useState([]) const [isWizardOpen, setIsWizardOpen] = useState(false) const [kanbanKey, setKanbanKey] = useState(0) - + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false) useEffect(() => { const fetchStatus = async () => { @@ -30,7 +29,6 @@ export default function App() { return () => clearInterval(interval) }, []) - const handleTaskCreated = () => { setKanbanKey(prev => prev + 1) } @@ -38,93 +36,126 @@ export default function App() { return (
{/* Sidebar */} -
- - + ) } From a1472121cf75d7533f599ee573b9f9f3beaedc60 Mon Sep 17 00:00:00 2001 From: MikeeBuilds Date: Mon, 22 Dec 2025 16:42:55 -0500 Subject: [PATCH 6/7] fix: restore window controls and fix collapsed icon centering --- desktop/src/App.tsx | 36 ++++++++++++++------------- desktop/src/components/ui/sidebar.tsx | 14 +++++------ 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 36171c4..eb8521a 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -65,18 +65,18 @@ export default function App() { return ( - -
-
-
-
+ +
+
+
+
Squadron - - - Project + + + Project navigate('kanban')} tooltip="Kanban Board"> @@ -104,15 +104,17 @@ export default function App() { - navigate('changelog')} tooltip="Changelog"> - Changelog - + + navigate('changelog')} tooltip="Changelog"> + Changelog + + - - Tools + + Tools navigate('github')} tooltip="GitHub Issues"> @@ -128,9 +130,9 @@ export default function App() { - - - + + + navigate('settings')} tooltip="Settings"> Settings @@ -138,7 +140,7 @@ export default function App() { - - - - - -
- {/* Subtle Background Glow */} -
- -
-
- -
-

- {activeTab === 'kanban' ? 'Operation Dashboard' : activeTab.charAt(0).toUpperCase() + activeTab.slice(1).replace('-', ' ')} -

-

- - - {systemStatus?.status === 'online' ? 'System Active' : (systemStatus?.status || 'Connecting')} - - {systemStatus && ( - <> - - - {systemStatus.agents_online} Agents - - - - {systemStatus.missions_active} Flights - - - )} -

-
-
-
- -
- {activeTab === 'kanban' && ( -
-
- {agents.map(agent => ( - - ))} + + + + + + +
+ {/* Subtle Background Glow */} +
+ +
+
+ +
+

+ {activeTab === 'kanban' ? 'Operation Dashboard' : 'Terminal Workbench'} +

+
+
+
+ Live System +
- -
- )} +
+ +
+ {activeTab === 'kanban' && ( +
+
+
+ {agents.map(agent => ( + + ))} +
+ +
+
+

Project Flight Path

+
+
+ +
+
+
+ )} - {activeTab === 'terminals' && ( -
- -
- )} -
- - setIsWizardOpen(false)} - onTaskCreated={handleTaskCreated} - /> -
-
- + {activeTab === 'terminals' && ( +
+ +
+ )} +
+
+
+ + setIsWizardOpen(false)} + onTaskCreated={() => { + setKanbanKey(prev => prev + 1) + }} + /> + + setIsSettingsOpen(false)} + /> + +
) } diff --git a/desktop/src/components/SettingsPanel.tsx b/desktop/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..f6f8ec3 --- /dev/null +++ b/desktop/src/components/SettingsPanel.tsx @@ -0,0 +1,173 @@ +import { useState, useEffect } from 'react' +import { X, Key, Check, AlertCircle, Eye, EyeOff, Trash2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import { PROVIDERS } from '@/lib/providers' + +interface SettingsPanelProps { + isOpen: boolean + onClose: () => void +} + +export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) { + const [apiKeys, setApiKeys] = useState>({}) + const [hasKeys, setHasKeys] = useState>({}) + const [showKey, setShowKey] = useState>({}) + const [saving, setSaving] = useState(null) + const [saved, setSaved] = useState(null) + + const api = (window as any).electronAPI + + // Load existing key status on mount + useEffect(() => { + if (!isOpen) return + + const loadKeyStatus = async () => { + const status: Record = {} + for (const providerId of Object.keys(PROVIDERS)) { + if (providerId === 'shell') continue + try { + status[providerId] = await api.hasApiKey(providerId) + } catch { + status[providerId] = false + } + } + setHasKeys(status) + } + loadKeyStatus() + }, [isOpen]) + + const handleSaveKey = async (providerId: string) => { + const key = apiKeys[providerId] + if (!key?.trim()) return + + setSaving(providerId) + try { + await api.saveApiKey(providerId, key.trim()) + setHasKeys(prev => ({ ...prev, [providerId]: true })) + setApiKeys(prev => ({ ...prev, [providerId]: '' })) + setSaved(providerId) + setTimeout(() => setSaved(null), 2000) + } catch (err) { + console.error(`Failed to save API key for ${providerId}:`, err) + } finally { + setSaving(null) + } + } + + const handleDeleteKey = async (providerId: string) => { + try { + await api.deleteApiKey(providerId) + setHasKeys(prev => ({ ...prev, [providerId]: false })) + } catch (err) { + console.error(`Failed to delete API key for ${providerId}:`, err) + } + } + + if (!isOpen) return null + + const providerList = Object.values(PROVIDERS).filter(p => p.id !== 'shell') + + return ( +
+
+ {/* Header */} +
+

+ + API Configuration +

+ +
+ + {/* Content */} +
+

+ Configure API keys for AI providers. Keys are encrypted and stored securely on your device. +

+ + {providerList.map(provider => ( +
+
+ + {hasKeys[provider.id] && ( + + + Configured + + )} +
+ +
+
+ setApiKeys(prev => ({ ...prev, [provider.id]: e.target.value }))} + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none focus:border-yellow-400/50 transition-colors pr-10" + /> + +
+ + + + {hasKeys[provider.id] && ( + + )} +
+ +

+ Environment variable: {provider.envKey} +

+
+ ))} + + {/* Security notice */} +
+ +
+ Security: API keys are encrypted using your operating system's secure storage (Windows DPAPI / macOS Keychain) and never leave your device. +
+
+
+ + {/* Footer */} +
+ +
+
+
+ ) +} diff --git a/desktop/src/components/TaskCard.tsx b/desktop/src/components/TaskCard.tsx index 4f4df53..4adfbd9 100644 --- a/desktop/src/components/TaskCard.tsx +++ b/desktop/src/components/TaskCard.tsx @@ -69,8 +69,17 @@ export function TaskCard({ task }: TaskCardProps) {
{task.status === 'in_progress' && ( -
-
+
+
+ Progress + {task.progress}% +
+
+
+
)} diff --git a/desktop/src/components/TerminalHub.tsx b/desktop/src/components/TerminalHub.tsx new file mode 100644 index 0000000..a0b95cf --- /dev/null +++ b/desktop/src/components/TerminalHub.tsx @@ -0,0 +1,379 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { Terminal as TerminalIcon, X, ChevronDown, Link2, Zap } from 'lucide-react' +import { cn } from '@/lib/utils' +import { XTermComponent } from './XTermComponent' +import { getTasks, type Task } from '@/lib/api' +import { PROVIDERS, getProviderById, getDefaultModel, type ProviderConfig } from '@/lib/providers' + +declare global { + interface Window { + electronAPI: { + spawnTerminal: (id: string, shell: string, args: string[], cwd: string, env?: Record) => void + writeTerminal: (id: string, data: string) => void + resizeTerminal: (id: string, cols: number, rows: number) => void + killTerminal: (id: string) => void + onTerminalData: (id: string, callback: (data: string) => void) => () => void + onTerminalExit: (id: string, callback: (code: number) => void) => () => void + getApiKey: (provider: string) => Promise + hasApiKey: (provider: string) => Promise + getEnabledProviders: () => Promise + } + } +} + +interface TerminalSession { + id: string + title: string + providerId: string + modelId: string + linkedTaskId?: string + isActive: boolean + needsRespawn: boolean +} + +// Pre-spawn 6 terminals on load +const DEFAULT_SESSIONS: TerminalSession[] = [ + { id: 'term-1', title: 'Terminal 1', providerId: 'shell', modelId: 'default', isActive: true, needsRespawn: false }, + { id: 'term-2', title: 'Terminal 2', providerId: 'shell', modelId: 'default', isActive: false, needsRespawn: false }, + { id: 'term-3', title: 'Terminal 3', providerId: 'shell', modelId: 'default', isActive: false, needsRespawn: false }, + { id: 'term-4', title: 'Terminal 4', providerId: 'shell', modelId: 'default', isActive: false, needsRespawn: false }, + { id: 'term-5', title: 'Terminal 5', providerId: 'shell', modelId: 'default', isActive: false, needsRespawn: false }, + { id: 'term-6', title: 'Terminal 6', providerId: 'shell', modelId: 'default', isActive: false, needsRespawn: false }, +] + +export function TerminalHub() { + const [sessions, setSessions] = useState(DEFAULT_SESSIONS) + const [tasks, setTasks] = useState([]) + const [enabledProviders, setEnabledProviders] = useState(['shell']) + const [openDropdown, setOpenDropdown] = useState(null) + const [openModelDropdown, setOpenModelDropdown] = useState(null) + const [openTaskDropdown, setOpenTaskDropdown] = useState(null) + const respawnTriggersRef = useRef>({}) + + // Fetch enabled providers on mount and periodically refresh + useEffect(() => { + const checkProviders = async () => { + try { + const providers = await window.electronAPI.getEnabledProviders() + setEnabledProviders(['shell', ...providers.filter(p => p !== 'shell')]) + } catch (err) { + console.error('Failed to get providers:', err) + } + } + checkProviders() + // Refresh every 2 seconds to pick up new API keys + const interval = setInterval(checkProviders, 2000) + return () => clearInterval(interval) + }, []) + + // Fetch tasks for linking + useEffect(() => { + const fetchTasks = async () => { + try { + const data = await getTasks() + setTasks(data) + } catch (err) { + console.error('Failed to fetch tasks:', err) + } + } + fetchTasks() + const interval = setInterval(fetchTasks, 10000) + return () => clearInterval(interval) + }, []) + + const closeSession = (id: string, e: React.MouseEvent) => { + e.stopPropagation() + window.electronAPI.killTerminal(id) + setSessions((prev: TerminalSession[]) => prev.filter(s => s.id !== id)) + setOpenDropdown(null) + setOpenModelDropdown(null) + setOpenTaskDropdown(null) + } + + const switchSession = (id: string) => { + setSessions((prev: TerminalSession[]) => prev.map(s => ({ + ...s, + isActive: s.id === id + }))) + } + + // Change provider and trigger respawn + const setProvider = useCallback(async (sessionId: string, providerId: string) => { + const provider = getProviderById(providerId) + if (!provider) return + + // Check if we have the API key for this provider + if (providerId !== 'shell') { + const hasKey = await window.electronAPI.hasApiKey(providerId) + if (!hasKey) { + console.warn(`No API key configured for ${providerId}`) + // Could show a toast/notification here + } + } + + const modelId = getDefaultModel(providerId) + + // Mark for respawn + respawnTriggersRef.current[sessionId] = (respawnTriggersRef.current[sessionId] || 0) + 1 + + setSessions((prev: TerminalSession[]) => prev.map(s => + s.id === sessionId ? { ...s, providerId, modelId, needsRespawn: true } : s + )) + setOpenDropdown(null) + }, []) + + // Change model and trigger respawn + const setModel = useCallback((sessionId: string, modelId: string) => { + respawnTriggersRef.current[sessionId] = (respawnTriggersRef.current[sessionId] || 0) + 1 + + setSessions((prev: TerminalSession[]) => prev.map(s => + s.id === sessionId ? { ...s, modelId, needsRespawn: true } : s + )) + setOpenModelDropdown(null) + }, []) + + // Link task to terminal + const linkTask = useCallback((sessionId: string, taskId: string | undefined) => { + setSessions((prev: TerminalSession[]) => prev.map(s => + s.id === sessionId ? { ...s, linkedTaskId: taskId } : s + )) + setOpenTaskDropdown(null) + }, []) + + // Inject task context into terminal + const injectTaskContext = useCallback((sessionId: string, task: Task) => { + const contextText = `\n# ═══════════════════════════════════════════════════════════════\n# TASK CONTEXT INJECTED\n# ═══════════════════════════════════════════════════════════════\n# Task: ${task.task}\n# Priority: ${task.priority}\n# Status: ${task.status}\n# ═══════════════════════════════════════════════════════════════\n\n` + window.electronAPI.writeTerminal(sessionId, contextText) + }, []) + + const getProviderInfo = (providerId: string): ProviderConfig => { + return getProviderById(providerId) || PROVIDERS.shell + } + + const getLinkedTask = (taskId?: string) => { + return tasks.find(t => t.id === taskId) + } + + // Get respawn trigger for XTermComponent key + const getRespawnTrigger = (id: string) => respawnTriggersRef.current[id] || 0 + + return ( +
+ {/* 6-Terminal Grid: 3 columns x 2 rows */} +
+ {sessions.map(s => { + const providerInfo = getProviderInfo(s.providerId) + const linkedTask = getLinkedTask(s.linkedTaskId) + const currentModel = providerInfo.models.find(m => m.id === s.modelId) || providerInfo.models[0] + + return ( +
switchSession(s.id)} + className={cn( + "flex flex-col min-h-0 bg-[#050506] transition-all cursor-pointer relative", + s.isActive && "ring-1 ring-yellow-500/30" + )} + > + {/* Terminal header */} +
+
+
+ + {/* Provider Toggle */} +
+ + + {openDropdown === s.id && ( +
+ {Object.values(PROVIDERS).filter(p => enabledProviders.includes(p.id) || p.id === 'shell').map(p => ( + + ))} +
+ )} +
+ + {/* Model Selector */} + {s.providerId !== 'shell' && ( +
+ + + {openModelDropdown === s.id && ( +
+ {providerInfo.models.map(m => ( + + ))} +
+ )} +
+ )} + + {/* Task Link Button */} +
+ + + {openTaskDropdown === s.id && ( +
+ + {tasks.map(t => ( + + ))} + {tasks.length === 0 && ( +
+ No tasks available +
+ )} +
+ )} +
+ + {/* Inject Context Button (when task linked) */} + {linkedTask && ( + + )} +
+ + closeSession(s.id, e)} + /> +
+ + {/* Linked task indicator */} + {linkedTask && ( +
+ 📋 {linkedTask.task.slice(0, 50)}{linkedTask.task.length > 50 ? '...' : ''} +
+ )} + + {/* Terminal content */} +
+ +
+
+ ) + })} + + {/* Empty state */} + {sessions.length === 0 && ( +
+ + NO TERMINALS +
+ )} +
+
+ ) +} diff --git a/desktop/src/components/TerminalView.tsx b/desktop/src/components/TerminalView.tsx new file mode 100644 index 0000000..37b9f38 --- /dev/null +++ b/desktop/src/components/TerminalView.tsx @@ -0,0 +1,196 @@ +import { useState, useEffect, useRef } from 'react' +import { Terminal as TerminalIcon, Shield, Search, Zap, Trash2, Cpu } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface LogEntry { + id: string + timestamp: string + agent: string + message: string + type: 'info' | 'warn' | 'error' | 'success' | 'cmd' +} + +export function TerminalView() { + const [logs, setLogs] = useState([]) + const [filter, setFilter] = useState('') + const [selectedAgent, setSelectedAgent] = useState(null) + const scrollRef = useRef(null) + + useEffect(() => { + const eventSource = new EventSource('http://127.0.0.1:8000/activity') + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + + // Convert activity events to terminal logs + let logType: LogEntry['type'] = 'info' + let messageValue = '' + + if (data.type === 'tool_call') { + logType = 'cmd' + messageValue = `> Executing tool: ${data.data.tool}(${JSON.stringify(data.data.arguments || {})})` + } else if (data.type === 'tool_result') { + logType = 'success' + messageValue = `✓ Tool result: ${typeof data.data.result === 'string' ? data.data.result.substring(0, 100) : 'Success'}` + } else if (data.type === 'agent_thought') { + logType = 'info' + messageValue = `🧠 Thought: ${data.data.thought}` + } else if (data.type === 'error') { + logType = 'error' + messageValue = `!! Error: ${data.data.error}` + } else { + return // Skip other event types for now + } + + const newEntry: LogEntry = { + id: Math.random().toString(36).substr(2, 9), + timestamp: data.timestamp, + agent: data.agent, + message: messageValue, + type: logType + } + + setLogs(prev => [...prev.slice(-199), newEntry]) + } catch (err) { + console.error('Failed to parse event:', err) + } + } + + return () => eventSource.close() + }, []) + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [logs]) + + const filteredLogs = logs.filter(log => { + const matchesAgent = selectedAgent ? log.agent === selectedAgent : true + const matchesSearch = log.message.toLowerCase().includes(filter.toLowerCase()) || + log.agent.toLowerCase().includes(filter.toLowerCase()) + return matchesAgent && matchesSearch + }) + + const agents = Array.from(new Set(logs.map(l => l.agent))) + + return ( +
+ {/* Terminal Header */} +
+
+
+
+
+
+
+
+

+ + Autonomous System Logs +

+
+ +
+
+ + setFilter(e.target.value)} + className="bg-zinc-950 border border-zinc-800 rounded-lg py-1 pl-8 pr-3 text-[10px] text-zinc-300 focus:outline-none focus:border-yellow-500/50 transition-colors w-48" + /> +
+ +
+
+ + {/* Agent Filter Tabs */} +
+ + {agents.map(agent => ( + + ))} +
+ + {/* Log Stream */} +
+ {filteredLogs.length === 0 ? ( +
+ + No Process Logs Found +
+ ) : ( + filteredLogs.map((log) => ( +
+ + [{new Date(log.timestamp).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}] + + + {log.agent.padEnd(8)} + + + {log.message} + +
+ )) + )} +
+ + {/* Terminal Footer */} +
+
+ + SSL Secured + + + SSE Live + +
+
+ {filteredLogs.length} Lines Displayed +
+
+
+ ) +} diff --git a/desktop/src/components/XTermComponent.tsx b/desktop/src/components/XTermComponent.tsx new file mode 100644 index 0000000..955d131 --- /dev/null +++ b/desktop/src/components/XTermComponent.tsx @@ -0,0 +1,170 @@ +import { useEffect, useRef } from 'react' +import { Terminal } from 'xterm' +import { FitAddon } from 'xterm-addon-fit' +import 'xterm/css/xterm.css' +import { getProviderById, PROVIDERS, type ProviderConfig } from '@/lib/providers' + +interface XTermComponentProps { + id: string + onData?: (data: string) => void + providerId?: string + modelId?: string + cwd?: string + isActive?: boolean +} + +export function XTermComponent({ id, providerId = 'shell', modelId, cwd, isActive }: XTermComponentProps) { + const termRef = useRef(null) + const terminalInstance = useRef(null) + const fitAddon = useRef(null) + + useEffect(() => { + if (!termRef.current) return + + // Initialize xterm + const term = new Terminal({ + cursorBlink: true, + fontSize: 10, + fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, "Courier New", monospace', + fontWeight: 'normal', + lineHeight: 1.1, + letterSpacing: 0, + allowTransparency: true, + theme: { + background: 'transparent', + foreground: '#a1a1aa', + cursor: '#eab308', + selectionBackground: '#eab30833', + black: '#09090b', + red: '#ef4444', + green: '#22c55e', + yellow: '#eab308', + blue: '#3b82f6', + magenta: '#d946ef', + cyan: '#06b6d4', + white: '#e4e4e7', + } + }) + + const fit = new FitAddon() + term.loadAddon(fit) + term.open(termRef.current) + fit.fit() + + terminalInstance.current = term + fitAddon.current = fit + + // Get provider configuration + const provider = getProviderById(providerId) || PROVIDERS.shell + const api = (window as any).electronAPI + + // Check if CLI needs installation and auto-install if needed + const checkAndInstallCli = async (provider: ProviderConfig): Promise => { + // Skip for npx-based CLIs (they auto-download) + if (provider.cli === 'npx' || provider.cli === 'shell') { + return true + } + + // Check if CLI is installed + const isInstalled = await api.checkCliInstalled(provider.cli) + + if (!isInstalled && provider.installCommand) { + term.writeln(`\x1b[33m[Squadron] ${provider.name} CLI not found. Installing...\x1b[0m`) + term.writeln(`\x1b[90m$ ${provider.installCommand.windows || provider.installCommand.unix}\x1b[0m`) + + const command = globalThis?.process?.platform === 'win32' + ? provider.installCommand.windows + : provider.installCommand.unix + + const result = await api.installCli(command) + + if (result.success) { + term.writeln(`\x1b[32m[Squadron] ${provider.name} CLI installed successfully!\x1b[0m`) + term.writeln('') + return true + } else { + term.writeln(`\x1b[31m[Squadron] Failed to install ${provider.name} CLI:\x1b[0m`) + term.writeln(`\x1b[31m${result.output}\x1b[0m`) + term.writeln('') + term.writeln(`\x1b[33mPlease install manually and try again.\x1b[0m`) + return false + } + } + + return isInstalled + } + + // Build spawn configuration + const spawnTerminal = async () => { + let shell = provider.cli + let args = [...provider.args] + let env: Record = {} + + // For AI providers, check and auto-install CLI if needed + if (providerId !== 'shell') { + const cliReady = await checkAndInstallCli(provider) + if (!cliReady && provider.cli !== 'npx') { + return // Don't spawn if CLI couldn't be installed + } + } + + // For AI providers, get the API key and add to env + if (providerId !== 'shell' && provider.envKey) { + try { + const apiKey = await api.getApiKey(providerId) + if (apiKey) { + env[provider.envKey] = apiKey + } + } catch (err) { + console.error(`Failed to get API key for ${providerId}:`, err) + } + } + + // Spawn the terminal with the correct CLI and env + api.spawnTerminal(id, shell, args, cwd, env) + } + + spawnTerminal() + + const cleanupData = api.onTerminalData(id, (data: string) => { + term.write(data) + }) + + term.onData((data) => { + api.writeTerminal(id, data) + }) + + const handleResize = () => { + fit.fit() + api.resizeTerminal(id, term.cols, term.rows) + } + + window.addEventListener('resize', handleResize) + + // Initial resize + setTimeout(handleResize, 100) + + return () => { + cleanupData() + window.removeEventListener('resize', handleResize) + term.dispose() + } + }, [id, providerId, modelId, cwd]) + + useEffect(() => { + if (isActive && fitAddon.current) { + setTimeout(() => fitAddon.current?.fit(), 100) + } + }, [isActive]) + + return ( +
+