Files
backnotprop__plannotator/packages/shared/annotate-args.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

106 lines
3.9 KiB
TypeScript

/**
* Parse CLI-style args arriving as a single whitespace-delimited string.
*
* Extracts the `--gate`, `--json`, and `--hook` flags from the remainder,
* which is treated as the target path. Leading `@` is
* stripped via the shared at-reference helper — reference-mode is primary.
* Scoped-package-style literal `@` paths are handled by a fallback that the
* downstream resolver opts into (see at-reference.ts).
*
* Used by the OpenCode plugin and Pi extension, where the whole args string
* arrives pre-joined from the harness slash-command dispatcher. The Claude
* Code binary parses argv directly with indexOf/splice and does not use
* this helper.
*
* Implementation: walks the raw string once, preserving whitespace runs and
* non-whitespace tokens as separate segments. Only known flag tokens
* (whole-word match) plus one adjacent whitespace run are removed.
* This keeps double-spaces and tabs inside file paths intact — which
* matches the pre-PR behavior on `main`, where OpenCode and Pi passed
* the raw args string straight through to the filesystem resolver.
*
* Remaining edge: if a path literally contains a known flag as a standalone
* whitespace-separated token (e.g. `"Feature --gate spec.md"`), that token
* is stripped. Supporting this would need shell-style quoting, which isn't
* worth the complexity for a vanishingly rare naming pattern.
*/
import { stripAtPrefix } from "./at-reference";
import { stripWrappingQuotes } from "./resolve-file";
export interface ParsedAnnotateArgs {
/**
* Primary resolution path with any leading `@` stripped (reference-mode
* convention). Most call sites should use this directly.
*/
filePath: string;
/**
* Raw path with the `@` prefix preserved (if the user supplied one).
* Callers that want the literal-`@` fallback for scoped-package-style
* paths pair this with `resolveAtReference` from at-reference.ts.
*/
rawFilePath: string;
gate: boolean;
json: boolean;
hook: boolean;
renderHtml: boolean;
}
type Segment = { type: "ws" | "tok"; text: string };
const FLAG_MAP = {
"--gate": "gate",
"--json": "json",
"--hook": "hook",
"--render-html": "renderHtml",
} as const satisfies Record<string, keyof Omit<ParsedAnnotateArgs, "filePath" | "rawFilePath">>;
export function parseAnnotateArgs(raw: string): ParsedAnnotateArgs {
const s = (raw ?? "").trim();
const flags = { gate: false, json: false, hook: false, renderHtml: false };
const segments: Segment[] = [];
for (let i = 0; i < s.length;) {
const isWs = /\s/.test(s[i]);
const start = i;
while (i < s.length && /\s/.test(s[i]) === isWs) i++;
segments.push({ type: isWs ? "ws" : "tok", text: s.slice(start, i) });
}
const keep = segments.map(() => true);
for (let j = 0; j < segments.length; j++) {
const seg = segments[j];
if (seg.type !== "tok") continue;
const key = FLAG_MAP[seg.text as keyof typeof FLAG_MAP];
if (!key) continue;
flags[key] = true;
keep[j] = false;
// Drop one adjacent whitespace run so removed flags don't leave dangling
// spaces. Prefer trailing whitespace; fall back to leading if at the end.
if (j + 1 < segments.length && segments[j + 1].type === "ws") {
keep[j + 1] = false;
} else if (j > 0 && segments[j - 1].type === "ws") {
keep[j - 1] = false;
}
}
// Trim covers the case where two adjacent flags (`... --gate --json`)
// both claim the single whitespace between them, leaving a trailing space
// after the kept token. Wrapping quotes come from OpenCode/Pi users who
// quote paths with spaces (shell muscle memory); strip them here so
// downstream callers never see tokenization artifacts.
const rawFilePath = stripWrappingQuotes(
segments
.filter((_, j) => keep[j])
.map((seg) => seg.text)
.join("")
.trim(),
);
if (flags.hook) flags.gate = true;
return { filePath: stripAtPrefix(rawFilePath), rawFilePath, ...flags };
}