Context injection
This commit is contained in:
484
main.js
484
main.js
@@ -27,7 +27,7 @@ __export(main_exports, {
|
||||
default: () => OpenCodePlugin
|
||||
});
|
||||
module.exports = __toCommonJS(main_exports);
|
||||
var import_obsidian4 = require("obsidian");
|
||||
var import_obsidian5 = require("obsidian");
|
||||
|
||||
// src/types.ts
|
||||
var DEFAULT_SETTINGS = {
|
||||
@@ -37,7 +37,10 @@ var DEFAULT_SETTINGS = {
|
||||
opencodePath: "opencode",
|
||||
projectDirectory: "",
|
||||
startupTimeout: 15e3,
|
||||
defaultViewLocation: "sidebar"
|
||||
defaultViewLocation: "sidebar",
|
||||
injectWorkspaceContext: true,
|
||||
maxNotesInContext: 20,
|
||||
maxSelectionLength: 2e3
|
||||
};
|
||||
var OPENCODE_VIEW_TYPE = "opencode-view";
|
||||
|
||||
@@ -92,6 +95,10 @@ var OpenCodeView = class extends import_obsidian2.ItemView {
|
||||
this.unsubscribeStateChange = null;
|
||||
}
|
||||
if (this.iframeEl) {
|
||||
const iframeUrl = this.iframeEl.src;
|
||||
if (iframeUrl.includes("/session/")) {
|
||||
this.plugin.setCachedIframeUrl(iframeUrl);
|
||||
}
|
||||
this.iframeEl.src = "about:blank";
|
||||
this.iframeEl = null;
|
||||
}
|
||||
@@ -146,6 +153,7 @@ var OpenCodeView = class extends import_obsidian2.ItemView {
|
||||
});
|
||||
}
|
||||
renderRunningState() {
|
||||
var _a;
|
||||
this.contentEl.empty();
|
||||
const headerEl = this.contentEl.createDiv({ cls: "opencode-header" });
|
||||
const titleSection = headerEl.createDiv({ cls: "opencode-header-title" });
|
||||
@@ -170,11 +178,12 @@ var OpenCodeView = class extends import_obsidian2.ItemView {
|
||||
const iframeContainer = this.contentEl.createDiv({
|
||||
cls: "opencode-iframe-container"
|
||||
});
|
||||
console.log("[OpenCode] Loading iframe with URL:", this.plugin.getServerUrl());
|
||||
const iframeUrl = (_a = this.plugin.getStoredIframeUrl()) != null ? _a : this.plugin.getServerUrl();
|
||||
console.log("[OpenCode] Loading iframe with URL:", iframeUrl);
|
||||
this.iframeEl = iframeContainer.createEl("iframe", {
|
||||
cls: "opencode-iframe",
|
||||
attr: {
|
||||
src: this.plugin.getServerUrl(),
|
||||
src: iframeUrl,
|
||||
frameborder: "0",
|
||||
allow: "clipboard-read; clipboard-write"
|
||||
}
|
||||
@@ -182,6 +191,22 @@ var OpenCodeView = class extends import_obsidian2.ItemView {
|
||||
this.iframeEl.addEventListener("error", () => {
|
||||
console.error("Failed to load OpenCode iframe");
|
||||
});
|
||||
this.iframeEl.addEventListener("focus", () => {
|
||||
this.plugin.refreshContextForView(this);
|
||||
});
|
||||
this.iframeEl.addEventListener("pointerdown", () => {
|
||||
this.plugin.refreshContextForView(this);
|
||||
});
|
||||
void this.plugin.ensureSessionUrl(this);
|
||||
}
|
||||
getIframeUrl() {
|
||||
var _a, _b;
|
||||
return (_b = (_a = this.iframeEl) == null ? void 0 : _a.src) != null ? _b : null;
|
||||
}
|
||||
setIframeUrl(url) {
|
||||
if (this.iframeEl && this.iframeEl.src !== url) {
|
||||
this.iframeEl.src = url;
|
||||
}
|
||||
}
|
||||
renderErrorState() {
|
||||
this.contentEl.empty();
|
||||
@@ -310,6 +335,27 @@ var OpenCodeSettingTab = class extends import_obsidian3.PluginSettingTab {
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
containerEl.createEl("h3", { text: "Workspace Context" });
|
||||
new import_obsidian3.Setting(containerEl).setName("Inject workspace context").setDesc(
|
||||
"Includes open note paths and selected text in OpenCode when the view is focused"
|
||||
).addToggle(
|
||||
(toggle) => toggle.setValue(this.plugin.settings.injectWorkspaceContext).onChange(async (value) => {
|
||||
this.plugin.settings.injectWorkspaceContext = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
new import_obsidian3.Setting(containerEl).setName("Max notes in context").setDesc("Limit how many open notes are included").addSlider(
|
||||
(slider) => slider.setLimits(1, 50, 1).setValue(this.plugin.settings.maxNotesInContext).setDynamicTooltip().onChange(async (value) => {
|
||||
this.plugin.settings.maxNotesInContext = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
new import_obsidian3.Setting(containerEl).setName("Max selection length").setDesc("Truncate selected text to avoid oversized context").addSlider(
|
||||
(slider) => slider.setLimits(500, 5e3, 100).setValue(this.plugin.settings.maxSelectionLength).setDynamicTooltip().onChange(async (value) => {
|
||||
this.plugin.settings.maxSelectionLength = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
containerEl.createEl("h3", { text: "Server Status" });
|
||||
const statusContainer = containerEl.createDiv({ cls: "opencode-settings-status" });
|
||||
this.renderServerStatus(statusContainer);
|
||||
@@ -437,9 +483,8 @@ var ProcessManager = class {
|
||||
return this.lastError;
|
||||
}
|
||||
getUrl() {
|
||||
const baseUrl = `http://${this.settings.hostname}:${this.settings.port}`;
|
||||
const encodedPath = btoa(this.projectDirectory);
|
||||
return `${baseUrl}/${encodedPath}`;
|
||||
return `http://${this.settings.hostname}:${this.settings.port}/${encodedPath}`;
|
||||
}
|
||||
async start() {
|
||||
var _a, _b;
|
||||
@@ -477,7 +522,7 @@ var ProcessManager = class {
|
||||
],
|
||||
{
|
||||
cwd: this.projectDirectory,
|
||||
env: { ...process.env },
|
||||
env: { ...process.env, NODE_USE_SYSTEM_CA: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false
|
||||
}
|
||||
@@ -583,12 +628,260 @@ var ProcessManager = class {
|
||||
}
|
||||
};
|
||||
|
||||
// src/OpenCodeClient.ts
|
||||
var OpenCodeClient = class {
|
||||
constructor(apiBaseUrl, uiBaseUrl, projectDirectory) {
|
||||
this.trackedSessionId = null;
|
||||
this.lastMessageId = null;
|
||||
this.lastPart = null;
|
||||
this.apiBaseUrl = this.normalizeBaseUrl(apiBaseUrl);
|
||||
this.uiBaseUrl = this.normalizeBaseUrl(uiBaseUrl);
|
||||
this.projectDirectory = projectDirectory;
|
||||
}
|
||||
updateBaseUrl(apiBaseUrl, uiBaseUrl, projectDirectory) {
|
||||
const nextApiUrl = this.normalizeBaseUrl(apiBaseUrl);
|
||||
const nextUiUrl = this.normalizeBaseUrl(uiBaseUrl);
|
||||
if (nextApiUrl !== this.apiBaseUrl || nextUiUrl !== this.uiBaseUrl || projectDirectory !== this.projectDirectory) {
|
||||
this.apiBaseUrl = nextApiUrl;
|
||||
this.uiBaseUrl = nextUiUrl;
|
||||
this.projectDirectory = projectDirectory;
|
||||
this.resetTracking();
|
||||
}
|
||||
}
|
||||
resetTracking() {
|
||||
this.trackedSessionId = null;
|
||||
this.lastMessageId = null;
|
||||
this.lastPart = null;
|
||||
}
|
||||
getSessionUrl(sessionId) {
|
||||
return `${this.uiBaseUrl}/session/${sessionId}`;
|
||||
}
|
||||
resolveSessionId(iframeUrl) {
|
||||
var _a;
|
||||
const match = iframeUrl.match(/\/session\/([^/?#]+)/);
|
||||
return (_a = match == null ? void 0 : match[1]) != null ? _a : null;
|
||||
}
|
||||
async createSession() {
|
||||
var _a;
|
||||
const result = await this.request("POST", "/session", {
|
||||
title: "Obsidian"
|
||||
});
|
||||
const session = this.unwrap(result);
|
||||
return (_a = session == null ? void 0 : session.id) != null ? _a : null;
|
||||
}
|
||||
async updateContext(params) {
|
||||
var _a, _b, _c;
|
||||
const { sessionId, contextText } = params;
|
||||
if (this.trackedSessionId && this.trackedSessionId !== sessionId) {
|
||||
this.resetTracking();
|
||||
}
|
||||
this.trackedSessionId = sessionId;
|
||||
if (!contextText) {
|
||||
await this.ignorePreviousPart();
|
||||
return;
|
||||
}
|
||||
if (this.lastPart) {
|
||||
const updated = await this.updatePart(this.lastPart, { text: contextText });
|
||||
if (updated) {
|
||||
return;
|
||||
}
|
||||
await this.ignorePreviousPart();
|
||||
}
|
||||
const message = await this.sendPrompt(sessionId, contextText);
|
||||
if ((_a = message == null ? void 0 : message.info) == null ? void 0 : _a.id) {
|
||||
this.lastMessageId = message.info.id;
|
||||
this.lastPart = (_c = (_b = message.parts) == null ? void 0 : _b[0]) != null ? _c : null;
|
||||
}
|
||||
}
|
||||
async sendPrompt(sessionId, contextText) {
|
||||
const result = await this.request(
|
||||
"POST",
|
||||
`/session/${sessionId}/message`,
|
||||
{
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: contextText }]
|
||||
}
|
||||
);
|
||||
console.log("[OpenCode] Injected context message");
|
||||
console.log(contextText);
|
||||
const message = this.unwrap(result);
|
||||
if (!message) {
|
||||
console.error("[OpenCode] Failed to inject context message");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
async updatePart(part, updates) {
|
||||
const result = await this.request(
|
||||
"PATCH",
|
||||
`/session/${part.sessionID}/message/${part.messageID}/part/${part.id}`,
|
||||
{
|
||||
...part,
|
||||
...updates
|
||||
}
|
||||
);
|
||||
const updated = this.unwrap(result);
|
||||
if (updated) {
|
||||
this.lastPart = updated;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async ignorePreviousPart() {
|
||||
if (!this.lastPart) {
|
||||
return false;
|
||||
}
|
||||
const ignored = await this.updatePart(this.lastPart, { ignored: true });
|
||||
if (!ignored) {
|
||||
return false;
|
||||
}
|
||||
this.lastMessageId = null;
|
||||
this.lastPart = null;
|
||||
return true;
|
||||
}
|
||||
async request(method, path, body) {
|
||||
try {
|
||||
const url = `${this.apiBaseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-opencode-directory": this.projectDirectory
|
||||
},
|
||||
body: body ? JSON.stringify(body) : void 0
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error("[OpenCode] API request failed", {
|
||||
path,
|
||||
status: response.status
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const json = await response.json().catch(() => null);
|
||||
return json;
|
||||
} catch (error) {
|
||||
console.error("[OpenCode] API request error", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
unwrap(result) {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
if (typeof result === "object") {
|
||||
const payload = result;
|
||||
if (payload.data) {
|
||||
return payload.data;
|
||||
}
|
||||
if (payload.message) {
|
||||
return payload.message;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
normalizeBaseUrl(baseUrl) {
|
||||
return baseUrl.replace(/\/+$/, "");
|
||||
}
|
||||
};
|
||||
|
||||
// src/WorkspaceContext.ts
|
||||
var import_obsidian4 = require("obsidian");
|
||||
var WorkspaceContext = class {
|
||||
constructor(app) {
|
||||
this.lastSelection = null;
|
||||
this.lastMarkdownView = null;
|
||||
this.app = app;
|
||||
}
|
||||
getOpenNotePaths(maxNotes) {
|
||||
var _a;
|
||||
const leaves = this.app.workspace.getLeavesOfType("markdown");
|
||||
const paths = /* @__PURE__ */ new Set();
|
||||
for (const leaf of leaves) {
|
||||
const view = leaf.view;
|
||||
const path = (_a = view.file) == null ? void 0 : _a.path;
|
||||
if (path) {
|
||||
paths.add(path);
|
||||
}
|
||||
}
|
||||
return Array.from(paths).slice(0, Math.max(0, maxNotes));
|
||||
}
|
||||
updateSelectionFromView(view) {
|
||||
var _a, _b, _c;
|
||||
if (view) {
|
||||
this.lastMarkdownView = view;
|
||||
}
|
||||
const sourcePath = (_a = view == null ? void 0 : view.file) == null ? void 0 : _a.path;
|
||||
const selection = (_c = (_b = view == null ? void 0 : view.editor) == null ? void 0 : _b.getSelection()) != null ? _c : "";
|
||||
if (!sourcePath || !selection.trim()) {
|
||||
this.lastSelection = null;
|
||||
return;
|
||||
}
|
||||
this.lastSelection = {
|
||||
text: selection,
|
||||
sourcePath
|
||||
};
|
||||
}
|
||||
getSelectedText(maxSelectionLength) {
|
||||
var _a, _b, _c, _d;
|
||||
const view = (_a = this.app.workspace.getActiveViewOfType(import_obsidian4.MarkdownView)) != null ? _a : this.lastMarkdownView;
|
||||
const sourcePath = (_b = view == null ? void 0 : view.file) == null ? void 0 : _b.path;
|
||||
const selection = (_d = (_c = view == null ? void 0 : view.editor) == null ? void 0 : _c.getSelection()) != null ? _d : "";
|
||||
let text = "";
|
||||
let path = "";
|
||||
if (sourcePath && selection.trim()) {
|
||||
text = selection;
|
||||
path = sourcePath;
|
||||
this.lastSelection = { text, sourcePath: path };
|
||||
} else if (this.lastSelection) {
|
||||
text = this.lastSelection.text;
|
||||
path = this.lastSelection.sourcePath;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
const truncated = text.length > maxSelectionLength;
|
||||
const trimmed = truncated ? text.slice(0, maxSelectionLength) : text;
|
||||
return {
|
||||
text: trimmed,
|
||||
sourcePath: path,
|
||||
truncated
|
||||
};
|
||||
}
|
||||
formatContext(openPaths, selection) {
|
||||
if (openPaths.length === 0 && !selection) {
|
||||
return null;
|
||||
}
|
||||
const lines = ["<system-reminder>"];
|
||||
if (openPaths.length > 0) {
|
||||
lines.push("Currently open notes in Obsidian:");
|
||||
for (const path of openPaths) {
|
||||
lines.push(`- ${path}`);
|
||||
}
|
||||
}
|
||||
if (selection) {
|
||||
lines.push("");
|
||||
lines.push(`Selected text (from ${selection.sourcePath}):`);
|
||||
lines.push('"""');
|
||||
lines.push(selection.text);
|
||||
if (selection.truncated) {
|
||||
lines.push("[truncated]");
|
||||
}
|
||||
lines.push('"""');
|
||||
}
|
||||
lines.push("</system-reminder>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
};
|
||||
|
||||
// src/main.ts
|
||||
var OpenCodePlugin = class extends import_obsidian4.Plugin {
|
||||
var OpenCodePlugin = class extends import_obsidian5.Plugin {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.settings = DEFAULT_SETTINGS;
|
||||
this.stateChangeCallbacks = [];
|
||||
this.cachedIframeUrl = null;
|
||||
this.lastBaseUrl = null;
|
||||
this.focusEventRef = null;
|
||||
this.sidebarEventRefs = [];
|
||||
this.sidebarRefreshTimer = null;
|
||||
}
|
||||
async onload() {
|
||||
console.log("Loading OpenCode plugin");
|
||||
@@ -600,6 +893,9 @@ var OpenCodePlugin = class extends import_obsidian4.Plugin {
|
||||
projectDirectory,
|
||||
(state) => this.notifyStateChange(state)
|
||||
);
|
||||
this.openCodeClient = new OpenCodeClient(this.getApiBaseUrl(), this.getServerUrl(), projectDirectory);
|
||||
this.workspaceContext = new WorkspaceContext(this.app);
|
||||
this.lastBaseUrl = this.getServerUrl();
|
||||
console.log("[OpenCode] Configured with project directory:", projectDirectory);
|
||||
this.registerView(OPENCODE_VIEW_TYPE, (leaf) => new OpenCodeView(leaf, this));
|
||||
this.addSettingTab(new OpenCodeSettingTab(this.app, this));
|
||||
@@ -638,6 +934,13 @@ var OpenCodePlugin = class extends import_obsidian4.Plugin {
|
||||
await this.startServer();
|
||||
});
|
||||
}
|
||||
this.updateFocusListener();
|
||||
this.updateSidebarListeners();
|
||||
this.onProcessStateChange((state) => {
|
||||
if (state === "running") {
|
||||
void this.handleServerRunning();
|
||||
}
|
||||
});
|
||||
console.log("OpenCode plugin loaded");
|
||||
}
|
||||
async onunload() {
|
||||
@@ -650,23 +953,25 @@ var OpenCodePlugin = class extends import_obsidian4.Plugin {
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
this.processManager.updateSettings(this.settings);
|
||||
this.refreshClientState();
|
||||
this.updateFocusListener();
|
||||
this.updateSidebarListeners();
|
||||
}
|
||||
// Update project directory and restart server if running
|
||||
async updateProjectDirectory(directory) {
|
||||
this.settings.projectDirectory = directory;
|
||||
await this.saveData(this.settings);
|
||||
this.processManager.updateProjectDirectory(this.getProjectDirectory());
|
||||
this.refreshClientState();
|
||||
if (this.getProcessState() === "running") {
|
||||
this.stopServer();
|
||||
await this.startServer();
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
@@ -687,7 +992,6 @@ var OpenCodePlugin = class extends import_obsidian4.Plugin {
|
||||
this.app.workspace.revealLeaf(leaf);
|
||||
}
|
||||
}
|
||||
// Toggle view visibility
|
||||
async toggleView() {
|
||||
const existingLeaf = this.getExistingLeaf();
|
||||
if (existingLeaf) {
|
||||
@@ -709,13 +1013,13 @@ var OpenCodePlugin = class extends import_obsidian4.Plugin {
|
||||
async startServer() {
|
||||
const success = await this.processManager.start();
|
||||
if (success) {
|
||||
new import_obsidian4.Notice("OpenCode server started");
|
||||
new import_obsidian5.Notice("OpenCode server started");
|
||||
}
|
||||
return success;
|
||||
}
|
||||
stopServer() {
|
||||
this.processManager.stop();
|
||||
new import_obsidian4.Notice("OpenCode server stopped");
|
||||
new import_obsidian5.Notice("OpenCode server stopped");
|
||||
}
|
||||
getProcessState() {
|
||||
var _a, _b;
|
||||
@@ -728,6 +1032,42 @@ var OpenCodePlugin = class extends import_obsidian4.Plugin {
|
||||
getServerUrl() {
|
||||
return this.processManager.getUrl();
|
||||
}
|
||||
getApiBaseUrl() {
|
||||
return `http://${this.settings.hostname}:${this.settings.port}`;
|
||||
}
|
||||
getStoredIframeUrl() {
|
||||
return this.cachedIframeUrl;
|
||||
}
|
||||
setCachedIframeUrl(url) {
|
||||
this.cachedIframeUrl = url;
|
||||
}
|
||||
async ensureSessionUrl(view) {
|
||||
var _a;
|
||||
if (this.getProcessState() !== "running") {
|
||||
return;
|
||||
}
|
||||
const existingUrl = (_a = this.cachedIframeUrl) != null ? _a : view.getIframeUrl();
|
||||
if (existingUrl && this.openCodeClient.resolveSessionId(existingUrl)) {
|
||||
this.cachedIframeUrl = existingUrl;
|
||||
return;
|
||||
}
|
||||
const sessionId = await this.openCodeClient.createSession();
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
const sessionUrl = this.openCodeClient.getSessionUrl(sessionId);
|
||||
this.cachedIframeUrl = sessionUrl;
|
||||
view.setIframeUrl(sessionUrl);
|
||||
if (this.app.workspace.activeLeaf === view.leaf) {
|
||||
await this.updateOpenCodeContext(view.leaf);
|
||||
}
|
||||
}
|
||||
refreshContextForView(view) {
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
return;
|
||||
}
|
||||
void this.updateOpenCodeContext(view.leaf);
|
||||
}
|
||||
onProcessStateChange(callback) {
|
||||
this.stateChangeCallbacks.push(callback);
|
||||
return () => {
|
||||
@@ -742,6 +1082,124 @@ var OpenCodePlugin = class extends import_obsidian4.Plugin {
|
||||
callback(state);
|
||||
}
|
||||
}
|
||||
refreshClientState() {
|
||||
const nextUiBaseUrl = this.getServerUrl();
|
||||
const nextApiBaseUrl = this.getApiBaseUrl();
|
||||
const projectDirectory = this.getProjectDirectory();
|
||||
this.openCodeClient.updateBaseUrl(nextApiBaseUrl, nextUiBaseUrl, projectDirectory);
|
||||
if (this.lastBaseUrl && this.lastBaseUrl !== nextUiBaseUrl) {
|
||||
this.cachedIframeUrl = null;
|
||||
}
|
||||
this.lastBaseUrl = nextUiBaseUrl;
|
||||
}
|
||||
updateFocusListener() {
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
if (this.focusEventRef) {
|
||||
this.app.workspace.offref(this.focusEventRef);
|
||||
this.focusEventRef = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.focusEventRef) {
|
||||
return;
|
||||
}
|
||||
const eventRef = this.app.workspace.on("active-leaf-change", (leaf) => {
|
||||
if ((leaf == null ? void 0 : leaf.view) instanceof import_obsidian5.MarkdownView) {
|
||||
this.workspaceContext.updateSelectionFromView(leaf.view);
|
||||
}
|
||||
if ((leaf == null ? void 0 : leaf.view.getViewType()) === OPENCODE_VIEW_TYPE) {
|
||||
void this.updateOpenCodeContext(leaf);
|
||||
}
|
||||
});
|
||||
this.focusEventRef = eventRef;
|
||||
this.registerEvent(eventRef);
|
||||
}
|
||||
updateSidebarListeners() {
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
this.clearSidebarListeners();
|
||||
return;
|
||||
}
|
||||
if (this.sidebarEventRefs.length > 0) {
|
||||
return;
|
||||
}
|
||||
const fileOpenRef = this.app.workspace.on("file-open", () => {
|
||||
this.scheduleSidebarContextRefresh();
|
||||
});
|
||||
const editorChangeRef = this.app.workspace.on("editor-change", (_editor, view) => {
|
||||
const markdownView = view instanceof import_obsidian5.MarkdownView ? view : this.app.workspace.getActiveViewOfType(import_obsidian5.MarkdownView);
|
||||
this.workspaceContext.updateSelectionFromView(markdownView);
|
||||
this.scheduleSidebarContextRefresh();
|
||||
});
|
||||
this.sidebarEventRefs = [fileOpenRef, editorChangeRef];
|
||||
this.sidebarEventRefs.forEach((ref) => this.registerEvent(ref));
|
||||
}
|
||||
clearSidebarListeners() {
|
||||
for (const ref of this.sidebarEventRefs) {
|
||||
this.app.workspace.offref(ref);
|
||||
}
|
||||
this.sidebarEventRefs = [];
|
||||
if (this.sidebarRefreshTimer !== null) {
|
||||
window.clearTimeout(this.sidebarRefreshTimer);
|
||||
this.sidebarRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
scheduleSidebarContextRefresh() {
|
||||
const leaf = this.getVisibleSidebarOpenCodeLeaf();
|
||||
if (!leaf) {
|
||||
return;
|
||||
}
|
||||
if (this.sidebarRefreshTimer !== null) {
|
||||
window.clearTimeout(this.sidebarRefreshTimer);
|
||||
}
|
||||
this.sidebarRefreshTimer = window.setTimeout(() => {
|
||||
this.sidebarRefreshTimer = null;
|
||||
void this.updateOpenCodeContext(leaf);
|
||||
}, 1e3);
|
||||
}
|
||||
getVisibleSidebarOpenCodeLeaf() {
|
||||
const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE);
|
||||
if (leaves.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const rightSplit = this.app.workspace.rightSplit;
|
||||
if (!rightSplit || rightSplit.collapsed) {
|
||||
return null;
|
||||
}
|
||||
const leaf = leaves[0];
|
||||
return leaf.getRoot() === rightSplit ? leaf : null;
|
||||
}
|
||||
async handleServerRunning() {
|
||||
const activeLeaf = this.app.workspace.activeLeaf;
|
||||
if ((activeLeaf == null ? void 0 : activeLeaf.view.getViewType()) === OPENCODE_VIEW_TYPE) {
|
||||
await this.updateOpenCodeContext(activeLeaf);
|
||||
}
|
||||
}
|
||||
async updateOpenCodeContext(leaf) {
|
||||
var _a;
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
return;
|
||||
}
|
||||
if (this.getProcessState() !== "running") {
|
||||
return;
|
||||
}
|
||||
const view = leaf.view instanceof OpenCodeView ? leaf.view : null;
|
||||
const iframeUrl = (_a = this.cachedIframeUrl) != null ? _a : view == null ? void 0 : view.getIframeUrl();
|
||||
if (!iframeUrl) {
|
||||
return;
|
||||
}
|
||||
const sessionId = this.openCodeClient.resolveSessionId(iframeUrl);
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
this.cachedIframeUrl = iframeUrl;
|
||||
const openPaths = this.workspaceContext.getOpenNotePaths(this.settings.maxNotesInContext);
|
||||
const selection = this.workspaceContext.getSelectedText(this.settings.maxSelectionLength);
|
||||
const contextText = this.workspaceContext.formatContext(openPaths, selection);
|
||||
await this.openCodeClient.updateContext({
|
||||
sessionId,
|
||||
contextText
|
||||
});
|
||||
}
|
||||
getProjectDirectory() {
|
||||
if (this.settings.projectDirectory) {
|
||||
console.log("[OpenCode] Using project directory from settings:", this.settings.projectDirectory);
|
||||
|
||||
165
openspec/changes/add-workspace-context-injection/design.md
Normal file
165
openspec/changes/add-workspace-context-injection/design.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Design: Workspace Context Injection
|
||||
|
||||
## Context
|
||||
|
||||
The plugin embeds OpenCode in an iframe and spawns a local server. OpenCode exposes an HTTP API that allows programmatic interaction with sessions. Obsidian's Workspace API provides access to open files, emits events when the workspace changes, and provides access to the active editor's selection.
|
||||
|
||||
**Stakeholders:** Users who want AI to be aware of their open notes and selected text without manual input.
|
||||
|
||||
**Constraints:**
|
||||
- Must not overload the context window with repeated injections
|
||||
- Must work with the existing ProcessManager and view lifecycle
|
||||
- Desktop-only (uses Node.js APIs)
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Automatically provide OpenCode with awareness of open notes
|
||||
- Include currently selected text for immediate context
|
||||
- Keep context window clean (no accumulation of stale context)
|
||||
- Minimal performance impact (focus-based updates)
|
||||
- User control via settings (enabled by default)
|
||||
|
||||
**Non-Goals:**
|
||||
- Injecting full file contents (only paths + selection)
|
||||
- Real-time synchronization with every keystroke
|
||||
- Mobile support
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: Use direct HTTP calls for API communication
|
||||
**What:** Use direct `fetch()` calls to the local OpenCode server for session management and message/part updates.
|
||||
|
||||
**Why:** The plugin needs a few specific endpoints (create session, prompt with `noReply`, update/ignore parts). Using `fetch()` avoids adding SDK bundle size and keeps implementation explicit.
|
||||
|
||||
**Alternatives considered:**
|
||||
- `@opencode-ai/sdk`: Type-safe, but adds dependency/bundle size and still requires careful session targeting.
|
||||
- postMessage to iframe: Not supported by OpenCode web UI.
|
||||
|
||||
### Decision 2: Replace context via update/ignore (never revert)
|
||||
**What:** Track the injected context part ID. On updates, prefer updating that part in-place. If in-place update is not available, mark the previous part as `ignored: true` and create a new context injection.
|
||||
|
||||
**Why:** In OpenCode, `session.revert()` implements user-visible undo semantics and can delete messages after the revert point during cleanup, which is unsafe for automatic context refresh.
|
||||
|
||||
**Alternatives considered:**
|
||||
- Revert + re-inject: Rejected (destructive semantics).
|
||||
- Append only: Would accumulate redundant messages.
|
||||
- One-time injection: Context becomes stale.
|
||||
- System prompt field: Not specified as replace-only.
|
||||
|
||||
### Decision 3: Inject paths + selected text, not full content
|
||||
**What:** Send file paths (e.g., `Notes/Project.md`) and the currently selected text (if any), but not full file contents.
|
||||
|
||||
**Why:**
|
||||
- Keeps context concise and within token limits
|
||||
- Users control what's "in scope" by opening/closing files
|
||||
- Selected text provides immediate, relevant context without overwhelming
|
||||
- Full content injection could easily exceed context limits
|
||||
|
||||
### Decision 4: Track context for the active iframe session
|
||||
**What:** Maintain a single tracked session and context reference (session ID + injected context part reference) based on the current iframe URL.
|
||||
|
||||
**Why:** This plugin assumes only one OpenCode tab exists at a time. The injected context must follow the session the user is actively viewing in the embedded UI, which is determined by the iframe URL at injection time.
|
||||
|
||||
### Decision 5: Include selection source file
|
||||
**What:** When including selected text, also indicate which file it's from.
|
||||
|
||||
**Why:** Helps the AI understand the context of the selection (e.g., "Selected from Daily/2026-01-12.md").
|
||||
|
||||
### Decision 6: Inject context on OpenCode view focus
|
||||
**What:** Refresh context when the OpenCode view becomes the active leaf.
|
||||
|
||||
**Why:** Keeps injections tied to user intent (they are about to use OpenCode), avoids constant background updates, and reduces risk of flooding the server.
|
||||
|
||||
**Trade-off:** Context can be briefly stale if the workspace changes while the OpenCode view remains active. This is accepted for simplicity.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Obsidian Plugin │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ │
|
||||
│ │ WorkspaceContext│ │ OpenCodeClient │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ - getOpenPaths()│ │ - updateContext()│ │
|
||||
│ │ - getSelection()│ │ - update/ignore │ │
|
||||
│ │ - formatContext │ │ │ │
|
||||
│ └────────┬────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ └──────────┬───────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ main.ts │ │
|
||||
│ │ │ │
|
||||
│ │ - view focus events │ │
|
||||
│ │ - context refresh │ │
|
||||
│ └──────────┬──────────┘ │
|
||||
│ │ │
|
||||
└──────────────────────┼──────────────────────────────────────┘
|
||||
│ HTTP
|
||||
┌────────▼────────┐
|
||||
│ OpenCode Server │
|
||||
│ │
|
||||
│ - session.create│
|
||||
│ - session.prompt│
|
||||
│ - part.update │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User focuses the OpenCode view (active leaf)
|
||||
2. `WorkspaceContext` collects:
|
||||
a. `getOpenNotePaths()` - current open files
|
||||
b. `getSelectedText()` - current selection (if any)
|
||||
3. `OpenCodeClient.updateContext()`:
|
||||
a. Determines the current session ID by parsing the iframe URL (`.../session/<sessionID>`)
|
||||
b. If no session ID is available (iframe not on a session route), do nothing
|
||||
c. Updates or ignores the previously injected context part (if any)
|
||||
d. Injects fresh context with `noReply: true`
|
||||
e. Stores injected message/part IDs for future updates
|
||||
4. OpenCode AI now has updated context for next interaction
|
||||
|
||||
## Context Format
|
||||
|
||||
```
|
||||
<system-reminder>
|
||||
Currently open notes in Obsidian:
|
||||
- Daily/2026-01-12.md
|
||||
- Projects/Feature-Spec.md
|
||||
- Reference/API-Docs.md
|
||||
|
||||
Selected text (from Projects/Feature-Spec.md):
|
||||
"""
|
||||
The plugin SHALL inject workspace context into OpenCode sessions.
|
||||
This includes both open file paths and selected text.
|
||||
"""
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
When no text is selected, the "Selected text" section is omitted.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Session changes between updates | Parse iframe URL at injection time; update tracked session and context reference |
|
||||
| Iframe not on a session route | No-op (do not inject) |
|
||||
| Server not running when focus fires | Check `getProcessState() === "running"` before attempting |
|
||||
| Context replacement is destructive | Avoid `session.revert()`; update or mark the previous part as `ignored` |
|
||||
| Tracking lost on plugin reload | Acceptable - next focus injects fresh context, old message becomes stale but harmless |
|
||||
| Large selection could bloat context | Truncate selection to reasonable limit (e.g., 2000 chars) |
|
||||
| Workspace changes while OpenCode is active | Accepted staleness until the next focus-based refresh |
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No migration needed. New feature enabled by default but can be disabled in settings.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. ~~Should we also inject the active file distinctly (e.g., "Currently editing: X.md")?~~
|
||||
- Resolved: The selected text section includes the source file, which serves this purpose.
|
||||
|
||||
2. Should context be injected on view open or only on focus?
|
||||
- Decision: Inject on focus; view open triggers focus in typical usage, but the event is the single source of refresh.
|
||||
28
openspec/changes/add-workspace-context-injection/proposal.md
Normal file
28
openspec/changes/add-workspace-context-injection/proposal.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Change: Add Workspace Context Injection
|
||||
|
||||
## Why
|
||||
|
||||
Users working in Obsidian have multiple notes open that provide context for their AI interactions. Currently, OpenCode has no awareness of which notes are open in Obsidian or what text the user has selected, requiring users to manually reference files and copy/paste selections. By automatically injecting the list of open notes and the currently selected text into OpenCode sessions, the AI gains valuable context about the user's current focus, improving response relevance without manual effort.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Collect currently open note paths from Obsidian's workspace API
|
||||
- Collect currently selected text from the active editor (if any)
|
||||
- Create a new OpenCode session when the OpenCode view is first opened (per Obsidian run)
|
||||
- Preserve and restore the last OpenCode iframe URL when the view is closed/reopened (until Obsidian is restarted)
|
||||
- Resolve the target session by parsing the `sessionID` from the current iframe URL when context is about to be injected
|
||||
- Inject open notes and selected text as context into the current OpenCode session using `session.prompt({ noReply: true })`
|
||||
- Replace previous injected context without using `session.revert()` (update/ignore the previous context part instead)
|
||||
- Inject context when the OpenCode view becomes active (focus-based refresh); accept brief staleness if workspace changes while the view remains active
|
||||
- Add settings to enable/disable the feature (enabled by default) and limit the number of notes included
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `001-mvp-opencode-embed`
|
||||
- Affected code:
|
||||
- `src/types.ts` - new settings fields
|
||||
- `src/main.ts` - session tracking, URL persistence, context injection orchestration
|
||||
- `src/OpenCodeView.ts` - use cached URL when available
|
||||
- `src/SettingsTab.ts` - new settings UI
|
||||
- `src/OpenCodeClient.ts` - **new file** for OpenCode HTTP client wrapper
|
||||
- `src/WorkspaceContext.ts` - **new file** for workspace data collection (open notes + selection)
|
||||
@@ -0,0 +1,157 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Workspace Context Collection
|
||||
The plugin SHALL collect the file paths of all currently open markdown notes in the Obsidian workspace.
|
||||
|
||||
#### Scenario: Collect open note paths
|
||||
- **WHEN** the plugin needs to gather workspace context
|
||||
- **THEN** it retrieves all leaves of type "markdown" from the workspace
|
||||
- **AND** extracts the file path from each leaf's view
|
||||
- **AND** deduplicates paths (same file may be open in multiple panes)
|
||||
|
||||
### Requirement: Selected Text Collection
|
||||
The plugin SHALL collect the currently selected text from the active editor, if any selection exists.
|
||||
|
||||
#### Scenario: Collect selected text with source
|
||||
- **WHEN** the plugin needs to gather workspace context
|
||||
- **AND** text is selected in the active editor
|
||||
- **THEN** it retrieves the selected text content
|
||||
- **AND** identifies the source file path of the selection
|
||||
|
||||
#### Scenario: No selection present
|
||||
- **WHEN** the plugin needs to gather workspace context
|
||||
- **AND** no text is selected in the active editor
|
||||
- **THEN** the selected text portion of the context is omitted
|
||||
|
||||
#### Scenario: Selection truncation
|
||||
- **WHEN** the selected text exceeds `maxSelectionLength` characters
|
||||
- **THEN** the selection is truncated to the configured limit
|
||||
- **AND** an indicator is added showing truncation occurred
|
||||
|
||||
### Requirement: Context Injection to OpenCode
|
||||
The plugin SHALL inject the workspace context (open notes and selected text) into the OpenCode session currently displayed in the embedded iframe.
|
||||
|
||||
#### Scenario: Inject context on OpenCode focus
|
||||
- **WHEN** the OpenCode view becomes the active leaf
|
||||
- **AND** the setting "Inject workspace context" is enabled
|
||||
- **AND** the OpenCode server is running
|
||||
- **AND** a `sessionID` can be resolved from the current iframe URL
|
||||
- **THEN** the plugin sends the workspace context to that session
|
||||
- **AND** the context is injected using `session.prompt({ noReply: true })` (no AI response triggered)
|
||||
|
||||
#### Scenario: Initial context injection
|
||||
- **WHEN** the OpenCode server is running
|
||||
- **AND** the OpenCode view becomes the active leaf
|
||||
- **AND** a tracked session exists (created on first open)
|
||||
- **THEN** the plugin injects the current workspace context
|
||||
|
||||
### Requirement: Context Replacement (Non-Destructive)
|
||||
The plugin SHALL replace previous context injections rather than accumulating them.
|
||||
|
||||
#### Scenario: Update previous context part
|
||||
- **WHEN** the plugin injects new context
|
||||
- **AND** a previous context part exists for the session
|
||||
- **THEN** the plugin updates the previous context part in-place to match the new context
|
||||
- **AND** no new context message is added to the session history
|
||||
|
||||
#### Scenario: Ignore previous context part and re-inject
|
||||
- **WHEN** in-place update is not available
|
||||
- **AND** a previous context part exists for the session
|
||||
- **THEN** the plugin marks the previous context part as `ignored: true`
|
||||
- **AND** injects new context with `noReply: true`
|
||||
- **AND** stores the new message/part IDs for future updates
|
||||
|
||||
#### Scenario: Replacement failure handling
|
||||
- **WHEN** a previous context part cannot be updated or ignored (already removed, invalid IDs)
|
||||
- **THEN** the plugin continues with fresh context injection
|
||||
- **AND** logs the error to console for debugging
|
||||
|
||||
#### Scenario: Never revert
|
||||
- **WHEN** updating context
|
||||
- **THEN** the plugin MUST NOT call `session.revert()` as part of this feature
|
||||
|
||||
### Requirement: Focus-based Context Updates
|
||||
The plugin SHALL refresh workspace context when the OpenCode view becomes the active leaf.
|
||||
|
||||
### Requirement: Session Tracking and URL Persistence
|
||||
The plugin SHALL create and track an OpenCode session and preserve the iframe URL for the duration of the Obsidian process.
|
||||
|
||||
#### Scenario: Create session on first view open
|
||||
- **WHEN** the OpenCode view is opened for the first time in the current Obsidian run
|
||||
- **AND** the OpenCode server is running
|
||||
- **THEN** the plugin creates a new OpenCode session
|
||||
- **AND** updates the iframe URL to the session route
|
||||
- **AND** stores that URL for later restores
|
||||
|
||||
#### Scenario: Restore last URL on reopen
|
||||
- **WHEN** the OpenCode view is closed and reopened
|
||||
- **AND** a previous iframe URL was stored in memory
|
||||
- **THEN** the iframe is loaded with the stored URL
|
||||
|
||||
#### Scenario: Adopt user-changed session
|
||||
- **WHEN** the user navigates to a different session within the iframe UI
|
||||
- **AND** the plugin is about to inject context
|
||||
- **THEN** the plugin reads the iframe `src` URL
|
||||
- **AND** resolves the session ID from that URL
|
||||
- **AND** updates the tracked session and stored URL to match
|
||||
|
||||
#### Scenario: No session route
|
||||
- **WHEN** the iframe URL does not contain a session route
|
||||
- **AND** the plugin is about to inject context
|
||||
- **THEN** the plugin does not inject any context
|
||||
|
||||
#### Scenario: Invalidate stored URL when base changes
|
||||
- **WHEN** hostname, port, or project directory changes
|
||||
- **THEN** the plugin clears the stored URL and tracked session
|
||||
- **AND** a new session is created on the next first open
|
||||
|
||||
#### Scenario: Workspace changes while OpenCode stays active
|
||||
- **WHEN** the user changes open notes or selection while the OpenCode view remains active
|
||||
- **THEN** the context MAY be briefly stale until the user focuses OpenCode again
|
||||
- **AND** this staleness is an accepted trade-off for simplicity
|
||||
|
||||
### Requirement: Context Injection Settings
|
||||
The plugin SHALL provide settings to control workspace context injection behavior.
|
||||
|
||||
#### Scenario: Enable/disable toggle
|
||||
- **WHEN** the user disables "Inject workspace context"
|
||||
- **THEN** the plugin does not register focus-based event listeners
|
||||
- **AND** no context is injected into OpenCode sessions
|
||||
|
||||
#### Scenario: Enabled by default
|
||||
- **WHEN** the plugin is installed fresh
|
||||
- **THEN** the "Inject workspace context" setting defaults to enabled
|
||||
|
||||
#### Scenario: Limit number of notes
|
||||
- **WHEN** more than `maxNotesInContext` notes are open
|
||||
- **THEN** the plugin includes only the first N paths
|
||||
- **AND** the default limit is 20 notes
|
||||
|
||||
#### Scenario: Limit selection length
|
||||
- **WHEN** the selected text exceeds `maxSelectionLength` characters
|
||||
- **THEN** the plugin truncates the selection
|
||||
- **AND** the default limit is 2000 characters
|
||||
|
||||
### Requirement: Context Format
|
||||
The plugin SHALL format the context as a system reminder containing file paths and optional selected text.
|
||||
|
||||
#### Scenario: Context message format with selection
|
||||
- **WHEN** context is injected
|
||||
- **AND** text is selected
|
||||
- **THEN** the message is wrapped in `<system-reminder>` tags
|
||||
- **AND** includes a header "Currently open notes in Obsidian:"
|
||||
- **AND** lists each file path as a bullet point
|
||||
- **AND** includes a "Selected text (from <filepath>):" section
|
||||
- **AND** wraps the selected text in triple quotes
|
||||
|
||||
#### Scenario: Context message format without selection
|
||||
- **WHEN** context is injected
|
||||
- **AND** no text is selected
|
||||
- **THEN** the message contains only the open notes section
|
||||
- **AND** the selected text section is omitted
|
||||
|
||||
#### Scenario: Empty context
|
||||
- **WHEN** no markdown files are open
|
||||
- **AND** no text is selected
|
||||
- **THEN** no context message is injected
|
||||
- **AND** any previous injected context part is marked as `ignored: true`
|
||||
58
openspec/changes/add-workspace-context-injection/tasks.md
Normal file
58
openspec/changes/add-workspace-context-injection/tasks.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Tasks: Add Workspace Context Injection
|
||||
|
||||
## 1. Dependencies
|
||||
|
||||
- [x] 1.1 No new dependencies required (use direct `fetch()` calls)
|
||||
|
||||
## 2. Types and Settings
|
||||
|
||||
- [x] 2.1 Add `injectWorkspaceContext: boolean` to `OpenCodeSettings` interface (default: `true`)
|
||||
- [x] 2.2 Add `maxNotesInContext: number` to `OpenCodeSettings` interface (default: `20`)
|
||||
- [x] 2.3 Add `maxSelectionLength: number` to `OpenCodeSettings` interface (default: `2000`)
|
||||
- [x] 2.4 Update `DEFAULT_SETTINGS` with new values
|
||||
|
||||
## 3. OpenCode Client Module
|
||||
|
||||
- [x] 3.1 Create `src/OpenCodeClient.ts` with a small HTTP wrapper
|
||||
- [x] 3.2 Implement `createSession()` and session URL helpers
|
||||
- [x] 3.3 Implement `updateContext()` using update-part or ignore+reinject (no revert)
|
||||
- [x] 3.4 Track last injected context message/part IDs for the tracked session
|
||||
- [x] 3.5 Add error handling for API failures (silent catch, log to console)
|
||||
|
||||
## 4. Workspace Context Module
|
||||
|
||||
- [x] 4.1 Create `src/WorkspaceContext.ts` for collecting workspace context
|
||||
- [x] 4.2 Implement `getOpenNotePaths()` using `getLeavesOfType("markdown")`
|
||||
- [x] 4.3 Implement `getSelectedText()` to get current editor selection with source file
|
||||
- [x] 4.4 Implement `formatContext()` to generate the combined context string
|
||||
- [x] 4.5 Add deduplication for files open in multiple panes
|
||||
- [x] 4.6 Add truncation for selections exceeding `maxSelectionLength`
|
||||
|
||||
## 5. Main Plugin Integration
|
||||
|
||||
- [x] 5.1 Import `OpenCodeClient` and `WorkspaceContext` in main.ts
|
||||
- [x] 5.2 Initialize `WorkspaceContext` in `onload()`
|
||||
- [x] 5.3 Create `updateOpenCodeContext()` method triggered on OpenCode view focus
|
||||
- [x] 5.4 Register `active-leaf-change` event listener to detect OpenCode view focus (conditional on setting)
|
||||
- [x] 5.5 Create an OpenCode session on first view open and store the iframe URL (in-memory)
|
||||
- [x] 5.6 Before injecting, resolve `sessionID` by parsing the current iframe URL (if no session route, no-op)
|
||||
- [x] 5.7 Add server running check before attempting context updates
|
||||
- [x] 5.8 Trigger initial context injection when server reaches running state and a session exists
|
||||
|
||||
## 6. Settings UI
|
||||
|
||||
- [x] 6.1 Add toggle for "Inject workspace context" in SettingsTab
|
||||
- [x] 6.2 Add slider for "Max notes in context" (1-50 range)
|
||||
- [x] 6.3 Add slider or input for "Max selection length" (500-5000 range)
|
||||
- [x] 6.4 Add descriptive text explaining the feature includes open notes and selected text
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- [ ] 7.1 Manual test: Open multiple notes, verify context appears in OpenCode
|
||||
- [ ] 7.2 Manual test: Select text, verify selection appears in context with source file
|
||||
- [ ] 7.3 Manual test: Change open notes while OpenCode is not focused, then focus OpenCode and verify context refreshes
|
||||
- [ ] 7.4 Manual test: Clear selection, verify selection section is removed
|
||||
- [ ] 7.5 Manual test: Disable setting, verify no context injection
|
||||
- [ ] 7.6 Manual test: Server not running, verify no errors thrown
|
||||
- [ ] 7.7 Manual test: Large selection, verify truncation works
|
||||
- [ ] 7.8 Build and verify no TypeScript errors
|
||||
216
src/OpenCodeClient.ts
Normal file
216
src/OpenCodeClient.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
type OpenCodePart = {
|
||||
id: string;
|
||||
messageID: string;
|
||||
sessionID: string;
|
||||
type: string;
|
||||
text?: string;
|
||||
ignored?: boolean;
|
||||
synthetic?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
time?: {
|
||||
start: number;
|
||||
end?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type OpenCodeMessageInfo = {
|
||||
id: string;
|
||||
sessionID: string;
|
||||
};
|
||||
|
||||
type OpenCodeMessageWithParts = {
|
||||
info: OpenCodeMessageInfo;
|
||||
parts: OpenCodePart[];
|
||||
};
|
||||
|
||||
type OpenCodeSession = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
type OpenCodeResponse<T> = T | { data?: T } | { message?: T } | null;
|
||||
|
||||
export class OpenCodeClient {
|
||||
private apiBaseUrl: string;
|
||||
private uiBaseUrl: string;
|
||||
private projectDirectory: string;
|
||||
private trackedSessionId: string | null = null;
|
||||
private lastMessageId: string | null = null;
|
||||
private lastPart: OpenCodePart | null = null;
|
||||
|
||||
constructor(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string) {
|
||||
this.apiBaseUrl = this.normalizeBaseUrl(apiBaseUrl);
|
||||
this.uiBaseUrl = this.normalizeBaseUrl(uiBaseUrl);
|
||||
this.projectDirectory = projectDirectory;
|
||||
}
|
||||
|
||||
updateBaseUrl(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string): void {
|
||||
const nextApiUrl = this.normalizeBaseUrl(apiBaseUrl);
|
||||
const nextUiUrl = this.normalizeBaseUrl(uiBaseUrl);
|
||||
if (
|
||||
nextApiUrl !== this.apiBaseUrl ||
|
||||
nextUiUrl !== this.uiBaseUrl ||
|
||||
projectDirectory !== this.projectDirectory
|
||||
) {
|
||||
this.apiBaseUrl = nextApiUrl;
|
||||
this.uiBaseUrl = nextUiUrl;
|
||||
this.projectDirectory = projectDirectory;
|
||||
this.resetTracking();
|
||||
}
|
||||
}
|
||||
|
||||
resetTracking(): void {
|
||||
this.trackedSessionId = null;
|
||||
this.lastMessageId = null;
|
||||
this.lastPart = null;
|
||||
}
|
||||
|
||||
getSessionUrl(sessionId: string): string {
|
||||
return `${this.uiBaseUrl}/session/${sessionId}`;
|
||||
}
|
||||
|
||||
resolveSessionId(iframeUrl: string): string | null {
|
||||
const match = iframeUrl.match(/\/session\/([^/?#]+)/);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
async createSession(): Promise<string | null> {
|
||||
const result = await this.request<OpenCodeSession>("POST", "/session", {
|
||||
title: "Obsidian",
|
||||
});
|
||||
const session = this.unwrap(result);
|
||||
return session?.id ?? null;
|
||||
}
|
||||
|
||||
async updateContext(params: {
|
||||
sessionId: string;
|
||||
contextText: string | null;
|
||||
}): Promise<void> {
|
||||
const { sessionId, contextText } = params;
|
||||
|
||||
if (this.trackedSessionId && this.trackedSessionId !== sessionId) {
|
||||
this.resetTracking();
|
||||
}
|
||||
this.trackedSessionId = sessionId;
|
||||
|
||||
if (!contextText) {
|
||||
await this.ignorePreviousPart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.lastPart) {
|
||||
const updated = await this.updatePart(this.lastPart, { text: contextText });
|
||||
if (updated) {
|
||||
return;
|
||||
}
|
||||
await this.ignorePreviousPart();
|
||||
}
|
||||
|
||||
const message = await this.sendPrompt(sessionId, contextText);
|
||||
if (message?.info?.id) {
|
||||
this.lastMessageId = message.info.id;
|
||||
this.lastPart = message.parts?.[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendPrompt(sessionId: string, contextText: string): Promise<OpenCodeMessageWithParts | null> {
|
||||
const result = await this.request<OpenCodeMessageWithParts>(
|
||||
"POST",
|
||||
`/session/${sessionId}/message`,
|
||||
{
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: contextText }],
|
||||
}
|
||||
);
|
||||
|
||||
console.log("[OpenCode] Injected context message");
|
||||
console.log(contextText)
|
||||
|
||||
const message = this.unwrap(result);
|
||||
if (!message) {
|
||||
console.error("[OpenCode] Failed to inject context message");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
private async updatePart(part: OpenCodePart, updates: { text?: string; ignored?: boolean }): Promise<boolean> {
|
||||
const result = await this.request<OpenCodePart>(
|
||||
"PATCH",
|
||||
`/session/${part.sessionID}/message/${part.messageID}/part/${part.id}`,
|
||||
{
|
||||
...part,
|
||||
...updates,
|
||||
}
|
||||
);
|
||||
const updated = this.unwrap(result);
|
||||
if (updated) {
|
||||
this.lastPart = updated;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async ignorePreviousPart(): Promise<boolean> {
|
||||
if (!this.lastPart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ignored = await this.updatePart(this.lastPart, { ignored: true });
|
||||
if (!ignored) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.lastMessageId = null;
|
||||
this.lastPart = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async request<T>(method: string, path: string, body?: unknown): Promise<OpenCodeResponse<T>> {
|
||||
try {
|
||||
const url = `${this.apiBaseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-opencode-directory": this.projectDirectory,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("[OpenCode] API request failed", {
|
||||
path,
|
||||
status: response.status,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = await response
|
||||
.json()
|
||||
.catch(() => null);
|
||||
return json as OpenCodeResponse<T>;
|
||||
} catch (error) {
|
||||
console.error("[OpenCode] API request error", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private unwrap<T>(result: OpenCodeResponse<T>): T | null {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
if (typeof result === "object") {
|
||||
const payload = result as { data?: T; message?: T };
|
||||
if (payload.data) {
|
||||
return payload.data;
|
||||
}
|
||||
if (payload.message) {
|
||||
return payload.message;
|
||||
}
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
private normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,10 @@ export class OpenCodeView extends ItemView {
|
||||
|
||||
// Clean up iframe
|
||||
if (this.iframeEl) {
|
||||
const iframeUrl = this.iframeEl.src;
|
||||
if (iframeUrl.includes("/session/")) {
|
||||
this.plugin.setCachedIframeUrl(iframeUrl);
|
||||
}
|
||||
this.iframeEl.src = "about:blank";
|
||||
this.iframeEl = null;
|
||||
}
|
||||
@@ -152,12 +156,13 @@ export class OpenCodeView extends ItemView {
|
||||
cls: "opencode-iframe-container",
|
||||
});
|
||||
|
||||
console.log("[OpenCode] Loading iframe with URL:", this.plugin.getServerUrl());
|
||||
const iframeUrl = this.plugin.getStoredIframeUrl() ?? this.plugin.getServerUrl();
|
||||
console.log("[OpenCode] Loading iframe with URL:", iframeUrl);
|
||||
|
||||
this.iframeEl = iframeContainer.createEl("iframe", {
|
||||
cls: "opencode-iframe",
|
||||
attr: {
|
||||
src: this.plugin.getServerUrl(),
|
||||
src: iframeUrl,
|
||||
frameborder: "0",
|
||||
allow: "clipboard-read; clipboard-write",
|
||||
},
|
||||
@@ -166,6 +171,26 @@ export class OpenCodeView extends ItemView {
|
||||
this.iframeEl.addEventListener("error", () => {
|
||||
console.error("Failed to load OpenCode iframe");
|
||||
});
|
||||
|
||||
this.iframeEl.addEventListener("focus", () => {
|
||||
this.plugin.refreshContextForView(this);
|
||||
});
|
||||
|
||||
this.iframeEl.addEventListener("pointerdown", () => {
|
||||
this.plugin.refreshContextForView(this);
|
||||
});
|
||||
|
||||
void this.plugin.ensureSessionUrl(this);
|
||||
}
|
||||
|
||||
getIframeUrl(): string | null {
|
||||
return this.iframeEl?.src ?? null;
|
||||
}
|
||||
|
||||
setIframeUrl(url: string): void {
|
||||
if (this.iframeEl && this.iframeEl.src !== url) {
|
||||
this.iframeEl.src = url;
|
||||
}
|
||||
}
|
||||
|
||||
private renderErrorState(): void {
|
||||
|
||||
@@ -125,6 +125,50 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
})
|
||||
);
|
||||
|
||||
containerEl.createEl("h3", { text: "Workspace Context" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Inject workspace context")
|
||||
.setDesc(
|
||||
"Includes open note paths and selected text in OpenCode when the view is focused"
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.injectWorkspaceContext)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.injectWorkspaceContext = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Max notes in context")
|
||||
.setDesc("Limit how many open notes are included")
|
||||
.addSlider((slider) =>
|
||||
slider
|
||||
.setLimits(1, 50, 1)
|
||||
.setValue(this.plugin.settings.maxNotesInContext)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.maxNotesInContext = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Max selection length")
|
||||
.setDesc("Truncate selected text to avoid oversized context")
|
||||
.addSlider((slider) =>
|
||||
slider
|
||||
.setLimits(500, 5000, 100)
|
||||
.setValue(this.plugin.settings.maxSelectionLength)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.maxSelectionLength = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
containerEl.createEl("h3", { text: "Server Status" });
|
||||
|
||||
const statusContainer = containerEl.createDiv({ cls: "opencode-settings-status" });
|
||||
|
||||
108
src/WorkspaceContext.ts
Normal file
108
src/WorkspaceContext.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { App, MarkdownView } from "obsidian";
|
||||
|
||||
type SelectedTextContext = {
|
||||
text: string;
|
||||
sourcePath: string;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export class WorkspaceContext {
|
||||
private app: App;
|
||||
private lastSelection: { text: string; sourcePath: string } | null = null;
|
||||
private lastMarkdownView: MarkdownView | null = null;
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
getOpenNotePaths(maxNotes: number): string[] {
|
||||
const leaves = this.app.workspace.getLeavesOfType("markdown");
|
||||
const paths = new Set<string>();
|
||||
|
||||
for (const leaf of leaves) {
|
||||
const view = leaf.view as MarkdownView;
|
||||
const path = view.file?.path;
|
||||
if (path) {
|
||||
paths.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(paths).slice(0, Math.max(0, maxNotes));
|
||||
}
|
||||
|
||||
updateSelectionFromView(view: MarkdownView | null): void {
|
||||
if (view) {
|
||||
this.lastMarkdownView = view;
|
||||
}
|
||||
const sourcePath = view?.file?.path;
|
||||
const selection = view?.editor?.getSelection() ?? "";
|
||||
|
||||
if (!sourcePath || !selection.trim()) {
|
||||
this.lastSelection = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastSelection = {
|
||||
text: selection,
|
||||
sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
getSelectedText(maxSelectionLength: number): SelectedTextContext | null {
|
||||
const view = this.app.workspace.getActiveViewOfType(MarkdownView) ?? this.lastMarkdownView;
|
||||
const sourcePath = view?.file?.path;
|
||||
const selection = view?.editor?.getSelection() ?? "";
|
||||
|
||||
let text = "";
|
||||
let path = "";
|
||||
|
||||
if (sourcePath && selection.trim()) {
|
||||
text = selection;
|
||||
path = sourcePath;
|
||||
this.lastSelection = { text, sourcePath: path };
|
||||
} else if (this.lastSelection) {
|
||||
text = this.lastSelection.text;
|
||||
path = this.lastSelection.sourcePath;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const truncated = text.length > maxSelectionLength;
|
||||
const trimmed = truncated ? text.slice(0, maxSelectionLength) : text;
|
||||
|
||||
return {
|
||||
text: trimmed,
|
||||
sourcePath: path,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
formatContext(openPaths: string[], selection: SelectedTextContext | null): string | null {
|
||||
if (openPaths.length === 0 && !selection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines: string[] = ["<system-reminder>"];
|
||||
|
||||
if (openPaths.length > 0) {
|
||||
lines.push("Currently open notes in Obsidian:");
|
||||
for (const path of openPaths) {
|
||||
lines.push(`- ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (selection) {
|
||||
lines.push("");
|
||||
lines.push(`Selected text (from ${selection.sourcePath}):`);
|
||||
lines.push('"""');
|
||||
lines.push(selection.text);
|
||||
if (selection.truncated) {
|
||||
lines.push("[truncated]");
|
||||
}
|
||||
lines.push('"""');
|
||||
}
|
||||
|
||||
lines.push("</system-reminder>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
215
src/main.ts
215
src/main.ts
@@ -1,14 +1,23 @@
|
||||
import { Plugin, WorkspaceLeaf, Notice } from "obsidian";
|
||||
import { Plugin, WorkspaceLeaf, Notice, EventRef, MarkdownView } from "obsidian";
|
||||
import { OpenCodeSettings, DEFAULT_SETTINGS, OPENCODE_VIEW_TYPE } from "./types";
|
||||
import { OpenCodeView } from "./OpenCodeView";
|
||||
import { OpenCodeSettingTab } from "./SettingsTab";
|
||||
import { ProcessManager, ProcessState } from "./ProcessManager";
|
||||
import { registerOpenCodeIcons, OPENCODE_ICON_NAME } from "./icons";
|
||||
import { OpenCodeClient } from "./OpenCodeClient";
|
||||
import { WorkspaceContext } from "./WorkspaceContext";
|
||||
|
||||
export default class OpenCodePlugin extends Plugin {
|
||||
settings: OpenCodeSettings = DEFAULT_SETTINGS;
|
||||
private processManager: ProcessManager;
|
||||
private stateChangeCallbacks: Array<(state: ProcessState) => void> = [];
|
||||
private openCodeClient: OpenCodeClient;
|
||||
private workspaceContext: WorkspaceContext;
|
||||
private cachedIframeUrl: string | null = null;
|
||||
private lastBaseUrl: string | null = null;
|
||||
private focusEventRef: EventRef | null = null;
|
||||
private sidebarEventRefs: EventRef[] = [];
|
||||
private sidebarRefreshTimer: number | null = null;
|
||||
|
||||
async onload(): Promise<void> {
|
||||
console.log("Loading OpenCode plugin");
|
||||
@@ -25,6 +34,10 @@ export default class OpenCodePlugin extends Plugin {
|
||||
(state) => this.notifyStateChange(state)
|
||||
);
|
||||
|
||||
this.openCodeClient = new OpenCodeClient(this.getApiBaseUrl(), this.getServerUrl(), projectDirectory);
|
||||
this.workspaceContext = new WorkspaceContext(this.app);
|
||||
this.lastBaseUrl = this.getServerUrl();
|
||||
|
||||
console.log("[OpenCode] Configured with project directory:", projectDirectory);
|
||||
|
||||
this.registerView(OPENCODE_VIEW_TYPE, (leaf) => new OpenCodeView(leaf, this));
|
||||
@@ -70,6 +83,14 @@ export default class OpenCodePlugin extends Plugin {
|
||||
});
|
||||
}
|
||||
|
||||
this.updateFocusListener();
|
||||
this.updateSidebarListeners();
|
||||
this.onProcessStateChange((state) => {
|
||||
if (state === "running") {
|
||||
void this.handleServerRunning();
|
||||
}
|
||||
});
|
||||
|
||||
console.log("OpenCode plugin loaded");
|
||||
}
|
||||
|
||||
@@ -85,6 +106,9 @@ export default class OpenCodePlugin extends Plugin {
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
this.processManager.updateSettings(this.settings);
|
||||
this.refreshClientState();
|
||||
this.updateFocusListener();
|
||||
this.updateSidebarListeners();
|
||||
}
|
||||
|
||||
// Update project directory and restart server if running
|
||||
@@ -93,6 +117,7 @@ export default class OpenCodePlugin extends Plugin {
|
||||
await this.saveData(this.settings);
|
||||
|
||||
this.processManager.updateProjectDirectory(this.getProjectDirectory());
|
||||
this.refreshClientState();
|
||||
|
||||
if (this.getProcessState() === "running") {
|
||||
this.stopServer();
|
||||
@@ -179,6 +204,51 @@ export default class OpenCodePlugin extends Plugin {
|
||||
return this.processManager.getUrl();
|
||||
}
|
||||
|
||||
getApiBaseUrl(): string {
|
||||
return `http://${this.settings.hostname}:${this.settings.port}`;
|
||||
}
|
||||
|
||||
getStoredIframeUrl(): string | null {
|
||||
return this.cachedIframeUrl;
|
||||
}
|
||||
|
||||
setCachedIframeUrl(url: string | null): void {
|
||||
this.cachedIframeUrl = url;
|
||||
}
|
||||
|
||||
async ensureSessionUrl(view: OpenCodeView): Promise<void> {
|
||||
if (this.getProcessState() !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingUrl = this.cachedIframeUrl ?? view.getIframeUrl();
|
||||
if (existingUrl && this.openCodeClient.resolveSessionId(existingUrl)) {
|
||||
this.cachedIframeUrl = existingUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = await this.openCodeClient.createSession();
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionUrl = this.openCodeClient.getSessionUrl(sessionId);
|
||||
this.cachedIframeUrl = sessionUrl;
|
||||
view.setIframeUrl(sessionUrl);
|
||||
|
||||
if (this.app.workspace.activeLeaf === view.leaf) {
|
||||
await this.updateOpenCodeContext(view.leaf);
|
||||
}
|
||||
}
|
||||
|
||||
refreshContextForView(view: OpenCodeView): void {
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.updateOpenCodeContext(view.leaf);
|
||||
}
|
||||
|
||||
onProcessStateChange(callback: (state: ProcessState) => void): () => void {
|
||||
this.stateChangeCallbacks.push(callback);
|
||||
return () => {
|
||||
@@ -195,6 +265,149 @@ export default class OpenCodePlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
private refreshClientState(): void {
|
||||
const nextUiBaseUrl = this.getServerUrl();
|
||||
const nextApiBaseUrl = this.getApiBaseUrl();
|
||||
const projectDirectory = this.getProjectDirectory();
|
||||
this.openCodeClient.updateBaseUrl(nextApiBaseUrl, nextUiBaseUrl, projectDirectory);
|
||||
|
||||
if (this.lastBaseUrl && this.lastBaseUrl !== nextUiBaseUrl) {
|
||||
this.cachedIframeUrl = null;
|
||||
}
|
||||
|
||||
this.lastBaseUrl = nextUiBaseUrl;
|
||||
}
|
||||
|
||||
private updateFocusListener(): void {
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
if (this.focusEventRef) {
|
||||
this.app.workspace.offref(this.focusEventRef);
|
||||
this.focusEventRef = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.focusEventRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventRef = this.app.workspace.on("active-leaf-change", (leaf) => {
|
||||
if (leaf?.view instanceof MarkdownView) {
|
||||
this.workspaceContext.updateSelectionFromView(leaf.view);
|
||||
}
|
||||
if (leaf?.view.getViewType() === OPENCODE_VIEW_TYPE) {
|
||||
void this.updateOpenCodeContext(leaf);
|
||||
}
|
||||
});
|
||||
|
||||
this.focusEventRef = eventRef;
|
||||
this.registerEvent(eventRef);
|
||||
}
|
||||
|
||||
private updateSidebarListeners(): void {
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
this.clearSidebarListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sidebarEventRefs.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileOpenRef = this.app.workspace.on("file-open", () => {
|
||||
this.scheduleSidebarContextRefresh();
|
||||
});
|
||||
const editorChangeRef = this.app.workspace.on("editor-change", (_editor, view) => {
|
||||
const markdownView = view instanceof MarkdownView ? view : this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
this.workspaceContext.updateSelectionFromView(markdownView);
|
||||
this.scheduleSidebarContextRefresh();
|
||||
});
|
||||
|
||||
this.sidebarEventRefs = [fileOpenRef, editorChangeRef];
|
||||
this.sidebarEventRefs.forEach((ref) => this.registerEvent(ref));
|
||||
}
|
||||
|
||||
private clearSidebarListeners(): void {
|
||||
for (const ref of this.sidebarEventRefs) {
|
||||
this.app.workspace.offref(ref);
|
||||
}
|
||||
this.sidebarEventRefs = [];
|
||||
if (this.sidebarRefreshTimer !== null) {
|
||||
window.clearTimeout(this.sidebarRefreshTimer);
|
||||
this.sidebarRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSidebarContextRefresh(): void {
|
||||
const leaf = this.getVisibleSidebarOpenCodeLeaf();
|
||||
if (!leaf) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sidebarRefreshTimer !== null) {
|
||||
window.clearTimeout(this.sidebarRefreshTimer);
|
||||
}
|
||||
|
||||
this.sidebarRefreshTimer = window.setTimeout(() => {
|
||||
this.sidebarRefreshTimer = null;
|
||||
void this.updateOpenCodeContext(leaf);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private getVisibleSidebarOpenCodeLeaf(): WorkspaceLeaf | null {
|
||||
const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE);
|
||||
if (leaves.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rightSplit = this.app.workspace.rightSplit;
|
||||
if (!rightSplit || rightSplit.collapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leaf = leaves[0];
|
||||
return leaf.getRoot() === rightSplit ? leaf : null;
|
||||
}
|
||||
|
||||
private async handleServerRunning(): Promise<void> {
|
||||
const activeLeaf = this.app.workspace.activeLeaf;
|
||||
if (activeLeaf?.view.getViewType() === OPENCODE_VIEW_TYPE) {
|
||||
await this.updateOpenCodeContext(activeLeaf);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateOpenCodeContext(leaf: WorkspaceLeaf): Promise<void> {
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.getProcessState() !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = leaf.view instanceof OpenCodeView ? leaf.view : null;
|
||||
const iframeUrl = this.cachedIframeUrl ?? view?.getIframeUrl();
|
||||
if (!iframeUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = this.openCodeClient.resolveSessionId(iframeUrl);
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cachedIframeUrl = iframeUrl;
|
||||
|
||||
const openPaths = this.workspaceContext.getOpenNotePaths(this.settings.maxNotesInContext);
|
||||
const selection = this.workspaceContext.getSelectedText(this.settings.maxSelectionLength);
|
||||
const contextText = this.workspaceContext.formatContext(openPaths, selection);
|
||||
|
||||
await this.openCodeClient.updateContext({
|
||||
sessionId,
|
||||
contextText,
|
||||
});
|
||||
}
|
||||
|
||||
getProjectDirectory(): string {
|
||||
if (this.settings.projectDirectory) {
|
||||
console.log("[OpenCode] Using project directory from settings:", this.settings.projectDirectory);
|
||||
|
||||
@@ -8,6 +8,9 @@ export interface OpenCodeSettings {
|
||||
projectDirectory: string;
|
||||
startupTimeout: number;
|
||||
defaultViewLocation: ViewLocation;
|
||||
injectWorkspaceContext: boolean;
|
||||
maxNotesInContext: number;
|
||||
maxSelectionLength: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: OpenCodeSettings = {
|
||||
@@ -18,6 +21,9 @@ export const DEFAULT_SETTINGS: OpenCodeSettings = {
|
||||
projectDirectory: "",
|
||||
startupTimeout: 15000,
|
||||
defaultViewLocation: "sidebar",
|
||||
injectWorkspaceContext: true,
|
||||
maxNotesInContext: 20,
|
||||
maxSelectionLength: 2000,
|
||||
};
|
||||
|
||||
export const OPENCODE_VIEW_TYPE = "opencode-view";
|
||||
|
||||
Reference in New Issue
Block a user