Files
backnotprop__plannotator/apps/opencode-plugin/commands.ts
T
Graham Lipsman be2c81fa3b annotate-last: pick which message to annotate (fixes #800) (#809)
* feat: message picker for annotate-last (#800)

When running /plannotator-last after /rewind, the newest transcript
entry is no longer the message the user intended to annotate, and there
was no affordance to pick a different one.

Adds a picker UI that surfaces the recent assistant messages so the
user can choose which one to annotate:

- A "Message N of M" button in the Viewer's sticky-top action bar
  (alongside Copy / Global comment / Attachments), so it stays
  accessible while scrolling.
- A "Messages" tab in the left sidebar with the full list
  (newest-first, preview + timestamp, default ★), mirroring the
  existing Files / Versions / Archive tab pattern.

Wired for Claude Code, Codex, and Droid (all share apps/hook/server).
OpenCode, Pi, and Copilot still get the original single-message
behavior — they don't emit recentMessages, so the picker affordances
hide cleanly.

Default selection (index 0) matches today's "last message" behavior,
so users who don't interact with the picker see no change.

* feat: extend annotate-last picker to Copilot and OpenCode

The picker UI from #800 was wired for Claude / Codex / Droid only. Pull
Copilot and OpenCode onto the same shape so users on those harnesses
also get the recent-messages picker when annotating the last assistant
message.

- Copilot: replace getLastCopilotMessage with getRecentCopilotMessages,
  walking events.jsonl newest-first up to 25 assistant.message events.
- OpenCode: rewrite the session walk to collect up to 25 messages
  (newest first) instead of bailing on the first hit; normalize the SDK
  time.created (ms epoch) to ISO to match the shared picker contract.
- Both pass recentMessages to startAnnotateServer only when length > 1,
  matching the existing Claude/Codex/Droid behavior.

Also trims a leftover narrating comment in MessagesBrowser and refreshes
the stale Copilot session-parser header.

Pi parity follows in the next commit (needs round-trip of the picker
selection through /api/feedback so its post-submit anchoring quotes the
right message).

* feat(pi): wire annotate-last picker with feedback round-trip

Extends the picker UI (#800) to Pi and fixes a Pi-specific anchoring bug
the picker would otherwise introduce.

Picker plumbing
- assistant-message: getRecentAssistantMessages walks the active branch
  newest-first, returning { messageId, text, timestamp? } in the same
  shape the other harnesses produce.
- Plumbed through plannotator-browser / plannotator-events so the Bun
  server's recentMessages option is populated when the branch has more
  than one assistant message.

Anchoring fix
- Pi quotes the targeted assistant message back to the agent because its
  UX is async — the conversation may have moved on by feedback time.
  With the picker, that target is no longer guaranteed to be the
  snapshot taken when the UI opened. The editor now sends the user's
  selectedMessageId with /api/feedback; Pi looks it up in the current
  branch via findAssistantMessageByEntryId and quotes that message
  instead. Falls back to the original snapshot if the entry is gone.
- The round-trip field is optional and only meaningful in annotate-last
  mode; other harnesses (and other modes) ignore it.

Timestamp safety
- Pi's SDK currently types SessionEntryBase.timestamp as string, but the
  picker contract everywhere else is ISO. Treat the value as unknown and
  normalize string/number(ms)/Date to ISO; drop anything else, rather
  than blind-casting and risking silent drift if the SDK changes.

* chore: strip issue-number references from comments

Comments shouldn't rely on external references — issue numbers age out
of context, link rot is a thing, and a reader shouldn't need to open
GitHub to understand why a line exists. Strip the `(#800)` and `(#570)`
parentheticals from comments and doc strings across the picker and
review-gate code; the surrounding "why" content is preserved.

* fix: prevent removeChild crash when switching annotate-last messages

Switching the picked message remounted nothing, so React reconciled new
content against DOM that web-highlighter had mutated with <mark> nodes,
throwing removeChild. Drive the Viewer key (and StickyHeaderLane's
remount token) off a shared viewerContentKey so a message switch fully
remounts the Viewer and re-anchors the sticky-header observer.

Also cap MessagesBrowser row previews via previewText() and drop the
redundant 'block' class that was overriding line-clamp-2.

* feat: persist annotate-last feedback across messages

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-06-03 13:01:36 -07:00

457 lines
15 KiB
TypeScript

/**
* Command Handlers for OpenCode Plugin
*
* Handles /plannotator-review, /plannotator-annotate, /plannotator-last,
* and /plannotator-archive slash commands. Extracted from the event hook
* for modularity.
*/
import {
startPlannotatorServer,
handleServerReady,
} from "@plannotator/server";
import {
startReviewServer,
handleReviewServerReady,
} from "@plannotator/server/review";
import {
startAnnotateServer,
handleAnnotateServerReady,
} from "@plannotator/server/annotate";
import { type DiffType, prepareLocalReviewDiff } from "@plannotator/server/vcs";
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
import {
getReviewApprovedPrompt,
getReviewDeniedSuffix,
getAnnotateFileFeedbackPrompt,
} from "@plannotator/shared/prompts";
import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file";
import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
import { parseAnnotateArgs } from "@plannotator/shared/annotate-args";
import { parseReviewArgs } from "@plannotator/shared/review-args";
import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown";
import { statSync } from "fs";
import path from "path";
/** Shared dependencies injected by the plugin */
export interface CommandDeps {
client: any;
htmlContent: string;
reviewHtmlContent: string;
getSharingEnabled: () => Promise<boolean>;
getShareBaseUrl: () => string | undefined;
getPasteApiUrl: () => string | undefined;
directory?: string;
}
export async function handleReviewCommand(
event: any,
deps: CommandDeps
) {
const { client, reviewHtmlContent, getSharingEnabled, getShareBaseUrl, directory } = deps;
// @ts-ignore - Event properties contain arguments
const reviewArgs = parseReviewArgs(event.properties?.arguments || "");
const urlArg = reviewArgs.prUrl;
const isPRMode = urlArg !== undefined;
let rawPatch: string;
let gitRef: string;
let diffError: string | undefined;
let userDiffType: DiffType | undefined;
let gitContext: Awaited<ReturnType<typeof prepareLocalReviewDiff>>["gitContext"] | undefined;
let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
if (isPRMode) {
const prRef = parsePRUrl(urlArg);
if (!prRef) {
client.app.log({ level: "error", message: `Invalid PR/MR URL: ${urlArg}` });
return;
}
client.app.log({ level: "info", message: `Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...` });
try {
await checkPRAuth(prRef);
} catch (err) {
const cliName = getCliName(prRef);
client.app.log({ level: "error", message: err instanceof Error ? err.message : `${cliName} auth check failed` });
return;
}
try {
const pr = await fetchPR(prRef);
rawPatch = pr.rawPatch;
gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`;
prMetadata = pr.metadata;
} catch (err) {
client.app.log({ level: "error", message: err instanceof Error ? err.message : `Failed to fetch ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}` });
return;
}
} else {
client.app.log({ level: "info", message: "Opening code review UI..." });
const config = loadConfig();
const diffResult = await prepareLocalReviewDiff({
cwd: directory,
vcsType: reviewArgs.vcsType,
configuredDiffType: resolveDefaultDiffType(config),
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
});
gitContext = diffResult.gitContext;
userDiffType = diffResult.diffType;
rawPatch = diffResult.rawPatch;
gitRef = diffResult.gitRef;
diffError = diffResult.error;
}
const server = await startReviewServer({
rawPatch,
gitRef,
error: diffError,
origin: "opencode",
diffType: isPRMode ? undefined : userDiffType,
gitContext,
prMetadata,
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
htmlContent: reviewHtmlContent,
opencodeClient: client,
onReady: (url, isRemote, port) => {
handleReviewServerReady(url, isRemote, port);
if (isRemote) {
client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` });
}
},
});
const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
if (result.exit) {
return;
}
if (result.feedback) {
// @ts-ignore - Event properties contain sessionID
const sessionId = event.properties?.sessionID;
if (sessionId) {
const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== "disabled";
const targetAgent = result.agentSwitch || "build";
const message = result.approved
? getReviewApprovedPrompt("opencode")
: isPRMode
? result.feedback
: `${result.feedback}${getReviewDeniedSuffix("opencode")}`;
try {
await client.session.prompt({
path: { id: sessionId },
body: {
...(shouldSwitchAgent && { agent: targetAgent }),
parts: [{ type: "text", text: message }],
},
});
} catch {
// Session may not be available
}
}
}
}
export async function handleAnnotateCommand(
event: any,
deps: CommandDeps
) {
const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl, directory } = deps;
// @ts-ignore - Event properties contain arguments
const rawArgs = event.properties?.arguments || event.arguments || "";
// Split --gate / --json out of the args; rest is the file path.
// --json is accepted silently (OpenCode writes to session, not stdout).
// parseAnnotateArgs strips leading @ on filePath (reference-mode convention).
// `rawFilePath` preserves it for the scoped-package markdown fallback.
const { filePath, rawFilePath, gate, renderHtml: renderHtmlFlag } = parseAnnotateArgs(rawArgs);
if (!filePath) {
client.app.log({ level: "error", message: "Usage: /plannotator-annotate <file.md | file.html | https://... | folder/> [--gate] [--json]" });
return;
}
let markdown: string;
let rawHtml: string | undefined;
let absolutePath: string;
let folderPath: string | undefined;
let annotateMode: "annotate" | "annotate-folder" = "annotate";
let isFolder = false;
let sourceInfo: string | undefined;
let sourceConverted = false;
// --- URL annotation ---
const isUrl = /^https?:\/\//i.test(filePath);
if (isUrl) {
const useJina = resolveUseJina(false, loadConfig());
client.app.log({ level: "info", message: `Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...` });
try {
const result = await urlToMarkdown(filePath, { useJina });
markdown = result.markdown;
sourceConverted = isConvertedSource(result.source);
} catch (err) {
client.app.log({ level: "error", message: `Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}` });
return;
}
absolutePath = filePath;
sourceInfo = filePath;
} else {
const projectRoot = directory || process.cwd();
const resolvedArg = resolveUserPath(filePath, projectRoot);
try {
isFolder = statSync(resolvedArg).isDirectory();
} catch {
// Not a directory, fall through to file resolution.
}
if (isFolder) {
if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) {
client.app.log({ level: "error", message: `No markdown or HTML files found in ${resolvedArg}` });
return;
}
folderPath = resolvedArg;
absolutePath = resolvedArg;
markdown = "";
annotateMode = "annotate-folder";
client.app.log({ level: "info", message: `Opening annotation UI for folder ${resolvedArg}...` });
} else if (/\.html?$/i.test(resolvedArg)) {
let fileSize: number;
try {
fileSize = statSync(resolvedArg).size;
} catch {
client.app.log({ level: "error", message: `File not found: ${filePath}` });
return;
}
if (fileSize > 10 * 1024 * 1024) {
client.app.log({ level: "error", message: `File too large (${Math.round(fileSize / 1024 / 1024)}MB, max 10MB)` });
return;
}
const html = await Bun.file(resolvedArg).text();
if (renderHtmlFlag) {
rawHtml = html;
markdown = "";
} else {
markdown = htmlToMarkdown(html);
sourceConverted = true;
}
absolutePath = resolvedArg;
sourceInfo = path.basename(resolvedArg);
client.app.log({ level: "info", message: `${renderHtmlFlag ? "Raw HTML" : "Converted"}: ${absolutePath}` });
} else {
// Markdown file annotation
client.app.log({ level: "info", message: `Opening annotation UI for ${filePath}...` });
// Strip-first with literal-@ fallback (scoped-package-style names).
let resolved = await resolveMarkdownFile(filePath, projectRoot);
if (resolved.kind === "not_found" && rawFilePath !== filePath) {
resolved = await resolveMarkdownFile(rawFilePath, projectRoot);
}
if (resolved.kind === "ambiguous") {
client.app.log({
level: "error",
message: `Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:\n${resolved.matches.map((m) => ` ${m}`).join("\n")}`,
});
return;
}
if (resolved.kind === "not_found") {
client.app.log({ level: "error", message: `File not found: ${resolved.input}` });
return;
}
absolutePath = resolved.path;
client.app.log({ level: "info", message: `Resolved: ${absolutePath}` });
markdown = await Bun.file(absolutePath).text();
}
}
const server = await startAnnotateServer({
markdown,
filePath: absolutePath,
origin: "opencode",
mode: annotateMode,
folderPath,
sourceInfo,
sourceConverted,
rawHtml,
renderHtml: renderHtmlFlag,
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
pasteApiUrl: getPasteApiUrl(),
gate,
htmlContent,
onReady: (url, isRemote, port) => {
handleAnnotateServerReady(url, isRemote, port);
if (isRemote) {
client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` });
}
},
});
const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
// Both exit and approve are "no-op for the agent" — skip session injection.
if (result.exit || result.approved) {
return;
}
if (result.feedback) {
// @ts-ignore - Event properties contain sessionID
const sessionId = event.properties?.sessionID;
if (sessionId) {
try {
await client.session.prompt({
path: { id: sessionId },
body: {
parts: [{
type: "text",
text: getAnnotateFileFeedbackPrompt("opencode", undefined, {
fileHeader: isFolder ? "Folder" : "File",
filePath: absolutePath,
feedback: result.feedback,
}),
}],
},
});
} catch {
// Session may not be available
}
}
}
}
/**
* Handle /plannotator-last command.
* Called from command.execute.before — returns the feedback string
* so the caller can set it as output.parts for the agent to see.
*/
export async function handleAnnotateLastCommand(
event: any,
deps: CommandDeps
): Promise<string | null> {
const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps;
// @ts-ignore - Event properties contain arguments
const rawArgs = event.properties?.arguments || event.arguments || "";
// Support --gate on /plannotator-last (Stop-hook review-gate pattern).
const { gate } = parseAnnotateArgs(rawArgs);
// @ts-ignore - Event properties contain sessionID
const sessionId = event.properties?.sessionID;
if (!sessionId) {
client.app.log({ level: "error", message: "No active session." });
return null;
}
// Fetch messages from session
const messagesResponse = await client.session.messages({
path: { id: sessionId },
});
const messages = messagesResponse.data;
const RECENT_LIMIT = 25;
const recentMessages: { messageId: string; text: string; timestamp?: string }[] = [];
if (messages) {
for (let i = messages.length - 1; i >= 0 && recentMessages.length < RECENT_LIMIT; i--) {
const msg = messages[i];
if (msg.info.role !== "assistant") continue;
const textParts = msg.parts
.filter((p: any) => p.type === "text" && p.text?.trim())
.map((p: any) => p.text);
if (textParts.length === 0) continue;
recentMessages.push({
messageId: msg.info.id ?? `opencode-${i}`,
text: textParts.join("\n"),
timestamp: msg.info.time?.created ? new Date(msg.info.time.created).toISOString() : undefined,
});
}
}
const lastText = recentMessages[0]?.text ?? null;
if (!lastText) {
client.app.log({ level: "error", message: "No assistant message found in session." });
return null;
}
client.app.log({ level: "info", message: "Opening annotation UI for last message..." });
const pickerMessages = recentMessages.length > 1 ? recentMessages : undefined;
const server = await startAnnotateServer({
markdown: lastText,
filePath: "last-message",
origin: "opencode",
mode: "annotate-last",
recentMessages: pickerMessages,
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
pasteApiUrl: getPasteApiUrl(),
gate,
htmlContent,
onReady: (url, isRemote, port) => {
handleAnnotateServerReady(url, isRemote, port);
if (isRemote) {
client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` });
}
},
});
const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
// Both exit and approve signal "don't inject feedback" — return null.
if (result.exit || result.approved) {
return null;
}
return result.feedback || null;
}
export async function handleArchiveCommand(
event: any,
deps: CommandDeps
) {
const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps;
client.app.log({ level: "info", message: "Opening plan archive..." });
const server = await startPlannotatorServer({
plan: "",
origin: "opencode",
mode: "archive",
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
pasteApiUrl: getPasteApiUrl(),
htmlContent,
onReady: (url, isRemote, port) => {
handleServerReady(url, isRemote, port);
if (isRemote) {
client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` });
}
},
});
if (server.waitForDone) {
await server.waitForDone();
}
await Bun.sleep(1500);
server.stop();
}