Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/opencode-plugin/commands.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import path from "path";
Expand All @@ -19,6 +19,11 @@ const startAnnotateServerMock = mock(async (_options: any) => ({
}));

const tempDirs: string[] = [];
const originalDataDir = process.env.PLANNOTATOR_DATA_DIR;

beforeEach(() => {
process.env.PLANNOTATOR_DATA_DIR = makeTempDir();
});

function makeTempDir(): string {
const dir = mkdtempSync(path.join(tmpdir(), "plannotator-opencode-commands-"));
Expand Down Expand Up @@ -49,6 +54,8 @@ function makeDeps() {

afterEach(() => {
startAnnotateServerMock.mockClear();
if (originalDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
else process.env.PLANNOTATOR_DATA_DIR = originalDataDir;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
Expand Down
98 changes: 78 additions & 20 deletions apps/opencode-plugin/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,8 @@
* slash commands. Extracted from the event hook for modularity.
*/

import {
startReviewServer,
handleReviewServerReady,
} from "@plannotator/server/review";
import {
startAnnotateServer,
handleAnnotateServerReady,
} from "@plannotator/server/annotate";
import { startReviewServer, handleReviewServerReady } from "@plannotator/server/review";
import { startAnnotateServer, handleAnnotateServerReady } from "@plannotator/server/annotate";
import { type DiffType, prepareLocalReviewDiff, detectManagedVcs } from "@plannotator/server/vcs";
import { detectProjectName } from "@plannotator/server/project";
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
Expand Down Expand Up @@ -41,10 +35,60 @@ import { statSync } from "fs";
import path from "path";
import { resolveValidatedTargetAgent } from "./agent-switch";
import { deliverOpenCodePrompt } from "./prompt-delivery-error";
import { clearSessionUrl, writeSessionUrl } from "./session-url";

/** Run fn with stderr temporarily silenced (prevents TUI overlay corruption). */
function withStderrSuppressed(fn: () => void | Promise<void>): void {
const orig = process.stderr.write.bind(process.stderr);
process.stderr.write = (() => true) as any;
try {
const result = fn();
if (result && typeof (result as any).catch === "function") (result as any).catch(() => {});
} finally {
process.stderr.write = orig;
}
}

/**
* Resolve the hostname for a toast URL in the server-plugin context.
*
* Uses `ctx.serverUrl` (passed through CommandDeps) — a getter on the
* opencode server's actual listen address (`Server.url`), evaluated lazily
* so it reflects the resolved hostname even though the SDK client's
* `baseUrl` was snapshotted at plugin init time. Falls back to
* "localhost" when serverUrl is unavailable (e.g. test stubs).
*
* This is intentionally separate from the TUI's `getServerHostname()`:
* the TUI resolves via `api.client.client.getConfig().baseUrl` and
* `api.state.config.server.hostname` (TUI-only sources), while the server
* plugin has direct access to `ctx.serverUrl`.
*/
function resolveToastHost(serverUrl: URL | undefined): string {
return serverUrl?.hostname ?? "localhost";
}

/** Show a Plannotator toast notification (best-effort, non-blocking). */
function showPlannotatorToast(client: any, serverUrl: URL | undefined, url: string, label: string): void {
const host = resolveToastHost(serverUrl);
let displayUrl = url;
try {
const port = new URL(url).port;
if (port) displayUrl = `http://${host}:${port}`;
} catch { /* use original URL */ }
try {
const result = client.tui?.showToast?.({
body: { title: "Plannotator", message: `Open ${label}: ${displayUrl} (URL also in sidebar)`, variant: "info" },
});
if (result && typeof result.catch === "function") result.catch(() => {});
} catch {}
}

/** Shared dependencies injected by the plugin */
export interface CommandDeps {
client: any;
/** The opencode server's actual listen URL (ctx.serverUrl from PluginInput).
* Used to resolve a reachable hostname for toast notifications. */
serverUrl?: URL;
htmlContent: string;
reviewHtmlContent: string;
getSharingEnabled: () => Promise<boolean>;
Expand All @@ -63,7 +107,7 @@ export async function handleReviewCommand(
event: any,
deps: CommandDeps
) {
const { client, reviewHtmlContent, getSharingEnabled, getShareBaseUrl, directory } = deps;
const { client, serverUrl, reviewHtmlContent, getSharingEnabled, getShareBaseUrl, directory } = deps;

// @ts-ignore - Event properties contain arguments
const reviewArgs = parseReviewArgs(event.properties?.arguments || "");
Expand Down Expand Up @@ -148,6 +192,9 @@ export async function handleReviewCommand(
}
}

// @ts-ignore - Event properties contain sessionID
const sessionId = event.properties?.sessionID;
let sessionUrl: string | undefined;
const server = await startReviewServer({
rawPatch,
gitRef,
Expand All @@ -164,23 +211,24 @@ export async function handleReviewCommand(
htmlContent: reviewHtmlContent,
opencodeClient: client,
onReady: (url, isRemote, port) => {
handleReviewServerReady(url, isRemote, port);
client.app.log({ level: "info", message: `[Plannotator] Open code review: ${url}` });
sessionUrl = url;
withStderrSuppressed(() => handleReviewServerReady(url, isRemote, port));
void client.app.log({ level: "info", message: `[Plannotator] Open code review: ${url}` });
showPlannotatorToast(client, serverUrl, url, "code review");
writeSessionUrl(url, sessionId);
},
});

const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
clearSessionUrl(sessionUrl);

if (result.exit) {
return;
}

if (result.feedback) {
// @ts-ignore - Event properties contain sessionID
const sessionId = event.properties?.sessionID;

if (sessionId) {
const targetAgent = await resolveValidatedTargetAgent({
client,
Expand Down Expand Up @@ -216,7 +264,7 @@ export async function handleAnnotateCommand(
event: any,
deps: CommandDeps
) {
const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl, directory } = deps;
const { client, serverUrl, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl, directory } = deps;
const startServer = deps.startAnnotateServer ?? startAnnotateServer;

// @ts-ignore - Event properties contain arguments
Expand Down Expand Up @@ -367,6 +415,7 @@ export async function handleAnnotateCommand(
// and Pi runtimes, which both pass it (otherwise history lands in the
// shared "_unknown" bucket).
const annotateProject = (await detectProjectName()) ?? undefined;
let sessionUrl: string | undefined;
const server = await startServer({
markdown,
filePath: absolutePath,
Expand All @@ -387,14 +436,18 @@ export async function handleAnnotateCommand(
agentCwd,
htmlContent,
onReady: (url, isRemote, port) => {
handleAnnotateServerReady(url, isRemote, port);
client.app.log({ level: "info", message: `[Plannotator] Open annotation UI: ${url}` });
sessionUrl = url;
withStderrSuppressed(() => handleAnnotateServerReady(url, isRemote, port));
void client.app.log({ level: "info", message: `[Plannotator] Open annotation UI: ${url}` });
showPlannotatorToast(client, serverUrl, url, "annotation UI");
writeSessionUrl(url, sessionId);
},
});

const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
clearSessionUrl(sessionUrl);

if (result.exit || (result.approved && !result.feedback)) {
return;
Expand Down Expand Up @@ -440,7 +493,7 @@ export async function handleAnnotateLastCommand(
event: any,
deps: CommandDeps
): Promise<{ approved: boolean; feedback: string } | null> {
const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps;
const { client, serverUrl, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps;
const startServer = deps.startAnnotateServer ?? startAnnotateServer;

// @ts-ignore - Event properties contain arguments
Expand Down Expand Up @@ -490,6 +543,7 @@ export async function handleAnnotateLastCommand(
const pickerMessages = recentMessages.length > 1 ? recentMessages : undefined;

const lastProject = (await detectProjectName()) ?? undefined;
let sessionUrl: string | undefined;
const server = await startServer({
markdown: lastText,
filePath: "last-message",
Expand All @@ -504,14 +558,18 @@ export async function handleAnnotateLastCommand(
approvalNotesSupported: true,
htmlContent,
onReady: (url, isRemote, port) => {
handleAnnotateServerReady(url, isRemote, port);
client.app.log({ level: "info", message: `[Plannotator] Open annotation UI: ${url}` });
sessionUrl = url;
withStderrSuppressed(() => handleAnnotateServerReady(url, isRemote, port));
void client.app.log({ level: "info", message: `[Plannotator] Open annotation UI: ${url}` });
showPlannotatorToast(client, serverUrl, url, "annotation UI");
writeSessionUrl(url, sessionId);
},
});

const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
clearSessionUrl(sessionUrl);

if (result.exit || (result.approved && !result.feedback)) {
return null;
Expand Down
1 change: 1 addition & 0 deletions apps/opencode-plugin/embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export async function handleEmbeddedCommand(
event: any,
deps: {
client: any;
serverUrl?: URL;
htmlContent: string;
reviewHtmlContent: string;
getSharingEnabled: () => Promise<boolean>;
Expand Down
2 changes: 2 additions & 0 deletions apps/opencode-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ type EmbeddedRuntimeModule = {
event: any,
deps: {
client: any;
serverUrl?: URL;
htmlContent: string;
reviewHtmlContent: string;
getSharingEnabled: () => Promise<boolean>;
Expand Down Expand Up @@ -466,6 +467,7 @@ Do NOT proceed with implementation until your plan is approved.`;
const embedded = await importEmbeddedRuntime();
const deps = {
client: ctx.client,
serverUrl: ctx.serverUrl,
htmlContent: getPlanHtml(),
reviewHtmlContent: getReviewHtml(),
getSharingEnabled,
Expand Down
1 change: 1 addition & 0 deletions apps/opencode-plugin/package-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe("OpenCode package entrypoints", () => {
// OpenCode 1 checks ./server before main, so that subpath must remain absent.
expect(packageJson.exports).toEqual({
".": "./dist/server.js",
"./tui": "./dist/tui.js",
});
});

Expand Down
13 changes: 10 additions & 3 deletions apps/opencode-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"//": "OpenCode 1 loads main, while OpenCode 2 loads exports[.]. Do not add ./server: OpenCode 1 checks it before main and would load the V2 adapter.",
"main": "dist/index.js",
"exports": {
".": "./dist/server.js"
".": "./dist/server.js",
"./tui": "./dist/tui.js"
},
"//files": "skills/ is generated by build:skill from apps/skills/core/plannotator and is gitignored, so the shipped copy cannot drift from the single source.",
"files": [
Expand All @@ -37,16 +38,22 @@
"review-editor.html"
],
"scripts": {
"build": "bun run build:skill && mkdir -p dist && cp ../hook/dist/index.html ./plannotator.html && cp ../review/dist/index.html ./review-editor.html && bun build embedded.ts --outfile dist/embedded.js --target bun --external @opencode-ai/plugin && bun build index.ts --outfile dist/index.js --target node && bun build server.ts --outfile dist/server.js --target node --external @opencode-ai/plugin",
"build": "bun run build:skill && mkdir -p dist && cp ../hook/dist/index.html ./plannotator.html && cp ../review/dist/index.html ./review-editor.html && bun build embedded.ts --outfile dist/embedded.js --target bun --external @opencode-ai/plugin && bun build index.ts --outfile dist/index.js --target node && bun build server.ts --outfile dist/server.js --target node --external @opencode-ai/plugin && bunx tsup --config tsup.tui.config.ts",
"build:skill": "rm -rf skills && mkdir -p skills && cp -R ../skills/core/plannotator skills/plannotator",
"smoke:v2": "bun fixtures/v2-installed-smoke.ts",
"postinstall": "mkdir -p ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands && cp ./commands/*.md ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands/ 2>/dev/null || true; mkdir -p ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills/plannotator && cp ./skills/plannotator/SKILL.md ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills/plannotator/ 2>/dev/null || true",
"prepublishOnly": "bun run build"
},
"dependencies": {
"@opentui/solid": "^0.4.5",
"solid-js": "1.9.12"
},
"devDependencies": {
"@opencode-ai/plugin": "0.0.0-next-16775",
"@plannotator/server": "workspace:*",
"@plannotator/shared": "workspace:*"
"@plannotator/shared": "workspace:*",
"esbuild-plugin-solid": "^0.6.0",
"tsup": "^8.5.1"
},
"engines": {
"bun": ">=1.0.0"
Expand Down
81 changes: 81 additions & 0 deletions apps/opencode-plugin/session-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Session URL registry — a JSONL file under the Plannotator data dir.
* Each line is a JSON object: {"url":"http://...","sid":"<session_id>"}.
*
* The server plugin writes a line when a session is ready and removes it
* when the session ends. The TUI plugin polls this file and filters by
* the current opencode session_id to show only its own URL.
*/
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getPlannotatorDataDir } from "@plannotator/shared/data-dir";

const FILENAME = "opencode-session-url";

interface SessionEntry {
url: string;
sid?: string;
}

function getPath(): string {
return join(getPlannotatorDataDir(), FILENAME);
}

export function writeSessionUrl(url: string, sid?: string): void {
try {
const file = getPath();
mkdirSync(dirname(file), { recursive: true });
const entries = readEntries(file);
// Replace any existing entry with the same URL (update sid)
const filtered = entries.filter(e => e.url !== url);
writeFileSync(
file,
[...filtered, { url, sid }].map(e => JSON.stringify(e)).join("\n") + "\n",
{ encoding: "utf8" },
);
} catch {
// best-effort — the sidebar is cosmetic
}
}

export function clearSessionUrl(url?: string): void {
if (!url) return;
try {
const file = getPath();
if (!existsSync(file)) return;
const remaining = readEntries(file).filter(e => e.url !== url);
if (remaining.length > 0) {
writeFileSync(file, remaining.map(e => JSON.stringify(e)).join("\n") + "\n", { encoding: "utf8" });
} else {
rmSync(file, { force: true });
}
} catch {
// best-effort
}
}

export function readSessionUrl(sid?: string): string {
try {
const entries = readEntries(getPath());
// Filter by session_id when provided (multi-instance), else return all
const matching = sid
? entries.filter(e => e.sid === sid)
: entries;
return matching.map(e => e.url).join("\n");
} catch {
return "";
}
}

/** Parse the file as JSONL (one JSON object per line). */
function readEntries(file: string): SessionEntry[] {
if (!existsSync(file)) return [];
return readFileSync(file, "utf8")
.split(/\r?\n/)
.map(l => l.trim())
.filter(Boolean)
.map(l => {
try { return JSON.parse(l) as SessionEntry; } catch { return null; }
})
.filter((e): e is SessionEntry => e !== null);
}
15 changes: 15 additions & 0 deletions apps/opencode-plugin/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"noEmit": true
},
"include": ["*.ts", "*.tsx"],
"exclude": ["node_modules", "dist"]
}
Loading