From f68bd73973f6b5f8ccb110da3a9960ed432d98c7 Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Sun, 25 Jan 2026 21:20:03 -0500 Subject: [PATCH] test --- packages/chrome-extension/background.js | 31 +++ packages/chrome-extension/manifest.json | 17 ++ packages/chrome-extension/sidepanel.js | 250 ++++++++++++++++++++ packages/cli/src/gemini.tsx | 4 + packages/cli/src/ui/AppContainer.tsx | 10 + packages/cli/src/utils/events.ts | 2 + packages/cli/src/utils/remoteInputServer.ts | 112 +++++++++ 7 files changed, 426 insertions(+) create mode 100644 packages/chrome-extension/background.js create mode 100644 packages/chrome-extension/manifest.json create mode 100644 packages/chrome-extension/sidepanel.js create mode 100644 packages/cli/src/utils/remoteInputServer.ts diff --git a/packages/chrome-extension/background.js b/packages/chrome-extension/background.js new file mode 100644 index 0000000000..0ee44a2bd0 --- /dev/null +++ b/packages/chrome-extension/background.js @@ -0,0 +1,31 @@ +// Wrap sidePanel API call to handle potential unavailability or errors +if (chrome.sidePanel && chrome.sidePanel.setPanelBehavior) { + chrome.sidePanel + .setPanelBehavior({ openPanelOnActionClick: true }) + .catch((error) => console.error("setPanelBehavior error:", error)); +} + +chrome.runtime.onInstalled.addListener(() => { + // Create context menu item, suppressing error if it already exists + chrome.contextMenus.create({ + id: 'openSidePanel', + title: 'Open Gemini CLI', + contexts: ['all'] + }, () => { + if (chrome.runtime.lastError) { + console.log("Context menu create warning:", chrome.runtime.lastError.message); + } + }); +}); + +chrome.contextMenus.onClicked.addListener((info, tab) => { + if (info.menuItemId === 'openSidePanel') { + // Check if sidePanel API is available before calling + if (chrome.sidePanel && chrome.sidePanel.open) { + chrome.sidePanel.open({ windowId: tab.windowId }) + .catch(err => console.error("sidePanel.open error:", err)); + } else { + console.error("chrome.sidePanel.open is not available."); + } + } +}); \ No newline at end of file diff --git a/packages/chrome-extension/manifest.json b/packages/chrome-extension/manifest.json new file mode 100644 index 0000000000..332d354246 --- /dev/null +++ b/packages/chrome-extension/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 3, + "name": "Gemini CLI Companion", + "version": "1.0", + "description": "Connects to Gemini CLI A2A Server", + "permissions": ["sidePanel", "activeTab", "scripting", "storage", "contextMenus"], + "host_permissions": ["http://localhost/*", ""], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_title": "Open Gemini CLI" + }, + "side_panel": { + "default_path": "sidepanel.html" + } +} diff --git a/packages/chrome-extension/sidepanel.js b/packages/chrome-extension/sidepanel.js new file mode 100644 index 0000000000..a7193d0e1e --- /dev/null +++ b/packages/chrome-extension/sidepanel.js @@ -0,0 +1,250 @@ +document.addEventListener('DOMContentLoaded', () => { + const serverUrlInput = document.getElementById('server-url'); + const chatHistory = document.getElementById('chat-history'); + const promptInput = document.getElementById('prompt-input'); + const sendBtn = document.getElementById('send-btn'); + const includeContextCheckbox = document.getElementById('include-page-context'); + + // Helper for UUID generation + function generateUUID() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); + } + + // Load saved settings + chrome.storage.local.get(['serverUrl'], (result) => { + if (result.serverUrl) { + serverUrlInput.value = result.serverUrl; + } + }); + + document.getElementById('save-config').addEventListener('click', () => { + chrome.storage.local.set({ serverUrl: serverUrlInput.value }); + alert('Settings saved'); + }); + + sendBtn.addEventListener('click', async () => { + console.log('Send button clicked'); + const prompt = promptInput.value.trim(); + if (!prompt) return; + + // Remove trailing slash if present + const serverUrl = serverUrlInput.value.replace(/\/$/, ''); + if (!serverUrl) { + alert('Please set the Server URL'); + return; + } + + appendMessage('user', prompt); + promptInput.value = ''; + sendBtn.disabled = true; + + let context = ''; + if (includeContextCheckbox.checked) { + try { + console.log('Fetching page context...'); + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab && tab.id) { + const [{ result }] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => document.body.innerText, + }); + context = ` + +[Context from ${tab.url}]: +${result} +`; + console.log('Page context retrieved successfully'); + } + } catch (e) { + console.error('Failed to get page context:', e); + appendMessage('agent', 'Error: Could not retrieve page context. ' + e.message); + } + } + + try { + console.log(`Sending message to ${serverUrl}...`); + await sendMessage(serverUrl, prompt + context); + } catch (error) { + console.error('sendMessage failed:', error); + appendMessage('agent', `Error: ${error.message}`); + } finally { + sendBtn.disabled = false; + } + }); + + function appendMessage(role, text) { + const div = document.createElement('div'); + div.className = `message ${role}-message`; + div.innerText = text; + chatHistory.appendChild(div); + chatHistory.scrollTop = chatHistory.scrollHeight; + } + + async function sendMessage(baseUrl, text) { + let messageId; + try { + messageId = generateUUID(); + } catch (e) { + console.error('UUID generation failed:', e); + messageId = 'fallback-id-' + Date.now(); + } + + // Construct JSON-RPC payload based on testing_utils.ts + const rpcPayload = { + jsonrpc: '2.0', + id: generateUUID(), + method: 'message/stream', + params: { + message: { + kind: 'message', + role: 'user', + parts: [{ kind: 'text', text: text }], + messageId: messageId + }, + metadata: { + coderAgent: { + kind: 'agent-settings', + // Optional: Try to infer or leave empty if server has defaults + // workspacePath: '/tmp' + } + } + } + }; + + console.log('Sending payload:', rpcPayload); + + let response; + + // Try root endpoint first (JSON-RPC style) + try { + console.log(`Attempting POST ${baseUrl}/`); + response = await fetch(`${baseUrl}/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(rpcPayload) + }); + console.log(`Root endpoint response status: ${response.status}`); + } catch (e) { + console.error(`Fetch to ${baseUrl}/ failed:`, e); + } + + if (!response || response.status === 404) { + console.log('Root endpoint 404 or failed, trying /message/stream (REST style)...'); + // Fallback to /message/stream + try { + response = await fetch(`${baseUrl}/message/stream`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(rpcPayload.params) // Send params as body + }); + console.log(`/message/stream response status: ${response ? response.status : 'undefined'}`); + } catch (e) { + console.error(`Fetch to ${baseUrl}/message/stream failed:`, e); + } + } + + if (!response || response.status === 404) { + console.log('Trying /v1/message/stream...'); + try { + response = await fetch(`${baseUrl}/v1/message/stream`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(rpcPayload.params) + }); + console.log(`/v1/message/stream response status: ${response ? response.status : 'undefined'}`); + } catch (e) { + console.error(`Fetch to ${baseUrl}/v1/message/stream failed:`, e); + } + } + + if (!response) { + throw new Error('All fetch attempts failed. Check console for details.'); + } + + if (!response.ok) { + throw new Error(`Server returned ${response.status} ${response.statusText}`); + } + + await handleStream(response); + } + + async function handleStream(response) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const messageDiv = document.createElement('div'); + messageDiv.className = 'message agent-message'; + chatHistory.appendChild(messageDiv); + + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + + const lines = buffer.split('\n'); + // Process all complete lines + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i]; + if (line.startsWith('data: ')) { + try { + const jsonStr = line.substring(6); + if (jsonStr.trim() === '[DONE]') continue; + + const jsonResponse = JSON.parse(jsonStr); + console.log('Event:', jsonResponse); + + // The server response wrapper: { jsonrpc: '2.0', result: EVENT, id: ... } + const event = jsonResponse.result || jsonResponse; + + if (event.kind === 'status-update' && event.status && event.status.message) { + const message = event.status.message; + if (message.parts) { + const textParts = message.parts.filter(p => p.kind === 'text'); + if (textParts.length > 0) { + // Accumulate text parts. + // Note: In a real streaming scenario, we might get partial text or full updates. + // If _sendTextContent sends a new message each time, we should append. + // If it sends the same message with more text, we should replace? + // Based on task.ts, _sendTextContent creates a NEW messageId each time. + // So we should APPEND. + + const newText = textParts.map(p => p.text).join(''); + messageDiv.innerText += newText; + } + } + } + + // Handle Tool Calls (if any info is sent via different event kind) + // task.ts sends 'status-update' with 'ToolCallConfirmationEvent' or 'ToolCallUpdateEvent' + // The message contains 'data' part with ToolCall info. + + if (event.kind === 'status-update' && event.status.message?.parts) { + const dataParts = event.status.message.parts.filter(p => p.kind === 'data'); + for (const part of dataParts) { + if (part.data && part.data.request) { // It's a tool call + const toolName = part.data.tool?.name || part.data.request.name || 'Unknown Tool'; + const status = part.data.status; + messageDiv.innerText += `\n[Tool: ${toolName} (${status})]\n`; + } + } + } + + } catch (e) { + console.error('Parse error', e, line); + } + } + } + // Keep the last incomplete line in buffer + buffer = lines[lines.length - 1]; + chatHistory.scrollTop = chatHistory.scrollHeight; + } + } +}); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index ff73dcfdfa..510aebae5b 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -97,6 +97,7 @@ import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { setupTerminalAndTheme } from './utils/terminalTheme.js'; import { profiler } from './ui/components/DebugProfiler.js'; import { runDeferredCommand } from './deferred.js'; +import { remoteInputServer } from './utils/remoteInputServer.js'; const SLOW_RENDER_MS = 200; @@ -206,6 +207,9 @@ export async function startInteractiveUI( consolePatcher.patch(); registerCleanup(consolePatcher.cleanup); + remoteInputServer.start(); + registerCleanup(() => remoteInputServer.stop()); + const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio(); // Create wrapper component to use hooks inside render diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 43553efe14..9edf73d653 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -993,6 +993,16 @@ Logging in with Google... Restarting Gemini CLI to continue. ], ); + useEffect(() => { + const handleRemoteInput = (text: string) => { + handleFinalSubmit(text); + }; + appEvents.on(AppEvent.RemoteInput, handleRemoteInput); + return () => { + appEvents.off(AppEvent.RemoteInput, handleRemoteInput); + }; + }, [handleFinalSubmit]); + const handleClearScreen = useCallback(() => { historyManager.clearItems(); clearConsoleMessagesState(); diff --git a/packages/cli/src/utils/events.ts b/packages/cli/src/utils/events.ts index 4bf19d44ef..8a67a0944a 100644 --- a/packages/cli/src/utils/events.ts +++ b/packages/cli/src/utils/events.ts @@ -11,6 +11,7 @@ export enum AppEvent { Flicker = 'flicker', SelectionWarning = 'selection-warning', PasteTimeout = 'paste-timeout', + RemoteInput = 'remote-input', } export interface AppEvents { @@ -18,6 +19,7 @@ export interface AppEvents { [AppEvent.Flicker]: never[]; [AppEvent.SelectionWarning]: never[]; [AppEvent.PasteTimeout]: never[]; + [AppEvent.RemoteInput]: string[]; } export const appEvents = new EventEmitter(); diff --git a/packages/cli/src/utils/remoteInputServer.ts b/packages/cli/src/utils/remoteInputServer.ts new file mode 100644 index 0000000000..6094e53b2e --- /dev/null +++ b/packages/cli/src/utils/remoteInputServer.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import http from 'node:http'; +import { appEvents, AppEvent } from './events.js'; +import { debugLogger } from '@google/gemini-cli-core'; + +const REMOTE_INPUT_PORT = 41243; + +export class RemoteInputServer { + private server: http.Server | undefined; + + start() { + this.server = http.createServer((req, res) => { + // Enable CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + if (req.method === 'POST' && (req.url === '/message' || req.url === '/message/stream')) { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const data = JSON.parse(body); + // Handle both raw format { text: "..." } and A2A format + let text = ''; + + // 1. Check for A2A JSON-RPC format + if (data.params?.message?.parts?.[0]?.text) { + text = data.params.message.parts[0].text; + } + // 2. Check for simple format + else if (data.text) { + text = data.text; + } + // 3. Check for message object (A2A style params body) + else if (data.message?.parts?.[0]?.text) { + text = data.message.parts[0].text; + } + // 4. Check for message object (standard content style) + else if (data.message?.content?.[0]?.text) { + text = data.message.content[0].text; + } + + if (text) { + debugLogger.log('[RemoteInputServer] Received input:', text); + appEvents.emit(AppEvent.RemoteInput, text); + + // Respond with SSE-like structure or just OK + // Chrome Extension expects SSE if it hits /message/stream + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }); + res.write(`data: ${JSON.stringify({ + result: { + kind: 'status-update', + status: { + message: { + parts: [{ kind: 'text', text: 'Input received by interactive CLI.' }] + } + } + } + })}\n\n`); + res.end(); + } else { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'No text found in body' })); + } + } catch (e) { + debugLogger.error('[RemoteInputServer] Error parsing body:', e); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } + }); + } else { + res.writeHead(404); + res.end(); + } + }); + + this.server.listen(REMOTE_INPUT_PORT, '127.0.0.1', () => { + debugLogger.log(`[RemoteInputServer] Listening on http://127.0.0.1:${REMOTE_INPUT_PORT}`); + }); + + this.server.on('error', (err) => { + debugLogger.warn(`[RemoteInputServer] Failed to start: ${err.message}`); + }); + } + + stop() { + if (this.server) { + this.server.close(); + this.server = undefined; + } + } +} + +export const remoteInputServer = new RemoteInputServer();