Context injection

This commit is contained in:
Mateusz Tymek
2026-01-31 14:32:51 +01:00
11 changed files with 1494 additions and 16 deletions

216
src/OpenCodeClient.ts Normal file
View 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(/\/+$/, "");
}
}

View File

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

View File

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

View File

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

View File

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