Initial commit

This commit is contained in:
Mateusz Tymek
2026-01-03 16:07:55 +00:00
commit d2c90fbd49
14 changed files with 1982 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules

135
AGENTS.md Normal file
View File

@@ -0,0 +1,135 @@
# AGENTS.md - Obsidian OpenCode Plugin
Guidelines for AI coding agents working on the obsidian-opencode plugin.
## Project Overview
Obsidian plugin that embeds the OpenCode AI assistant via an iframe. Spawns a local server process and displays its web UI in the Obsidian sidebar.
**Tech Stack:** TypeScript, Obsidian Plugin API, esbuild, Node.js child processes
## Build Commands
```bash
bun install # Install dependencies
bun run dev # Development (watch mode)
bun run build # Production (type-check + bundle)
```
Output: `main.js` (CommonJS bundle)
## Testing & Linting
**Not configured.** If adding:
- Tests: Vitest, files in `src/__tests__/` or `*.test.ts`
- Linting: ESLint + `@typescript-eslint/parser`
- Scripts: `"test": "vitest run"`, `"lint": "eslint src"`
## Project Structure
```
src/
├── main.ts # Plugin entry, extends Plugin
├── types.ts # Types and constants
├── OpenCodeView.ts # Sidebar view (ItemView) with iframe
├── ProcessManager.ts # Server process lifecycle
└── SettingsTab.ts # Settings UI (PluginSettingTab)
```
## Code Style
### Imports
- ES modules with named imports
- Order: Obsidian API, Node.js builtins, local modules
- Use `type` for type-only imports
- Relative paths with `./` prefix
```typescript
import { Plugin, WorkspaceLeaf, Notice } from "obsidian";
import { spawn, ChildProcess } from "child_process";
import type OpenCodePlugin from "./main";
import { OpenCodeSettings, DEFAULT_SETTINGS } from "./types";
```
### Exports
- `export default class` for main plugin
- Named exports for other classes/types/constants
- One class per file, filename matches class (PascalCase)
### Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| Classes | PascalCase | `OpenCodePlugin`, `ProcessManager` |
| Interfaces/Types | PascalCase | `OpenCodeSettings`, `ProcessState` |
| Constants | UPPER_CASE or camelCase | `DEFAULT_SETTINGS`, `OPENCODE_VIEW_TYPE` |
| Variables/functions | camelCase | `getVaultPath`, `startServer` |
| Private members | camelCase (no prefix) | `private processManager` |
| Files | PascalCase (classes), lowercase (entry) | `ProcessManager.ts`, `main.ts` |
### TypeScript Patterns
- `strictNullChecks` enabled - handle null/undefined
- Union types for state: `"stopped" | "starting" | "running" | "error"`
- `async/await` over Promises
- Explicit return types on public methods
```typescript
getProcessState(): ProcessState {
return this.processManager?.getState() ?? "stopped";
}
```
### Error Handling
- try/catch for async operations
- `console.error()` for debugging
- `new Notice()` for user-facing errors
- Boolean returns for success/failure
- Silent catch for non-critical ops (health checks)
```typescript
try {
await this.processManager.start();
} catch (error) {
console.error("Failed to start:", error);
new Notice(`Failed to start OpenCode: ${error.message}`);
return false;
}
```
### Obsidian API Patterns
- Extend `Plugin` with `onload()`/`onunload()` lifecycle
- Extend `ItemView` for views: `getViewType()`, `onOpen()`, `onClose()`
- Extend `PluginSettingTab` for settings: `display()`
- DOM helpers: `createEl()`, `createDiv()`, `setIcon()`
- Register in `onload()`, clean up in `onunload()`
```typescript
this.registerView(OPENCODE_VIEW_TYPE, (leaf) => new OpenCodeView(leaf, this));
this.addCommand({ id: "toggle-view", name: "Toggle panel", callback: () => this.toggleView() });
```
### DOM Creation
```typescript
const container = this.contentEl.createDiv({ cls: "opencode-container" });
container.createEl("h3", { text: "Title" });
container.createEl("button", { text: "Click", cls: "mod-cta" });
```
### State Management
- Callback-based subscriptions
- Centralized state in manager classes
- Immediate notification on state change
## Config Summary
**tsconfig.json:** ES6 target, ESNext modules, strictNullChecks, noImplicitAny
**esbuild:** CJS format, es2018 target, node platform. Externals: obsidian, electron, CodeMirror, Node builtins
## Desktop-Only
Uses Node.js APIs unavailable on mobile:
- `child_process.spawn()` for server process
- File system via vault adapter
Check for desktop environment before adding mobile-incompatible features.

98
bun.lock Normal file
View File

@@ -0,0 +1,98 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "obsidian-opencode",
"devDependencies": {
"@types/node": "^20.11.0",
"builtin-modules": "^3.3.0",
"esbuild": "^0.21.5",
"obsidian": "latest",
"tslib": "^2.6.2",
"typescript": "^5.4.5",
},
},
},
"packages": {
"@codemirror/state": ["@codemirror/state@6.5.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw=="],
"@codemirror/view": ["@codemirror/view@6.38.6", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@types/codemirror": ["@types/codemirror@5.60.8", "", { "dependencies": { "@types/tern": "*" } }, "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
"@types/tern": ["@types/tern@0.23.9", "", { "dependencies": { "@types/estree": "*" } }, "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw=="],
"builtin-modules": ["builtin-modules@3.3.0", "", {}, "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"moment": ["moment@2.29.4", "", {}, "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="],
"obsidian": ["obsidian@1.11.0", "", { "dependencies": { "@types/codemirror": "5.60.8", "moment": "2.29.4" }, "peerDependencies": { "@codemirror/state": "6.5.0", "@codemirror/view": "6.38.6" } }, "sha512-lVqN9AmDWHzhNATi2tDnjqVgI6WUYKeT+lIsAycAyLt4XCC6zRsWzb+tFCiB7Rn3PpttefjoovilhYwvS4Iqxw=="],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
}
}

49
esbuild.config.mjs Normal file
View File

@@ -0,0 +1,49 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
If you want to view the source, please visit the GitHub repository.
*/
`;
const prod = process.argv[2] === "production";
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
platform: "node",
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

632
main.js Normal file
View File

@@ -0,0 +1,632 @@
/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
If you want to view the source, please visit the GitHub repository.
*/
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/main.ts
var main_exports = {};
__export(main_exports, {
default: () => OpenCodePlugin
});
module.exports = __toCommonJS(main_exports);
var import_obsidian4 = require("obsidian");
// src/types.ts
var DEFAULT_SETTINGS = {
port: 14096,
hostname: "127.0.0.1",
autoStart: false,
opencodePath: "opencode"
};
var OPENCODE_VIEW_TYPE = "opencode-view";
// src/OpenCodeView.ts
var import_obsidian = require("obsidian");
var OpenCodeView = class extends import_obsidian.ItemView {
constructor(leaf, plugin) {
super(leaf);
this.iframeEl = null;
this.currentState = "stopped";
this.plugin = plugin;
}
getViewType() {
return OPENCODE_VIEW_TYPE;
}
getDisplayText() {
return "OpenCode";
}
getIcon() {
return "terminal";
}
async onOpen() {
this.contentEl.empty();
this.contentEl.addClass("opencode-container");
this.plugin.onProcessStateChange((state) => {
this.currentState = state;
this.updateView();
});
this.currentState = this.plugin.getProcessState();
this.updateView();
if (this.currentState === "stopped") {
this.plugin.startServer();
}
}
async onClose() {
if (this.iframeEl) {
this.iframeEl.src = "about:blank";
this.iframeEl = null;
}
}
updateView() {
switch (this.currentState) {
case "stopped":
this.renderStoppedState();
break;
case "starting":
this.renderStartingState();
break;
case "running":
this.renderRunningState();
break;
case "error":
this.renderErrorState();
break;
}
}
renderStoppedState() {
this.contentEl.empty();
const statusContainer = this.contentEl.createDiv({
cls: "opencode-status-container"
});
const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" });
(0, import_obsidian.setIcon)(iconEl, "power-off");
statusContainer.createEl("h3", { text: "OpenCode is stopped" });
statusContainer.createEl("p", {
text: "Click the button below to start the OpenCode server.",
cls: "opencode-status-message"
});
const startButton = statusContainer.createEl("button", {
text: "Start OpenCode",
cls: "mod-cta"
});
startButton.addEventListener("click", () => {
this.plugin.startServer();
});
}
renderStartingState() {
this.contentEl.empty();
const statusContainer = this.contentEl.createDiv({
cls: "opencode-status-container"
});
const loadingEl = statusContainer.createDiv({ cls: "opencode-loading" });
loadingEl.createDiv({ cls: "opencode-spinner" });
statusContainer.createEl("h3", { text: "Starting OpenCode..." });
statusContainer.createEl("p", {
text: "Please wait while the server starts up.",
cls: "opencode-status-message"
});
}
renderRunningState() {
this.contentEl.empty();
const headerEl = this.contentEl.createDiv({ cls: "opencode-header" });
const titleSection = headerEl.createDiv({ cls: "opencode-header-title" });
const iconEl = titleSection.createSpan();
(0, import_obsidian.setIcon)(iconEl, "terminal");
titleSection.createSpan({ text: "OpenCode" });
const actionsEl = headerEl.createDiv({ cls: "opencode-header-actions" });
const reloadButton = actionsEl.createEl("button", {
attr: { "aria-label": "Reload" }
});
(0, import_obsidian.setIcon)(reloadButton, "refresh-cw");
reloadButton.addEventListener("click", () => {
this.reloadIframe();
});
const externalButton = actionsEl.createEl("button", {
attr: { "aria-label": "Open in browser" }
});
(0, import_obsidian.setIcon)(externalButton, "external-link");
externalButton.addEventListener("click", () => {
window.open(this.plugin.getServerUrl(), "_blank");
});
const stopButton = actionsEl.createEl("button", {
attr: { "aria-label": "Stop server" }
});
(0, import_obsidian.setIcon)(stopButton, "square");
stopButton.addEventListener("click", () => {
this.plugin.stopServer();
});
const iframeContainer = this.contentEl.createDiv({
cls: "opencode-iframe-container"
});
this.iframeEl = iframeContainer.createEl("iframe", {
cls: "opencode-iframe",
attr: {
src: this.plugin.getServerUrl(),
frameborder: "0",
allow: "clipboard-read; clipboard-write"
}
});
this.iframeEl.addEventListener("error", () => {
console.error("Failed to load OpenCode iframe");
});
}
renderErrorState() {
this.contentEl.empty();
const statusContainer = this.contentEl.createDiv({
cls: "opencode-status-container opencode-error"
});
const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" });
(0, import_obsidian.setIcon)(iconEl, "alert-circle");
statusContainer.createEl("h3", { text: "Failed to start OpenCode" });
statusContainer.createEl("p", {
text: "There was an error starting the OpenCode server. Please check that OpenCode is installed and try again.",
cls: "opencode-status-message"
});
const retryButton = statusContainer.createEl("button", {
text: "Retry",
cls: "mod-cta"
});
retryButton.addEventListener("click", () => {
this.plugin.startServer();
});
const settingsButton = statusContainer.createEl("button", {
text: "Open Settings"
});
settingsButton.addEventListener("click", () => {
this.app.setting.open();
this.app.setting.openTabById("obsidian-opencode");
});
}
reloadIframe() {
if (this.iframeEl) {
const src = this.iframeEl.src;
this.iframeEl.src = "about:blank";
setTimeout(() => {
if (this.iframeEl) {
this.iframeEl.src = src;
}
}, 100);
}
}
};
// src/SettingsTab.ts
var import_obsidian2 = require("obsidian");
var OpenCodeSettingTab = class extends import_obsidian2.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "OpenCode Settings" });
containerEl.createEl("h3", { text: "Server Configuration" });
new import_obsidian2.Setting(containerEl).setName("Port").setDesc("Port number for the OpenCode web server").addText(
(text) => text.setPlaceholder("14096").setValue(this.plugin.settings.port.toString()).onChange(async (value) => {
const port = parseInt(value, 10);
if (!isNaN(port) && port > 0 && port < 65536) {
this.plugin.settings.port = port;
await this.plugin.saveSettings();
}
})
);
new import_obsidian2.Setting(containerEl).setName("Hostname").setDesc("Hostname to bind the server to (usually 127.0.0.1)").addText(
(text) => text.setPlaceholder("127.0.0.1").setValue(this.plugin.settings.hostname).onChange(async (value) => {
this.plugin.settings.hostname = value || "127.0.0.1";
await this.plugin.saveSettings();
})
);
new import_obsidian2.Setting(containerEl).setName("OpenCode path").setDesc(
"Path to the OpenCode executable. Leave as 'opencode' if it's in your PATH."
).addText(
(text) => text.setPlaceholder("opencode").setValue(this.plugin.settings.opencodePath).onChange(async (value) => {
this.plugin.settings.opencodePath = value || "opencode";
await this.plugin.saveSettings();
})
);
containerEl.createEl("h3", { text: "Behavior" });
new import_obsidian2.Setting(containerEl).setName("Auto-start server").setDesc(
"Automatically start the OpenCode server when Obsidian opens (not recommended for faster startup)"
).addToggle(
(toggle) => toggle.setValue(this.plugin.settings.autoStart).onChange(async (value) => {
this.plugin.settings.autoStart = value;
await this.plugin.saveSettings();
})
);
containerEl.createEl("h3", { text: "Server Status" });
const statusContainer = containerEl.createDiv({ cls: "opencode-settings-status" });
this.renderServerStatus(statusContainer);
}
renderServerStatus(container) {
container.empty();
const state = this.plugin.getProcessState();
const statusText = {
stopped: "Stopped",
starting: "Starting...",
running: "Running",
error: "Error"
};
const statusClass = {
stopped: "status-stopped",
starting: "status-starting",
running: "status-running",
error: "status-error"
};
const statusEl = container.createDiv({ cls: "opencode-status-line" });
statusEl.createSpan({ text: "Status: " });
statusEl.createSpan({
text: statusText[state],
cls: `opencode-status-badge ${statusClass[state]}`
});
if (state === "running") {
const urlEl = container.createDiv({ cls: "opencode-status-line" });
urlEl.createSpan({ text: "URL: " });
const linkEl = urlEl.createEl("a", {
text: this.plugin.getServerUrl(),
href: this.plugin.getServerUrl()
});
linkEl.addEventListener("click", (e) => {
e.preventDefault();
window.open(this.plugin.getServerUrl(), "_blank");
});
}
const buttonContainer = container.createDiv({ cls: "opencode-settings-buttons" });
if (state === "stopped" || state === "error") {
const startButton = buttonContainer.createEl("button", {
text: "Start Server",
cls: "mod-cta"
});
startButton.addEventListener("click", async () => {
await this.plugin.startServer();
this.renderServerStatus(container);
});
}
if (state === "running") {
const stopButton = buttonContainer.createEl("button", {
text: "Stop Server"
});
stopButton.addEventListener("click", () => {
this.plugin.stopServer();
this.renderServerStatus(container);
});
const restartButton = buttonContainer.createEl("button", {
text: "Restart Server",
cls: "mod-warning"
});
restartButton.addEventListener("click", async () => {
this.plugin.stopServer();
await this.plugin.startServer();
this.renderServerStatus(container);
});
}
if (state === "starting") {
buttonContainer.createSpan({
text: "Please wait...",
cls: "opencode-status-waiting"
});
}
}
};
// src/ProcessManager.ts
var import_child_process = require("child_process");
var import_obsidian3 = require("obsidian");
var ProcessManager = class {
constructor(settings, workingDirectory, projectDirectory, onStateChange) {
this.process = null;
this.state = "stopped";
this.startupTimeout = null;
this.settings = settings;
this.workingDirectory = workingDirectory;
this.projectDirectory = projectDirectory;
this.onStateChange = onStateChange;
}
updateSettings(settings) {
this.settings = settings;
}
getState() {
return this.state;
}
getUrl() {
return `http://${this.settings.hostname}:${this.settings.port}`;
}
async start() {
var _a, _b;
if (this.state === "running" || this.state === "starting") {
return true;
}
this.setState("starting");
try {
const alreadyRunning = await this.checkServerHealth();
if (alreadyRunning) {
console.log("OpenCode server already running on port", this.settings.port);
this.setState("running");
return true;
}
console.log("[OpenCode] Starting server:", {
path: this.settings.opencodePath,
cwd: this.workingDirectory,
project: this.projectDirectory,
port: this.settings.port,
hostname: this.settings.hostname
});
this.process = (0, import_child_process.spawn)(
this.settings.opencodePath,
[
"serve",
this.projectDirectory,
"--port",
this.settings.port.toString(),
"--hostname",
this.settings.hostname,
"--cors",
"app://obsidian.md"
],
{
cwd: this.workingDirectory,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false
}
);
console.log("[OpenCode] Process spawned with PID:", this.process.pid);
(_a = this.process.stdout) == null ? void 0 : _a.on("data", (data) => {
console.log("[OpenCode]", data.toString().trim());
});
(_b = this.process.stderr) == null ? void 0 : _b.on("data", (data) => {
console.error("[OpenCode Error]", data.toString().trim());
});
this.process.on("exit", (code, signal) => {
console.log(`OpenCode process exited with code ${code}, signal ${signal}`);
this.process = null;
if (this.state === "running") {
this.setState("stopped");
}
});
this.process.on("error", (err) => {
console.error("Failed to start OpenCode process:", err);
new import_obsidian3.Notice(`Failed to start OpenCode: ${err.message}`);
this.process = null;
this.setState("error");
});
const ready = await this.waitForServerOrExit(15e3);
if (ready) {
this.setState("running");
return true;
} else {
this.stop();
this.setState("error");
new import_obsidian3.Notice("OpenCode server failed to start within timeout");
return false;
}
} catch (error) {
console.error("Error starting OpenCode:", error);
this.setState("error");
return false;
}
}
stop() {
if (this.startupTimeout) {
clearTimeout(this.startupTimeout);
this.startupTimeout = null;
}
if (this.process) {
try {
this.process.kill("SIGTERM");
setTimeout(() => {
if (this.process && !this.process.killed) {
this.process.kill("SIGKILL");
}
}, 2e3);
} catch (error) {
console.error("Error stopping OpenCode process:", error);
}
this.process = null;
}
this.setState("stopped");
}
setState(state) {
this.state = state;
this.onStateChange(state);
}
async checkServerHealth() {
try {
const response = await fetch(`${this.getUrl()}/global/health`, {
method: "GET",
signal: AbortSignal.timeout(2e3)
});
return response.ok;
} catch (e) {
return false;
}
}
async waitForServerOrExit(timeoutMs) {
const startTime = Date.now();
const pollInterval = 500;
while (Date.now() - startTime < timeoutMs) {
if (!this.process) {
console.log("OpenCode process exited before server became ready");
return false;
}
if (await this.checkServerHealth()) {
return true;
}
await this.sleep(pollInterval);
}
return false;
}
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
};
// src/main.ts
var OpenCodePlugin = class extends import_obsidian4.Plugin {
constructor() {
super(...arguments);
this.settings = DEFAULT_SETTINGS;
this.processManager = null;
this.stateChangeCallbacks = [];
}
async onload() {
console.log("Loading OpenCode plugin");
await this.loadSettings();
this.processManager = new ProcessManager(
this.settings,
this.getVaultPath(),
this.getVaultPath(),
(state) => this.notifyStateChange(state)
);
this.registerView(OPENCODE_VIEW_TYPE, (leaf) => new OpenCodeView(leaf, this));
this.addRibbonIcon("terminal", "OpenCode", () => {
this.activateView();
});
this.addCommand({
id: "toggle-opencode-view",
name: "Toggle OpenCode panel",
callback: () => {
this.toggleView();
},
hotkeys: [
{
modifiers: ["Mod", "Shift"],
key: "o"
}
]
});
this.addCommand({
id: "start-opencode-server",
name: "Start OpenCode server",
callback: () => {
this.startServer();
}
});
this.addCommand({
id: "stop-opencode-server",
name: "Stop OpenCode server",
callback: () => {
this.stopServer();
}
});
this.addSettingTab(new OpenCodeSettingTab(this.app, this));
if (this.settings.autoStart) {
this.app.workspace.onLayoutReady(async () => {
await this.startServer();
});
}
console.log("OpenCode plugin loaded");
}
async onunload() {
console.log("Unloading OpenCode plugin");
this.stopServer();
this.app.workspace.detachLeavesOfType(OPENCODE_VIEW_TYPE);
console.log("OpenCode plugin unloaded");
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
if (this.processManager) {
this.processManager.updateSettings(this.settings);
}
}
// Get existing view leaf if any
getExistingLeaf() {
const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE);
return leaves.length > 0 ? leaves[0] : null;
}
// Activate or create the view
async activateView() {
const existingLeaf = this.getExistingLeaf();
if (existingLeaf) {
this.app.workspace.revealLeaf(existingLeaf);
return;
}
const leaf = this.app.workspace.getRightLeaf(false);
if (leaf) {
await leaf.setViewState({
type: OPENCODE_VIEW_TYPE,
active: true
});
this.app.workspace.revealLeaf(leaf);
}
}
// Toggle view visibility
async toggleView() {
const existingLeaf = this.getExistingLeaf();
if (existingLeaf) {
const rightSplit = this.app.workspace.rightSplit;
if (rightSplit && !rightSplit.collapsed) {
existingLeaf.detach();
} else {
this.app.workspace.revealLeaf(existingLeaf);
}
} else {
await this.activateView();
}
}
// Start the OpenCode server
async startServer() {
if (!this.processManager) {
new import_obsidian4.Notice("OpenCode: Process manager not initialized");
return false;
}
const success = await this.processManager.start();
if (success) {
new import_obsidian4.Notice("OpenCode server started");
}
return success;
}
// Stop the OpenCode server
stopServer() {
if (this.processManager) {
this.processManager.stop();
new import_obsidian4.Notice("OpenCode server stopped");
}
}
// Get the current process state
getProcessState() {
var _a, _b;
return (_b = (_a = this.processManager) == null ? void 0 : _a.getState()) != null ? _b : "stopped";
}
// Get the server URL
getServerUrl() {
var _a, _b;
return (_b = (_a = this.processManager) == null ? void 0 : _a.getUrl()) != null ? _b : `http://127.0.0.1:${this.settings.port}`;
}
// Subscribe to process state changes
onProcessStateChange(callback) {
this.stateChangeCallbacks.push(callback);
}
// Notify all subscribers of state change
notifyStateChange(state) {
for (const callback of this.stateChangeCallbacks) {
callback(state);
}
}
// Get the vault path
getVaultPath() {
const adapter = this.app.vault.adapter;
return adapter.basePath || "";
}
};

9
manifest.json Normal file
View File

@@ -0,0 +1,9 @@
{
"id": "obsidian-opencode",
"name": "OpenCode",
"version": "0.1.0",
"minAppVersion": "1.4.0",
"description": "Embed OpenCode AI assistant in Obsidian for AI-powered note management",
"author": "mat",
"isDesktopOnly": true
}

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "obsidian-opencode",
"version": "0.1.0",
"description": "Embed OpenCode AI assistant in Obsidian",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "npx tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
},
"keywords": [
"obsidian",
"opencode",
"ai",
"assistant"
],
"author": "mat",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.11.0",
"builtin-modules": "^3.3.0",
"esbuild": "^0.21.5",
"obsidian": "latest",
"tslib": "^2.6.2",
"typescript": "^5.4.5"
}
}

219
src/OpenCodeView.ts Normal file
View File

@@ -0,0 +1,219 @@
import { ItemView, WorkspaceLeaf, setIcon } from "obsidian";
import { OPENCODE_VIEW_TYPE } from "./types";
import type OpenCodePlugin from "./main";
import { ProcessState } from "./ProcessManager";
export class OpenCodeView extends ItemView {
plugin: OpenCodePlugin;
private iframeEl: HTMLIFrameElement | null = null;
private currentState: ProcessState = "stopped";
constructor(leaf: WorkspaceLeaf, plugin: OpenCodePlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string {
return OPENCODE_VIEW_TYPE;
}
getDisplayText(): string {
return "OpenCode";
}
getIcon(): string {
return "terminal";
}
async onOpen(): Promise<void> {
this.contentEl.empty();
this.contentEl.addClass("opencode-container");
// Subscribe to state changes
this.plugin.onProcessStateChange((state) => {
this.currentState = state;
this.updateView();
});
// Initial render
this.currentState = this.plugin.getProcessState();
this.updateView();
// Start server if not running (lazy start) - don't await to avoid blocking view open
if (this.currentState === "stopped") {
this.plugin.startServer();
}
}
async onClose(): Promise<void> {
// Clean up iframe
if (this.iframeEl) {
this.iframeEl.src = "about:blank";
this.iframeEl = null;
}
}
private updateView(): void {
switch (this.currentState) {
case "stopped":
this.renderStoppedState();
break;
case "starting":
this.renderStartingState();
break;
case "running":
this.renderRunningState();
break;
case "error":
this.renderErrorState();
break;
}
}
private renderStoppedState(): void {
this.contentEl.empty();
const statusContainer = this.contentEl.createDiv({
cls: "opencode-status-container",
});
const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" });
setIcon(iconEl, "power-off");
statusContainer.createEl("h3", { text: "OpenCode is stopped" });
statusContainer.createEl("p", {
text: "Click the button below to start the OpenCode server.",
cls: "opencode-status-message",
});
const startButton = statusContainer.createEl("button", {
text: "Start OpenCode",
cls: "mod-cta",
});
startButton.addEventListener("click", () => {
this.plugin.startServer();
});
}
private renderStartingState(): void {
this.contentEl.empty();
const statusContainer = this.contentEl.createDiv({
cls: "opencode-status-container",
});
const loadingEl = statusContainer.createDiv({ cls: "opencode-loading" });
loadingEl.createDiv({ cls: "opencode-spinner" });
statusContainer.createEl("h3", { text: "Starting OpenCode..." });
statusContainer.createEl("p", {
text: "Please wait while the server starts up.",
cls: "opencode-status-message",
});
}
private renderRunningState(): void {
this.contentEl.empty();
// Create header with controls
const headerEl = this.contentEl.createDiv({ cls: "opencode-header" });
const titleSection = headerEl.createDiv({ cls: "opencode-header-title" });
const iconEl = titleSection.createSpan();
setIcon(iconEl, "terminal");
titleSection.createSpan({ text: "OpenCode" });
const actionsEl = headerEl.createDiv({ cls: "opencode-header-actions" });
// Reload button
const reloadButton = actionsEl.createEl("button", {
attr: { "aria-label": "Reload" },
});
setIcon(reloadButton, "refresh-cw");
reloadButton.addEventListener("click", () => {
this.reloadIframe();
});
// Open in browser button
const externalButton = actionsEl.createEl("button", {
attr: { "aria-label": "Open in browser" },
});
setIcon(externalButton, "external-link");
externalButton.addEventListener("click", () => {
window.open(this.plugin.getServerUrl(), "_blank");
});
// Stop button
const stopButton = actionsEl.createEl("button", {
attr: { "aria-label": "Stop server" },
});
setIcon(stopButton, "square");
stopButton.addEventListener("click", () => {
this.plugin.stopServer();
});
// Create iframe container
const iframeContainer = this.contentEl.createDiv({
cls: "opencode-iframe-container",
});
this.iframeEl = iframeContainer.createEl("iframe", {
cls: "opencode-iframe",
attr: {
src: this.plugin.getServerUrl(),
frameborder: "0",
allow: "clipboard-read; clipboard-write",
},
});
// Handle iframe load errors
this.iframeEl.addEventListener("error", () => {
console.error("Failed to load OpenCode iframe");
});
}
private renderErrorState(): void {
this.contentEl.empty();
const statusContainer = this.contentEl.createDiv({
cls: "opencode-status-container opencode-error",
});
const iconEl = statusContainer.createDiv({ cls: "opencode-status-icon" });
setIcon(iconEl, "alert-circle");
statusContainer.createEl("h3", { text: "Failed to start OpenCode" });
statusContainer.createEl("p", {
text: "There was an error starting the OpenCode server. Please check that OpenCode is installed and try again.",
cls: "opencode-status-message",
});
const retryButton = statusContainer.createEl("button", {
text: "Retry",
cls: "mod-cta",
});
retryButton.addEventListener("click", () => {
this.plugin.startServer();
});
const settingsButton = statusContainer.createEl("button", {
text: "Open Settings",
});
settingsButton.addEventListener("click", () => {
(this.app as any).setting.open();
(this.app as any).setting.openTabById("obsidian-opencode");
});
}
private reloadIframe(): void {
if (this.iframeEl) {
const src = this.iframeEl.src;
this.iframeEl.src = "about:blank";
setTimeout(() => {
if (this.iframeEl) {
this.iframeEl.src = src;
}
}, 100);
}
}
}

207
src/ProcessManager.ts Normal file
View File

@@ -0,0 +1,207 @@
import { spawn, ChildProcess } from "child_process";
import { Notice } from "obsidian";
import { OpenCodeSettings } from "./types";
export type ProcessState = "stopped" | "starting" | "running" | "error";
export class ProcessManager {
private process: ChildProcess | null = null;
private state: ProcessState = "stopped";
private settings: OpenCodeSettings;
private workingDirectory: string;
private projectDirectory: string;
private onStateChange: (state: ProcessState) => void;
private startupTimeout: NodeJS.Timeout | null = null;
constructor(
settings: OpenCodeSettings,
workingDirectory: string,
projectDirectory: string,
onStateChange: (state: ProcessState) => void
) {
this.settings = settings;
this.workingDirectory = workingDirectory;
this.projectDirectory = projectDirectory;
this.onStateChange = onStateChange;
}
updateSettings(settings: OpenCodeSettings) {
this.settings = settings;
}
getState(): ProcessState {
return this.state;
}
getUrl(): string {
return `http://${this.settings.hostname}:${this.settings.port}`;
}
async start(): Promise<boolean> {
if (this.state === "running" || this.state === "starting") {
return true;
}
this.setState("starting");
try {
// Validate vault/project directory is set
if (!this.projectDirectory) {
const error = "Project directory (vault) not configured";
console.error("[OpenCode Error]", error);
new Notice(`Failed to start OpenCode: ${error}`);
this.setState("error");
return false;
}
// Check if server is already running on this port
const alreadyRunning = await this.checkServerHealth();
if (alreadyRunning) {
console.log("OpenCode server already running on port", this.settings.port);
this.setState("running");
return true;
}
// Start the opencode serve process (headless server, no browser)
// OpenCode is initialized with the vault directory as the project
console.log("[OpenCode] Starting server with vault:", {
vaultDirectory: this.projectDirectory,
workingDirectory: this.workingDirectory,
opencodePath: this.settings.opencodePath,
port: this.settings.port,
hostname: this.settings.hostname,
});
this.process = spawn(
this.settings.opencodePath,
[
"serve",
this.projectDirectory,
"--port",
this.settings.port.toString(),
"--hostname",
this.settings.hostname,
"--cors",
"app://obsidian.md",
],
{
cwd: this.workingDirectory,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
}
);
console.log("[OpenCode] Process spawned with PID:", this.process.pid);
// Handle process output
this.process.stdout?.on("data", (data) => {
console.log("[OpenCode]", data.toString().trim());
});
this.process.stderr?.on("data", (data) => {
console.error("[OpenCode Error]", data.toString().trim());
});
// Handle process exit
this.process.on("exit", (code, signal) => {
console.log(`OpenCode process exited with code ${code}, signal ${signal}`);
this.process = null;
// Only set stopped if we're in running state (not during startup)
if (this.state === "running") {
this.setState("stopped");
}
});
this.process.on("error", (err) => {
console.error("Failed to start OpenCode process:", err);
new Notice(`Failed to start OpenCode: ${err.message}`);
this.process = null;
this.setState("error");
});
// Wait for server to be ready, detecting early process exit
const ready = await this.waitForServerOrExit(15000);
if (ready) {
this.setState("running");
return true;
} else {
this.stop();
this.setState("error");
new Notice("OpenCode server failed to start within timeout");
return false;
}
} catch (error) {
console.error("Error starting OpenCode:", error);
this.setState("error");
return false;
}
}
stop(): void {
if (this.startupTimeout) {
clearTimeout(this.startupTimeout);
this.startupTimeout = null;
}
if (this.process) {
try {
// Try graceful shutdown first
this.process.kill("SIGTERM");
// Force kill after 2 seconds if still running
setTimeout(() => {
if (this.process && !this.process.killed) {
this.process.kill("SIGKILL");
}
}, 2000);
} catch (error) {
console.error("Error stopping OpenCode process:", error);
}
this.process = null;
}
this.setState("stopped");
}
private setState(state: ProcessState): void {
this.state = state;
this.onStateChange(state);
}
private async checkServerHealth(): Promise<boolean> {
try {
const response = await fetch(`${this.getUrl()}/global/health`, {
method: "GET",
signal: AbortSignal.timeout(2000),
});
return response.ok;
} catch {
return false;
}
}
private async waitForServerOrExit(timeoutMs: number): Promise<boolean> {
const startTime = Date.now();
const pollInterval = 500;
while (Date.now() - startTime < timeoutMs) {
// If process exited early, fail fast
if (!this.process) {
console.log("OpenCode process exited before server became ready");
return false;
}
if (await this.checkServerHealth()) {
return true;
}
await this.sleep(pollInterval);
}
return false;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

168
src/SettingsTab.ts Normal file
View File

@@ -0,0 +1,168 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import type OpenCodePlugin from "./main";
export class OpenCodeSettingTab extends PluginSettingTab {
plugin: OpenCodePlugin;
constructor(app: App, plugin: OpenCodePlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "OpenCode Settings" });
// Server settings section
containerEl.createEl("h3", { text: "Server Configuration" });
new Setting(containerEl)
.setName("Port")
.setDesc("Port number for the OpenCode web server")
.addText((text) =>
text
.setPlaceholder("14096")
.setValue(this.plugin.settings.port.toString())
.onChange(async (value) => {
const port = parseInt(value, 10);
if (!isNaN(port) && port > 0 && port < 65536) {
this.plugin.settings.port = port;
await this.plugin.saveSettings();
}
})
);
new Setting(containerEl)
.setName("Hostname")
.setDesc("Hostname to bind the server to (usually 127.0.0.1)")
.addText((text) =>
text
.setPlaceholder("127.0.0.1")
.setValue(this.plugin.settings.hostname)
.onChange(async (value) => {
this.plugin.settings.hostname = value || "127.0.0.1";
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("OpenCode path")
.setDesc(
"Path to the OpenCode executable. Leave as 'opencode' if it's in your PATH."
)
.addText((text) =>
text
.setPlaceholder("opencode")
.setValue(this.plugin.settings.opencodePath)
.onChange(async (value) => {
this.plugin.settings.opencodePath = value || "opencode";
await this.plugin.saveSettings();
})
);
// Behavior settings section
containerEl.createEl("h3", { text: "Behavior" });
new Setting(containerEl)
.setName("Auto-start server")
.setDesc(
"Automatically start the OpenCode server when Obsidian opens (not recommended for faster startup)"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoStart)
.onChange(async (value) => {
this.plugin.settings.autoStart = value;
await this.plugin.saveSettings();
})
);
// Server status section
containerEl.createEl("h3", { text: "Server Status" });
const statusContainer = containerEl.createDiv({ cls: "opencode-settings-status" });
this.renderServerStatus(statusContainer);
}
private renderServerStatus(container: HTMLElement): void {
container.empty();
const state = this.plugin.getProcessState();
const statusText = {
stopped: "Stopped",
starting: "Starting...",
running: "Running",
error: "Error",
};
const statusClass = {
stopped: "status-stopped",
starting: "status-starting",
running: "status-running",
error: "status-error",
};
const statusEl = container.createDiv({ cls: "opencode-status-line" });
statusEl.createSpan({ text: "Status: " });
statusEl.createSpan({
text: statusText[state],
cls: `opencode-status-badge ${statusClass[state]}`,
});
if (state === "running") {
const urlEl = container.createDiv({ cls: "opencode-status-line" });
urlEl.createSpan({ text: "URL: " });
const linkEl = urlEl.createEl("a", {
text: this.plugin.getServerUrl(),
href: this.plugin.getServerUrl(),
});
linkEl.addEventListener("click", (e) => {
e.preventDefault();
window.open(this.plugin.getServerUrl(), "_blank");
});
}
// Control buttons
const buttonContainer = container.createDiv({ cls: "opencode-settings-buttons" });
if (state === "stopped" || state === "error") {
const startButton = buttonContainer.createEl("button", {
text: "Start Server",
cls: "mod-cta",
});
startButton.addEventListener("click", async () => {
await this.plugin.startServer();
this.renderServerStatus(container);
});
}
if (state === "running") {
const stopButton = buttonContainer.createEl("button", {
text: "Stop Server",
});
stopButton.addEventListener("click", () => {
this.plugin.stopServer();
this.renderServerStatus(container);
});
const restartButton = buttonContainer.createEl("button", {
text: "Restart Server",
cls: "mod-warning",
});
restartButton.addEventListener("click", async () => {
this.plugin.stopServer();
await this.plugin.startServer();
this.renderServerStatus(container);
});
}
if (state === "starting") {
buttonContainer.createSpan({
text: "Please wait...",
cls: "opencode-status-waiting",
});
}
}
}

205
src/main.ts Normal file
View File

@@ -0,0 +1,205 @@
import { Plugin, WorkspaceLeaf, Notice } from "obsidian";
import { OpenCodeSettings, DEFAULT_SETTINGS, OPENCODE_VIEW_TYPE } from "./types";
import { OpenCodeView } from "./OpenCodeView";
import { OpenCodeSettingTab } from "./SettingsTab";
import { ProcessManager, ProcessState } from "./ProcessManager";
export default class OpenCodePlugin extends Plugin {
settings: OpenCodeSettings = DEFAULT_SETTINGS;
private processManager: ProcessManager | null = null;
private stateChangeCallbacks: Array<(state: ProcessState) => void> = [];
async onload(): Promise<void> {
console.log("Loading OpenCode plugin");
await this.loadSettings();
// Get the vault directory path to pass to OpenCode
const vaultPath = this.getVaultPath();
// Initialize process manager with vault as the project directory
this.processManager = new ProcessManager(
this.settings,
vaultPath,
vaultPath,
(state) => this.notifyStateChange(state)
);
console.log("[OpenCode] Configured with vault directory:", vaultPath);
// Register the OpenCode view
this.registerView(OPENCODE_VIEW_TYPE, (leaf) => new OpenCodeView(leaf, this));
// Add ribbon icon
this.addRibbonIcon("terminal", "OpenCode", () => {
this.activateView();
});
// Add command to toggle view
this.addCommand({
id: "toggle-opencode-view",
name: "Toggle OpenCode panel",
callback: () => {
this.toggleView();
},
hotkeys: [
{
modifiers: ["Mod", "Shift"],
key: "o",
},
],
});
// Add command to start server
this.addCommand({
id: "start-opencode-server",
name: "Start OpenCode server",
callback: () => {
this.startServer();
},
});
// Add command to stop server
this.addCommand({
id: "stop-opencode-server",
name: "Stop OpenCode server",
callback: () => {
this.stopServer();
},
});
// Register settings tab
this.addSettingTab(new OpenCodeSettingTab(this.app, this));
// Auto-start if enabled
if (this.settings.autoStart) {
this.app.workspace.onLayoutReady(async () => {
await this.startServer();
});
}
console.log("OpenCode plugin loaded");
}
async onunload(): Promise<void> {
console.log("Unloading OpenCode plugin");
// Stop the server
this.stopServer();
// Detach all views
this.app.workspace.detachLeavesOfType(OPENCODE_VIEW_TYPE);
console.log("OpenCode plugin unloaded");
}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
// Update process manager with new settings
if (this.processManager) {
this.processManager.updateSettings(this.settings);
}
}
// Get existing view leaf if any
private getExistingLeaf(): WorkspaceLeaf | null {
const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE);
return leaves.length > 0 ? leaves[0] : null;
}
// Activate or create the view
async activateView(): Promise<void> {
const existingLeaf = this.getExistingLeaf();
if (existingLeaf) {
this.app.workspace.revealLeaf(existingLeaf);
return;
}
// Create new leaf in right sidebar
const leaf = this.app.workspace.getRightLeaf(false);
if (leaf) {
await leaf.setViewState({
type: OPENCODE_VIEW_TYPE,
active: true,
});
this.app.workspace.revealLeaf(leaf);
}
}
// Toggle view visibility
async toggleView(): Promise<void> {
const existingLeaf = this.getExistingLeaf();
if (existingLeaf) {
// Check if visible
const rightSplit = this.app.workspace.rightSplit;
if (rightSplit && !rightSplit.collapsed) {
existingLeaf.detach();
} else {
this.app.workspace.revealLeaf(existingLeaf);
}
} else {
await this.activateView();
}
}
// Start the OpenCode server
async startServer(): Promise<boolean> {
if (!this.processManager) {
new Notice("OpenCode: Process manager not initialized");
return false;
}
const success = await this.processManager.start();
if (success) {
new Notice("OpenCode server started");
}
return success;
}
// Stop the OpenCode server
stopServer(): void {
if (this.processManager) {
this.processManager.stop();
new Notice("OpenCode server stopped");
}
}
// Get the current process state
getProcessState(): ProcessState {
return this.processManager?.getState() ?? "stopped";
}
// Get the server URL
getServerUrl(): string {
return this.processManager?.getUrl() ?? `http://127.0.0.1:${this.settings.port}`;
}
// Subscribe to process state changes
onProcessStateChange(callback: (state: ProcessState) => void): void {
this.stateChangeCallbacks.push(callback);
}
// Notify all subscribers of state change
private notifyStateChange(state: ProcessState): void {
for (const callback of this.stateChangeCallbacks) {
callback(state);
}
}
// Get the vault path - this is the root directory of the Obsidian vault
// which will be passed to OpenCode as the project directory
private getVaultPath(): string {
const adapter = this.app.vault.adapter as any;
const vaultPath = adapter.basePath || "";
if (!vaultPath) {
console.warn("[OpenCode] Warning: Could not determine vault path");
}
return vaultPath;
}
}

15
src/types.ts Normal file
View File

@@ -0,0 +1,15 @@
export interface OpenCodeSettings {
port: number;
hostname: string;
autoStart: boolean;
opencodePath: string;
}
export const DEFAULT_SETTINGS: OpenCodeSettings = {
port: 14096,
hostname: "127.0.0.1",
autoStart: false,
opencodePath: "opencode",
};
export const OPENCODE_VIEW_TYPE = "opencode-view";

193
styles.css Normal file
View File

@@ -0,0 +1,193 @@
/* OpenCode Plugin Styles */
/* Main container */
.opencode-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* Header */
.opencode-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
flex-shrink: 0;
}
.opencode-header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.opencode-header-title svg {
width: 16px;
height: 16px;
}
.opencode-header-actions {
display: flex;
gap: 4px;
}
.opencode-header-actions button {
background: transparent;
border: none;
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
}
.opencode-header-actions button:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
.opencode-header-actions button svg {
width: 16px;
height: 16px;
}
/* iframe container */
.opencode-iframe-container {
flex: 1;
overflow: hidden;
position: relative;
}
.opencode-iframe {
width: 100%;
height: 100%;
border: none;
background-color: var(--background-primary);
}
/* Status container (for stopped/starting/error states) */
.opencode-status-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 24px;
text-align: center;
gap: 16px;
}
.opencode-status-icon {
width: 48px;
height: 48px;
color: var(--text-muted);
}
.opencode-status-icon svg {
width: 100%;
height: 100%;
}
.opencode-status-container h3 {
margin: 0;
font-size: 1.2em;
}
.opencode-status-message {
color: var(--text-muted);
max-width: 300px;
margin: 0;
}
.opencode-status-container button {
margin-top: 8px;
}
.opencode-status-container button + button {
margin-left: 8px;
}
/* Error state */
.opencode-status-container.opencode-error .opencode-status-icon {
color: var(--text-error);
}
/* Loading spinner */
.opencode-loading {
display: flex;
justify-content: center;
align-items: center;
}
.opencode-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--background-modifier-border);
border-top-color: var(--interactive-accent);
border-radius: 50%;
animation: opencode-spin 1s linear infinite;
}
@keyframes opencode-spin {
to {
transform: rotate(360deg);
}
}
/* Settings tab styles */
.opencode-settings-status {
margin-top: 12px;
padding: 12px;
background-color: var(--background-secondary);
border-radius: 8px;
}
.opencode-status-line {
margin-bottom: 8px;
}
.opencode-status-line a {
color: var(--text-accent);
}
.opencode-status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 500;
}
.opencode-status-badge.status-stopped {
background-color: var(--background-modifier-border);
color: var(--text-muted);
}
.opencode-status-badge.status-starting {
background-color: var(--text-warning);
color: var(--background-primary);
}
.opencode-status-badge.status-running {
background-color: var(--text-success);
color: var(--background-primary);
}
.opencode-status-badge.status-error {
background-color: var(--text-error);
color: var(--background-primary);
}
.opencode-settings-buttons {
display: flex;
gap: 8px;
margin-top: 12px;
}
.opencode-status-waiting {
color: var(--text-muted);
font-style: italic;
}

24
tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"src/**/*.ts"
]
}