From 08ed394737993bdeb3f612bee7a099736a2bc8f7 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 11 May 2026 23:31:09 +0000 Subject: [PATCH] Move OpenCode UI into Electron shell --- applications/electron-shell/package.json | 11 +- applications/electron-shell/preload.cjs | 16 + applications/electron-shell/ts/main.ts | 737 +++++++++++++++++- applications/remote-theia/package.json | 1 - .../remote-theia/src-gen/backend/main.js | 4 - .../remote-theia/src-gen/backend/server.js | 1 - .../remote-theia/src-gen/frontend/index.js | 1 - architecture.md | 6 +- packages/opencode-bridge/ts/index.ts | 14 + pnpm-lock.yaml | 130 ++- readme.md | 4 +- readme.plan.md | 5 +- test/test.opencode.node.ts | 25 +- .../gitzone-opencode-frontend-module.d.ts | 22 - .../gitzone-opencode-frontend-module.d.ts.map | 1 - .../gitzone-opencode-frontend-module.js | 83 -- .../gitzone-opencode-frontend-module.js.map | 1 - .../lib/common/gitzone-opencode-protocol.d.ts | 51 -- .../common/gitzone-opencode-protocol.d.ts.map | 1 - .../lib/common/gitzone-opencode-protocol.js | 6 - .../common/gitzone-opencode-protocol.js.map | 1 - .../node/gitzone-opencode-backend-module.d.ts | 4 - .../gitzone-opencode-backend-module.d.ts.map | 1 - .../node/gitzone-opencode-backend-module.js | 21 - .../gitzone-opencode-backend-module.js.map | 1 - .../node/gitzone-opencode-node-service.d.ts | 36 - .../gitzone-opencode-node-service.d.ts.map | 1 - .../lib/node/gitzone-opencode-node-service.js | 182 ----- .../node/gitzone-opencode-node-service.js.map | 1 - .../gitzone-opencode/lib/node/plugins.d.ts | 3 - .../lib/node/plugins.d.ts.map | 1 - .../gitzone-opencode/lib/node/plugins.js | 29 - .../gitzone-opencode/lib/node/plugins.js.map | 1 - .../gitzone-opencode/package.json | 20 - .../gitzone-opencode-frontend-module.ts | 76 -- .../src/common/gitzone-opencode-protocol.ts | 53 -- .../node/gitzone-opencode-backend-module.ts | 26 - .../src/node/gitzone-opencode-node-service.ts | 188 ----- .../gitzone-opencode/src/node/plugins.ts | 3 - .../gitzone-opencode/tsconfig.json | 14 - 40 files changed, 901 insertions(+), 881 deletions(-) delete mode 100644 theia-extensions/gitzone-opencode/lib/browser/gitzone-opencode-frontend-module.d.ts delete mode 100644 theia-extensions/gitzone-opencode/lib/browser/gitzone-opencode-frontend-module.d.ts.map delete mode 100644 theia-extensions/gitzone-opencode/lib/browser/gitzone-opencode-frontend-module.js delete mode 100644 theia-extensions/gitzone-opencode/lib/browser/gitzone-opencode-frontend-module.js.map delete mode 100644 theia-extensions/gitzone-opencode/lib/common/gitzone-opencode-protocol.d.ts delete mode 100644 theia-extensions/gitzone-opencode/lib/common/gitzone-opencode-protocol.d.ts.map delete mode 100644 theia-extensions/gitzone-opencode/lib/common/gitzone-opencode-protocol.js delete mode 100644 theia-extensions/gitzone-opencode/lib/common/gitzone-opencode-protocol.js.map delete mode 100644 theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-backend-module.d.ts delete mode 100644 theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-backend-module.d.ts.map delete mode 100644 theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-backend-module.js delete mode 100644 theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-backend-module.js.map delete mode 100644 theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.d.ts delete mode 100644 theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.d.ts.map delete mode 100644 theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.js delete mode 100644 theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.js.map delete mode 100644 theia-extensions/gitzone-opencode/lib/node/plugins.d.ts delete mode 100644 theia-extensions/gitzone-opencode/lib/node/plugins.d.ts.map delete mode 100644 theia-extensions/gitzone-opencode/lib/node/plugins.js delete mode 100644 theia-extensions/gitzone-opencode/lib/node/plugins.js.map delete mode 100644 theia-extensions/gitzone-opencode/package.json delete mode 100644 theia-extensions/gitzone-opencode/src/browser/gitzone-opencode-frontend-module.ts delete mode 100644 theia-extensions/gitzone-opencode/src/common/gitzone-opencode-protocol.ts delete mode 100644 theia-extensions/gitzone-opencode/src/node/gitzone-opencode-backend-module.ts delete mode 100644 theia-extensions/gitzone-opencode/src/node/gitzone-opencode-node-service.ts delete mode 100644 theia-extensions/gitzone-opencode/src/node/plugins.ts delete mode 100644 theia-extensions/gitzone-opencode/tsconfig.json diff --git a/applications/electron-shell/package.json b/applications/electron-shell/package.json index 7efce9d..96cdeb9 100644 --- a/applications/electron-shell/package.json +++ b/applications/electron-shell/package.json @@ -10,14 +10,19 @@ "package": "pnpm run build && electron-builder --config electron-builder.yml" }, "dependencies": { - "@git.zone/ide-protocol": "workspace:*", "@git.zone/ide-opencode-bridge": "workspace:*", + "@git.zone/ide-protocol": "workspace:*", "@git.zone/ide-server-installer": "workspace:*", "@git.zone/ide-ssh": "workspace:*", - "electron": "^42.0.1" + "electron": "^42.0.1", + "opencode-ai": "1.14.48" }, "devDependencies": { "electron-builder": "^26.8.1" }, - "files": ["dist_ts/**/*", "preload.cjs", "electron-builder.yml"] + "files": [ + "dist_ts/**/*", + "preload.cjs", + "electron-builder.yml" + ] } diff --git a/applications/electron-shell/preload.cjs b/applications/electron-shell/preload.cjs index 2989459..6324de9 100644 --- a/applications/electron-shell/preload.cjs +++ b/applications/electron-shell/preload.cjs @@ -6,6 +6,22 @@ contextBridge.exposeInMainWorld('gitZoneIde', { connect: (input) => ipcRenderer.invoke('gitzone:connect', input), addProject: (input) => ipcRenderer.invoke('gitzone:add-project', input), openProject: (input) => ipcRenderer.invoke('gitzone:open-project', input), + openCode: { + health: (input) => ipcRenderer.invoke('gitzone:opencode-health', input), + sessions: (input) => ipcRenderer.invoke('gitzone:opencode-sessions', input), + createSession: (input) => ipcRenderer.invoke('gitzone:opencode-create-session', input), + messages: (input) => ipcRenderer.invoke('gitzone:opencode-messages', input), + prompt: (input) => ipcRenderer.invoke('gitzone:opencode-prompt', input), + abort: (input) => ipcRenderer.invoke('gitzone:opencode-abort', input), + respondToPermission: (input) => ipcRenderer.invoke('gitzone:opencode-respond-permission', input), + providers: (input) => ipcRenderer.invoke('gitzone:opencode-providers', input), + agents: (input) => ipcRenderer.invoke('gitzone:opencode-agents', input), + onEvent: (callback) => { + const listener = (_event, payload) => callback(payload); + ipcRenderer.on('gitzone:opencode-event', listener); + return () => ipcRenderer.removeListener('gitzone:opencode-event', listener); + }, + }, onConnectProgress: (callback) => { const listener = (_event, message) => callback(message); ipcRenderer.on('gitzone:connect-progress', listener); diff --git a/applications/electron-shell/ts/main.ts b/applications/electron-shell/ts/main.ts index 44b5a13..b0bb76d 100644 --- a/applications/electron-shell/ts/main.ts +++ b/applications/electron-shell/ts/main.ts @@ -213,6 +213,76 @@ class GitZoneIdeElectronShell { progress(`Project ${project.title} is ready.`); return instance; }); + + plugins.electron.ipcMain.handle('gitzone:opencode-health', async (_event, input: IOpenCodeRuntimeInput) => { + return this.createLocalOpenCodeClient(input.instanceId).health(); + }); + + plugins.electron.ipcMain.handle('gitzone:opencode-sessions', async (_event, input: IOpenCodeRuntimeInput) => { + return this.createLocalOpenCodeClient(input.instanceId).sessions(); + }); + + plugins.electron.ipcMain.handle('gitzone:opencode-create-session', async (_event, input: IOpenCodeCreateSessionInput) => { + return this.createLocalOpenCodeClient(input.instanceId).createSession({ + title: trimOptional(input.title), + }); + }); + + plugins.electron.ipcMain.handle('gitzone:opencode-messages', async (_event, input: IOpenCodeSessionInput) => { + return this.createLocalOpenCodeClient(input.instanceId).messages( + requireTrimmed(input.sessionId, 'OpenCode session id'), + normalizeOptionalLimit(input.limit, 200), + ); + }); + + plugins.electron.ipcMain.handle('gitzone:opencode-prompt', async (_event, input: IOpenCodePromptInput) => { + const text = requireBoundedText(input.text, 'Prompt', 200000); + await this.createLocalOpenCodeClient(input.instanceId).promptAsync( + requireTrimmed(input.sessionId, 'OpenCode session id'), + { + agent: trimOptional(input.agent), + parts: [{ type: 'text', text }], + }, + ); + return { ok: true }; + }); + + plugins.electron.ipcMain.handle('gitzone:opencode-abort', async (_event, input: IOpenCodeSessionInput) => { + return this.createLocalOpenCodeClient(input.instanceId).abort(requireTrimmed(input.sessionId, 'OpenCode session id')); + }); + + plugins.electron.ipcMain.handle('gitzone:opencode-respond-permission', async (_event, input: IOpenCodePermissionInput) => { + return this.createLocalOpenCodeClient(input.instanceId).respondToPermission( + requireTrimmed(input.sessionId, 'OpenCode session id'), + requireTrimmed(input.permissionId, 'OpenCode permission id'), + { + response: requireTrimmed(input.response, 'OpenCode permission response'), + remember: input.remember, + }, + ); + }); + + plugins.electron.ipcMain.handle('gitzone:opencode-providers', async (_event, input: IOpenCodeRuntimeInput) => { + return this.createLocalOpenCodeClient(input.instanceId).providers(); + }); + + plugins.electron.ipcMain.handle('gitzone:opencode-agents', async (_event, input: IOpenCodeRuntimeInput) => { + return this.createLocalOpenCodeClient(input.instanceId).agents(); + }); + } + + private requireLocalOpenCodeRuntime(instanceId: string | undefined) { + const id = requireTrimmed(instanceId, 'OpenCode runtime id'); + const runtime = this.localOpenCodeRuntimes.get(id); + if (!runtime) { + throw new Error(`OpenCode runtime not found: ${id}`); + } + return runtime; + } + + private createLocalOpenCodeClient(instanceId: string | undefined) { + const runtime = this.requireLocalOpenCodeRuntime(instanceId); + return createLocalOpenCodeClient(runtime); } private async ensureToolBridge() { @@ -292,6 +362,7 @@ class GitZoneIdeElectronShell { }); await waitForOpenCodeHealth(baseUrl, username, password, 15000); + startOpenCodeEventStream(runtime); progress(`Local OpenCode server ready for ${project.title}; remote tools are bridged over SSH.`); return { status: 'ready', @@ -385,6 +456,30 @@ interface IOpenProjectInput { projectPath?: string; } +interface IOpenCodeRuntimeInput { + instanceId?: string; +} + +interface IOpenCodeCreateSessionInput extends IOpenCodeRuntimeInput { + title?: string; +} + +interface IOpenCodeSessionInput extends IOpenCodeRuntimeInput { + sessionId?: string; + limit?: number; +} + +interface IOpenCodePromptInput extends IOpenCodeSessionInput { + text?: string; + agent?: string; +} + +interface IOpenCodePermissionInput extends IOpenCodeSessionInput { + permissionId?: string; + response?: string; + remember?: boolean; +} + interface IRemoteProject { id: string; path: string; @@ -425,6 +520,7 @@ interface ILocalOpenCodeRuntime { process: childProcess.ChildProcess; bridge: LocalOpenCodeToolBridge; bridgeToken: string; + eventAbortController?: AbortController; configDir: string; proxyWorkspacePath: string; baseUrl: string; @@ -649,12 +745,13 @@ const writeOpenCodeBridgeConfig = async (configDir: string) => { }; const resolveOpenCodeExecutable = async () => { + const executableName = process.platform === 'win32' ? 'opencode.cmd' : 'opencode'; const candidates = [ process.env.GITZONE_IDE_OPENCODE_BINARY, - process.env.OPENCODE_BINARY, - path.join(os.homedir(), '.opencode', 'bin', 'opencode'), - '/usr/local/bin/opencode', - '/usr/bin/opencode', + path.join(electronPackageRoot, 'node_modules', '.bin', executableName), + path.join(workspaceRoot, 'node_modules', '.bin', executableName), + path.join(electronPackageRoot, 'node_modules', 'opencode-ai', 'bin', 'opencode'), + path.join(workspaceRoot, 'node_modules', 'opencode-ai', 'bin', 'opencode'), ].filter(Boolean) as string[]; for (const candidate of candidates) { try { @@ -662,7 +759,7 @@ const resolveOpenCodeExecutable = async () => { return candidate; } catch {} } - return 'opencode'; + throw new Error('Git.Zone IDE OpenCode install not found. Run pnpm install so the electron shell dependency opencode-ai is available.'); }; const waitForOpenCodeHealth = async (baseUrl: string, username: string, password: string, timeoutMs: number) => { @@ -692,6 +789,7 @@ const waitForOpenCodeHealth = async (baseUrl: string, username: string, password }; const disposeLocalOpenCodeRuntime = (runtime: ILocalOpenCodeRuntime) => { + runtime.eventAbortController?.abort(); runtime.bridge.unregister(runtime.bridgeToken); if (runtime.process.exitCode === null && !runtime.process.killed) { runtime.process.kill('SIGTERM'); @@ -748,6 +846,68 @@ const normalizeOptionalPort = (value: number | undefined, label: string) => { return port; }; +const normalizeOptionalLimit = (value: number | undefined, defaultLimit: number) => { + if (value === undefined || value === null) { + return defaultLimit; + } + const limit = Number(value); + if (!Number.isInteger(limit) || limit <= 0) { + return defaultLimit; + } + return Math.min(limit, 500); +}; + +const requireBoundedText = (value: string | undefined, label: string, maxLength: number) => { + const text = requireTrimmed(value, label); + if (text.length > maxLength) { + throw new Error(`${label} must not exceed ${maxLength} characters.`); + } + return text; +}; + +const createLocalOpenCodeClient = (runtime: ILocalOpenCodeRuntime) => new plugins.ideOpenCodeBridge.OpenCodeServerClient({ + baseUrl: runtime.baseUrl, + username: runtime.username, + password: runtime.password, +}); + +const startOpenCodeEventStream = (runtime: ILocalOpenCodeRuntime) => { + runtime.eventAbortController?.abort(); + const abortController = new AbortController(); + runtime.eventAbortController = abortController; + void (async () => { + while (!abortController.signal.aborted && runtime.process.exitCode === null) { + try { + const client = createLocalOpenCodeClient(runtime); + for await (const event of client.events(abortController.signal)) { + if (abortController.signal.aborted) { + return; + } + sendOpenCodeEventToRenderers(runtime.instanceId, event); + } + } catch (error) { + if (abortController.signal.aborted || runtime.process.exitCode !== null) { + return; + } + console.warn(`OpenCode event stream failed for ${runtime.instanceId}: ${error instanceof Error ? error.message : String(error)}`); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + })(); +}; + +const sendOpenCodeEventToRenderers = (instanceId: string, event: plugins.ideOpenCodeBridge.IOpenCodeEvent) => { + const payload = { + instanceId, + event: plugins.ideOpenCodeBridge.sanitizeOpenCodeEventForRenderer(event), + }; + for (const window of plugins.electron.BrowserWindow.getAllWindows()) { + if (!window.webContents.isDestroyed()) { + window.webContents.send('gitzone:opencode-event', payload); + } + } +}; + const toHostSessionDescriptor = (session: IRemoteHostSession) => ({ id: session.id, hostAlias: session.target.hostAlias, @@ -1085,7 +1245,7 @@ const renderLauncherHtml = () => ` Git.Zone IDE -
+
Git.Zone IDENot connected
@@ -1240,6 +1442,18 @@ const renderLauncherHtml = () => ` + +
+
+
+ +
+ + +
+
-
Connect to SSH Host
+
+ Connect to SSH Host +
+ +
+

Connect to Remote Host

@@ -1300,29 +1520,42 @@ const renderLauncherHtml = () => `
+
Output

       
-
ReadyOpenSSH
+
+
Ready
+
OpenSSH
+