This commit is contained in:
Sehoon Shon
2026-01-25 21:20:03 -05:00
parent cb772a5b7f
commit f68bd73973
7 changed files with 426 additions and 0 deletions

View File

@@ -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.");
}
}
});

View File

@@ -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/*", "<all_urls>"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "Open Gemini CLI"
},
"side_panel": {
"default_path": "sidepanel.html"
}
}

View File

@@ -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;
}
}
});

View File

@@ -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

View File

@@ -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();

View File

@@ -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<AppEvents>();

View File

@@ -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();