diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentList.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentList.tsx index aae4547c7..825ba5e46 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentList.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentList.tsx @@ -30,6 +30,7 @@ interface AgentListProps { deletingAgentKey: string | null onCreateAgent: () => void onDeleteAgent: (agent: AgentListItem) => void + onOpenTerminal?: (agent: AgentListItem) => void onPinToggle: (agent: AgentListItem, next: boolean) => void } @@ -42,6 +43,7 @@ export const AgentList: FC = ({ deletingAgentKey, onCreateAgent, onDeleteAgent, + onOpenTerminal, onPinToggle, }) => { const adapterHealth = useMemo(() => { @@ -104,6 +106,7 @@ export const AgentList: FC = ({ data={data} deleting={deletingAgentKey === agent.key} onDelete={onDeleteAgent} + onOpenTerminal={onOpenTerminal} onPinToggle={onPinToggle} /> ) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentRowCard.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentRowCard.tsx index f54866fba..074533eb6 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentRowCard.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentRowCard.tsx @@ -33,6 +33,7 @@ export const AgentRowCard: FC = ({ data, deleting, onDelete, + onOpenTerminal, onPinToggle, }) => { return ( @@ -92,6 +93,7 @@ export const AgentRowCard: FC = ({ activeTurnId={data.activeTurnId} deleting={deleting} onDelete={onDelete} + onOpenTerminal={onOpenTerminal} /> diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentTerminal.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentTerminal.tsx index b399c7a4d..ab0e415bd 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentTerminal.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentTerminal.tsx @@ -13,16 +13,28 @@ import { getAgentServerUrl } from '@/lib/browseros/helpers' interface AgentTerminalProps { onBack: () => void + target?: TerminalTargetId + agentId?: string initialCommand?: string onSessionExit?: () => void } +type TerminalTargetId = 'openclaw' | 'claude' | 'codex' | 'hermes' + +interface TerminalTargetOption { + id: TerminalTargetId + label: string + workingDir: string + shell: string +} + type TerminalServerMessage = | { type: 'output'; data: string } | { type: 'exit'; exitCode: number } | { type: 'error'; message: string } const TERMINAL_HOME_DIR = OPENCLAW_CONTAINER_HOME +const DEFAULT_TARGET: TerminalTargetId = 'openclaw' const TERMINAL_FONT_FAMILY = '"Geist Mono", Menlo, Monaco, "Courier New", monospace' @@ -118,11 +130,14 @@ function parseTerminalMessage(data: unknown): TerminalServerMessage | null { export const AgentTerminal: FC = ({ onBack, + target = DEFAULT_TARGET, + agentId, initialCommand, onSessionExit, }) => { const containerRef = useRef(null) const terminalRef = useRef(null) + const sentInitialCommandRef = useRef(false) // Refs keep the mount-once effect from tearing down the PTY when the // parent re-renders with new inline callbacks. const initialCommandRef = useRef(initialCommand) @@ -131,6 +146,45 @@ export const AgentTerminal: FC = ({ onSessionExitRef.current = onSessionExit const [copied, setCopied] = useState(false) + const [selectedTarget, setSelectedTarget] = useState(target) + const [targets, setTargets] = useState([]) + + const selectedTargetInfo = targets.find( + (entry) => entry.id === selectedTarget, + ) + const workingDir = selectedTargetInfo?.workingDir ?? TERMINAL_HOME_DIR + const shell = selectedTargetInfo?.shell ?? OPENCLAW_TERMINAL_SHELL + + useEffect(() => { + setSelectedTarget(target) + }, [target]) + + useEffect(() => { + const ac = new AbortController() + const loadTargets = async (): Promise => { + try { + const baseUrl = await getAgentServerUrl() + if (ac.signal.aborted) return + const url = new URL('/terminal/targets', baseUrl) + if (agentId) url.searchParams.set('agentId', agentId) + const res = await fetch(url, { signal: ac.signal }) + if (!res.ok) return + const body = (await res.json()) as { targets?: TerminalTargetOption[] } + const nextTargets = body.targets ?? [] + setTargets(nextTargets) + setSelectedTarget((current) => + nextTargets.length > 0 && + !nextTargets.some((entry) => entry.id === current) + ? nextTargets[0].id + : current, + ) + } catch { + if (!ac.signal.aborted) setTargets([]) + } + } + void loadTargets() + return () => ac.abort() + }, [agentId]) // Copy the current xterm selection to the browser clipboard. No-op // if nothing is selected — users who want the whole buffer can @@ -152,6 +206,8 @@ export const AgentTerminal: FC = ({ useEffect(() => { if (!containerRef.current) return + sentInitialCommandRef.current = false + const terminal = new Terminal({ fontSize: 14, fontFamily: TERMINAL_FONT_FAMILY, @@ -226,6 +282,8 @@ export const AgentTerminal: FC = ({ if (ac.signal.aborted) return const wsUrl = new URL('/terminal/ws', baseUrl) wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:' + wsUrl.searchParams.set('target', selectedTarget) + if (agentId) wsUrl.searchParams.set('agentId', agentId) ws = new WebSocket(wsUrl) // If the effect was cleaned up between the await above and now, @@ -242,7 +300,10 @@ export const AgentTerminal: FC = ({ terminal.focus() sendResize() const cmd = initialCommandRef.current - if (cmd) sendMessage({ type: 'input', data: `${cmd}\n` }) + if (cmd && !sentInitialCommandRef.current) { + sentInitialCommandRef.current = true + sendMessage({ type: 'input', data: `${cmd}\n` }) + } } ws.onmessage = (event) => { @@ -303,7 +364,7 @@ export const AgentTerminal: FC = ({ terminal.dispose() terminalRef.current = null } - }, []) + }, [agentId, selectedTarget]) return (
@@ -318,10 +379,25 @@ export const AgentTerminal: FC = ({ Container Terminal
- OpenClaw shell in {TERMINAL_HOME_DIR} + {selectedTargetInfo?.label ?? 'Managed runtime'} shell
+ {targets.length > 1 && ( + + )}