diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 4c1b50b83ad..829119f65b4 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -58,6 +58,7 @@ import { clearSessionTodos } from '@/store/todos' import type { ClientSessionState, + BrowserManageResponse, FileAttachResponse, HandoffFailResponse, HandoffRequestResponse, @@ -1141,6 +1142,81 @@ export function usePromptActions({ } catch (err) { renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) } + }, + // /browser connect|disconnect|status manages the live CDP connection on + // the gateway host, mirroring the TUI's browser.manage RPC. It mutates + // BROWSER_CDP_URL (and may launch Chrome) in the gateway process โ€” only + // meaningful when that process runs on this machine, so it's gated to + // local connections. A remote gateway would act on the wrong host. + browser: async ctx => { + const resolved = await withSlashOutput(ctx) + + if (!resolved) { + return + } + + const { render: renderSlashOutput, sessionId } = resolved + + if ($connection.get()?.mode === 'remote') { + renderSlashOutput( + '/browser manages a Chromium-family browser on the gateway host โ€” only available when connected to a local gateway.' + ) + + return + } + + const [rawAction = 'status', ...rest] = ctx.arg.trim().split(/\s+/).filter(Boolean) + const cmdAction = rawAction.toLowerCase() + + if (!['connect', 'disconnect', 'status'].includes(cmdAction)) { + renderSlashOutput( + 'usage: /browser [connect|disconnect|status] [url] ยท persistent: set browser.cdp_url in config.yaml' + ) + + return + } + + const url = cmdAction === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined + + if (url) { + renderSlashOutput(`checking Chromium-family browser remote debugging at ${url}...`) + } + + try { + const result = await requestGateway('browser.manage', { + action: cmdAction, + session_id: sessionId, + ...(url && { url }) + }) + + // Without a streamed session subscription, the gateway bundles its + // progress lines into `messages` โ€” flush them inline. + result?.messages?.forEach(message => renderSlashOutput(message)) + + if (cmdAction === 'status') { + renderSlashOutput( + result?.connected + ? `browser connected: ${result.url || '(url unavailable)'}` + : 'browser not connected (try /browser connect or set browser.cdp_url in config.yaml)' + ) + + return + } + + if (cmdAction === 'disconnect') { + renderSlashOutput('browser disconnected') + + return + } + + if (result?.connected) { + renderSlashOutput('Browser connected to live Chromium-family browser via CDP') + renderSlashOutput(`Endpoint: ${result.url || '(url unavailable)'}`) + renderSlashOutput('next browser tool call will use this CDP endpoint') + } + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } } } diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 5082b70406d..9500468482c 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -46,6 +46,12 @@ export interface SlashExecResponse { warning?: string } +export interface BrowserManageResponse { + connected?: boolean + url?: string + messages?: string[] +} + export interface SessionSteerResponse { // 'queued' == accepted into the live turn's steer slot (injected at the next // tool-result boundary); 'rejected' == no live tool window, caller queues. diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index d37738173ce..54f5a6f89df 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -52,6 +52,17 @@ describe('desktop slash command curation', () => { expect(desktopSlashUnavailableMessage('/personality')).toBeNull() }) + it('treats /browser as an executable action command (local-gateway connect)', () => { + // /browser used to be terminal-only; it now resolves to a desktop action + // handler that routes browser.manage RPC when the gateway is local. + expect(isDesktopSlashCommand('/browser')).toBe(true) + expect(isDesktopSlashSuggestion('/browser')).toBe(true) + expect(desktopSlashUnavailableMessage('/browser')).toBeNull() + expect(resolveDesktopCommand('/browser')?.surface).toEqual({ kind: 'action', action: 'browser' }) + // Bare /browser expands to its sub-action options in the popover. + expect(resolveDesktopCommand('/browser')?.args).toBe(true) + }) + it('allows aliases to execute without cluttering the popover', () => { expect(isDesktopSlashSuggestion('/reset')).toBe(false) expect(isDesktopSlashCommand('/reset')).toBe(true) diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index d898a6c83f1..f9ae934edf4 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -30,6 +30,7 @@ export interface DesktopThemeCommandOption { */ export type DesktopActionId = | 'branch' + | 'browser' | 'handoff' | 'help' | 'new' @@ -103,6 +104,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ { name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true }, { name: '/title', description: 'Rename the current session', surface: action('title') }, { name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') }, + { + name: '/browser', + description: 'Manage browser CDP connection [connect|disconnect|status] (local gateway only)', + surface: action('browser'), + args: true + }, // Overlay pickers { name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true }, @@ -142,7 +149,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ // per reason beats 40 identical object literals. const NO_DESKTOP_SURFACE: Record = { terminal: [ - '/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details', + '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details', '/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs', '/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart', '/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'