diff --git a/README.md b/README.md index ce5a2b0..d20463c 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,12 @@ all-or-nothing setup: install the packages that fit your workflow. | [@iurysza/artifact-explorer](packages/artifact-explorer) | Open each repository's `ai-artifacts/` directory as an isolated Obsidian vault. | | [@iurysza/pi-ghost-in-the-machine](packages/pi-ghost-in-the-machine) | Reactive Ghostty shader face for Pi lifecycle states. | | [@iurysza/pi-token-tank](packages/pi-token-tank) | Subscription quota gauges for OpenAI Codex, Kimi, GitHub Copilot, and Cursor. | +| [@iurysza/pi-cache-hit-predictor](packages/pi-cache-hit-predictor) | Predict reusable prompt-cache prefixes across model and reasoning lanes. | +| [@iurysza/pi-cursor-sdk](packages/pi-cursor-sdk) | Run Cursor SDK models in Pi with fail-closed recorded tool replay. | | [@iurysza/pi-agent-explorer](packages/pi-agent-explorer) | Read-only Neovim snapshot of Pi's loaded runtime. | | [@iurysza/pi-context-audit](packages/pi-context-audit) | Inspect prompt, tool-schema, context, and MCP overhead. | | [@iurysza/pi-secret-env](packages/pi-secret-env) | Load shared credentials while blocking and redacting secret access. | +| [@iurysza/pi-wtf](packages/pi-wtf) | Append timestamped friction notes to a repository-local WTF log. | ## Development diff --git a/packages/pi-cursor-sdk/README.md b/packages/pi-cursor-sdk/README.md index 5221501..9f9bc4a 100644 --- a/packages/pi-cursor-sdk/README.md +++ b/packages/pi-cursor-sdk/README.md @@ -1,4 +1,4 @@ -# pi-cursor-sdk +# @iurysza/pi-cursor-sdk A pi provider extension that lets pi use Cursor models through the local-by-default `@cursor/sdk` agent runtime, with explicit minimal Cursor Cloud opt-in. @@ -6,21 +6,21 @@ Use this extension if you primarily use Cursor models inside pi and want Cursor' ## Why use this instead of an OpenAI-compatible Cursor endpoint? -Use `pi-cursor-sdk` when you primarily want to use Cursor models **inside pi**. +Use `@iurysza/pi-cursor-sdk` when you primarily want to use Cursor models **inside pi**. This extension runs Cursor models through `@cursor/sdk` and keeps Cursor's agent loop intact. Local remains the default; explicit cloud runtime starts Cursor Cloud after acknowledgement and preflight. pi integrates around that loop: model discovery, model selection, context-window variants, thinking controls where Cursor exposes them, fast/slow aliases, Cursor mode, session handling, native replay cards, and the optional local pi tool bridge. OpenAI-compatible Cursor proxies are useful when you want a generic `/v1/chat/completions` or `/v1/responses` endpoint for many clients such as curl, the OpenAI SDK, OpenCode, or other tools. That compatibility comes from translating Cursor behavior into OpenAI-shaped requests, responses, and tool calls. -For pi users, that translation is usually the wrong abstraction. `pi-cursor-sdk` is pi-specific on purpose: it lets Cursor remain Cursor while making it feel native in pi. +For pi users, that translation is usually the wrong abstraction. `@iurysza/pi-cursor-sdk` is pi-specific on purpose: it lets Cursor remain Cursor while making it feel native in pi. | If you want... | Prefer | | --- | --- | -| First-class Cursor usage inside pi | `pi-cursor-sdk` | -| Cursor's local SDK agent loop preserved, not replaced by an OpenAI-shaped adapter | `pi-cursor-sdk` | -| pi model picker, `/login`, `/model`, sessions, context display, footer/status UX | `pi-cursor-sdk` | -| Cursor SDK local-agent tools, settings, MCP, and native replay surfaced in pi | `pi-cursor-sdk` | -| pi extension tools exposed to Cursor through a local MCP bridge | `pi-cursor-sdk` | +| First-class Cursor usage inside pi | `@iurysza/pi-cursor-sdk` | +| Cursor's local SDK agent loop preserved, not replaced by an OpenAI-shaped adapter | `@iurysza/pi-cursor-sdk` | +| pi model picker, `/login`, `/model`, sessions, context display, footer/status UX | `@iurysza/pi-cursor-sdk` | +| Cursor SDK local-agent tools, settings, MCP, and native replay surfaced in pi | `@iurysza/pi-cursor-sdk` | +| pi extension tools exposed to Cursor through a local MCP bridge | `@iurysza/pi-cursor-sdk` | | A generic OpenAI-compatible localhost `/v1` API for non-pi clients | An OpenAI-compatible Cursor proxy | | One Cursor-ish endpoint shared across several unrelated tools | An OpenAI-compatible Cursor proxy | @@ -29,14 +29,10 @@ For pi users, that translation is usually the wrong abstraction. `pi-cursor-sdk` 1. Install the package: ```bash -pi install npm:pi-cursor-sdk +pi install npm:@iurysza/pi-cursor-sdk ``` -Or install from GitHub: - -```bash -pi install https://github.com/fitchmultz/pi-cursor-sdk -``` +Source and preserved upstream history live in [`iurysza/pi-extensions`](https://github.com/iurysza/pi-extensions/tree/main/packages/pi-cursor-sdk). 2. Start pi with a Cursor model: @@ -61,13 +57,7 @@ No global `@cursor/sdk` install is required. This package depends on exact `@cur ### Global install ```bash -pi install npm:pi-cursor-sdk -``` - -Alternative GitHub install: - -```bash -pi install https://github.com/fitchmultz/pi-cursor-sdk +pi install npm:@iurysza/pi-cursor-sdk ``` ### Project-local install @@ -75,7 +65,7 @@ pi install https://github.com/fitchmultz/pi-cursor-sdk Use `-l` if you want the package recorded in the current project's `.pi/settings.json` instead of your global pi settings: ```bash -pi install -l npm:pi-cursor-sdk +pi install -l npm:@iurysza/pi-cursor-sdk ``` Pi 0.80.9 loads project-local extensions only after project trust is resolved, so this extension cannot observe that trust event. When a project-local install needs to read or write `.pi/cursor-sdk.json`, start every such run with explicit approval: @@ -97,7 +87,7 @@ pi --approve -e . --model cursor/composer-2-5 ## Configure your Cursor SDK API key -`pi-cursor-sdk` passes an explicit API key to the Cursor SDK. It does **not** reuse Cursor Agent CLI login, Cursor Desktop login, or Cursor subscription/OAuth state shown by `agent status`. +`@iurysza/pi-cursor-sdk` passes an explicit API key to the Cursor SDK. It does **not** reuse Cursor Agent CLI login, Cursor Desktop login, or Cursor subscription/OAuth state shown by `agent status`. Use either a user API key from Cursor Dashboard → API Keys or a service account API key from Team settings. Team Admin API keys are not supported by the Cursor SDK. Then configure the key with one of the methods below. @@ -456,7 +446,7 @@ Bridge capabilities are snapshotted from `pi.getActiveTools()` and `pi.getAllToo Overlapping built-in pi tools (`read`, `bash`, `write`, `edit`, `grep`, `find`, `ls`) are hidden by default because Cursor local agents already have native equivalents. Extension/custom tools and non-overlapping active tools present in pi's active tool registry normally remain exposed. The bridge also exposes `cursor_ask_question` as `pi__cursor_ask_question` when enabled, allowing Cursor to ask the user through pi UI instead of silently choosing a default. For local runtime, when pi has visible Agent Skills loaded, the extension rewrites pi's skill catalog for Cursor and exposes `cursor_activate_skill` as `pi__cursor_activate_skill`; Cursor should call that bridge tool with a listed skill name to load the full `SKILL.md` and bundled resource list before applying the skill. If the local bridge is disabled, the catalog remains available and instructs Cursor to fall back to reading the listed `SKILL.md` path directly. Cloud runtime preserves Pi project instructions but omits Pi's local skill catalog and keeps `cursor_activate_skill` inactive because the bridge and local absolute skill paths are unavailable there. -Cursor-native tool replay is separate from the bridge. Replay cards are display-only recorded Cursor SDK activity. They never re-run Cursor-side commands, reapply Cursor edits, call MCP servers, or mutate pi state. See [Cursor native tool replay](docs/cursor-native-tool-replay.md). +Cursor-native tool replay is separate from the bridge. Replay cards are display-only recorded Cursor SDK activity. With `@iurysza/pi-ext` installed and compact tools enabled, built-in-equivalent replay (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) is consumed once by pi-ext's shared compact cards. Missing replay state fails closed before any built-in executor runs. Cursor-only MCP, plan, task, search, web, image, and unknown activity remains visible as bounded neutral one-line cards. See [Cursor native tool replay](docs/cursor-native-tool-replay.md). Bridge controls: @@ -527,7 +517,7 @@ Actual Cursor runs still need a key from `/login`, `CURSOR_API_KEY`, or `--api-k - **Cloud runtime is explicit and minimal.** Local remains the default. Cloud runs create Cursor cloud agents only after first-use acknowledgement and safety preflight, use fresh context by default, do not expose the pi bridge or local MCP, do not forward pi env vars, support explicit Cursor-managed environment selection, name agents from the pi session title when available, stream display-only agent/run/branch/PR/artifact/raw-usage telemetry when available, and record only explicit session-branch lifecycle commands for cleanup (`/cursor-cloud list|archive|delete`). - **The pi tool bridge is local and MCP-backed.** Bridgeable active pi tools are exposed to local Cursor agents through a tokenized `127.0.0.1` MCP endpoint; internal Cursor replay activity names are excluded, and overlapping built-in pi tools are hidden by default. Set `PI_CURSOR_PI_TOOL_BRIDGE=0` to disable it or `PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1` to expose overlapping built-ins too. -- **Cursor native tool replay is display-only.** Replay renders recorded Cursor SDK activity and never re-runs Cursor-side commands, reapplies Cursor edits, calls MCP servers, or mutates pi state. Workflow tools such as Cursor mode/task/todo/plan activity are not pi workflow controls. See [Cursor native tool replay](docs/cursor-native-tool-replay.md) for supported replay cards, ordering, conflict handling, and opt-out flags. +- **Cursor native tool replay is display-only and fail-closed.** Replay renders recorded Cursor SDK activity and never re-runs Cursor-side commands, reapplies Cursor edits, calls MCP servers, or mutates pi state. Every `cursor-replay-*` built-in call requires one matching recorded result; missing or already-consumed state errors before native execution. `@iurysza/pi-ext` can own the shared compact built-in cards, while Cursor-only activity stays one bounded neutral line. Workflow tools such as Cursor mode/task/todo/plan activity are not pi workflow controls. See [Cursor native tool replay](docs/cursor-native-tool-replay.md) for supported replay cards, ordering, conflict handling, and opt-out flags. - **Cursor run state can span tool-use turns.** Within a pi session, the extension reuses one Cursor SDK agent across compatible follow-up turns and sends incremental prompts when context still matches. It recreates the agent when context diverges, after compaction or `/tree` navigation, on API key changes, after send errors, or on session shutdown. For bridged pi tools, the matching pi `toolResult` resolves into the same live Cursor SDK run without creating a new `Agent`, unless the run was disposed, aborted, or cancelled. Replay can also split one live Cursor SDK run across pi `toolUse` turns for display. - **Final assistant text is the last non-empty text part.** Composer responses can produce one assistant message with early progress `text`, thinking/tool metadata, and a later final `text` report. Consumers that need a final answer should scan assistant message content from the end and use the last non-empty `text` part, not the first. Cursor `thinking` deltas are shown as thinking traces when the SDK emits them; those traces can include draft answers or copied exact-output targets and are intentionally not collapsed by this extension. - **Cursor setting sources default to all.** The extension passes `local.settingSources: ["all"]` by default so configured Cursor MCP servers, plugin tools, project/user settings, and related Cursor-native capabilities are available like they are in Cursor. To narrow loading, set a comma-separated list such as `PI_CURSOR_SETTING_SOURCES=project,user,plugins`. To disable ambient setting sources, set `PI_CURSOR_SETTING_SOURCES=none`. Direct Cursor SDK bootstrap logs (settings, skills, hook-load compatibility warnings, and similar) are suppressed so they do not pollute the TUI. @@ -572,7 +562,7 @@ pi list Then reinstall if needed: ```bash -pi install npm:pi-cursor-sdk +pi install npm:@iurysza/pi-cursor-sdk ``` ### `pi --list-models` shows `thinking=no` @@ -601,7 +591,7 @@ Known SDK boundary: Cursor SDK `task` activity is shown as **Cursor subagent** b Many runs never expose web activity as replayable SDK tool completions or local transcript web tool records. The model may still answer from internal Cursor web tooling or only mention search in assistant text/thinking. In that case pi cannot render a tool card because there is no completed SDK tool-call payload to replay. Capture a run with `npm run debug:provider-events` when investigating; if `on-delta.jsonl`, `on-step.jsonl`, `stream-events.jsonl`, `coordinator-events.jsonl`, and `display-decisions.jsonl` have no completed or transcript web tool data, the limitation is on the Cursor SDK surface, not pi replay registration. -**Web fetch:** `pi-cursor-sdk` can display `webFetchToolCall` transcript records and web-fetch-shaped MCP/host completions when Cursor reports them. It cannot make Cursor expose or execute a `WebFetch` tool. If Cursor's current local SDK tool set does not include WebFetch, pi cannot fetch a URL through Cursor web fetch; use an allowed browser/shell/MCP tool instead. +**Web fetch:** `@iurysza/pi-cursor-sdk` can display `webFetchToolCall` transcript records and web-fetch-shaped MCP/host completions when Cursor reports them. It cannot make Cursor expose or execute a `WebFetch` tool. If Cursor's current local SDK tool set does not include WebFetch, pi cannot fetch a URL through Cursor web fetch; use an allowed browser/shell/MCP tool instead. ### I disabled MCP in pi but Cursor still has extra tools diff --git a/packages/pi-cursor-sdk/docs/cursor-native-tool-replay.md b/packages/pi-cursor-sdk/docs/cursor-native-tool-replay.md index cf0f32b..eae2552 100644 --- a/packages/pi-cursor-sdk/docs/cursor-native-tool-replay.md +++ b/packages/pi-cursor-sdk/docs/cursor-native-tool-replay.md @@ -2,7 +2,7 @@ User-facing overview of callable vs display-only tools: [Cursor tool surfaces in pi](./cursor-tool-surfaces.md). -pi-cursor-sdk has two separate pi-facing paths plus Cursor's own local-agent tool surface: +`@iurysza/pi-cursor-sdk` has two separate pi-facing paths plus Cursor's own local-agent tool surface: 1. **Local pi MCP bridge:** default-on for local Cursor agents. It exposes the current pi session's bridgeable active tools to Cursor through a tokenized `127.0.0.1` MCP endpoint, excluding internal Cursor replay activity names and, by default, overlapping built-in pi tools (`read`, `bash`, `write`, `edit`, `grep`, `find`, `ls`). When Cursor calls one of those MCP tools, pi executes the real pi tool through the normal pi tool path. 2. **Cursor native tool replay:** display-only. It renders completed Cursor SDK tool activity as pi-native-looking cards using recorded Cursor results. @@ -66,7 +66,9 @@ Cursor `glob` activity is displayed through native `find` cards. For the full `@cursor/sdk@1.0.23` `ToolType` set, disposition matrix, and runtime alias normalization, see [SDK ToolType replay matrix](#sdk-tooltype-replay-matrix) below. Official SDK reference: https://cursor.com/docs/sdk/typescript -Edit and write activity replays through pi-facing `edit` and `write` cards only when replay arguments truthfully satisfy the matching pi schema, but still uses recorded Cursor results only. The adapter passes through truthful Cursor paths, content when Cursor reported it, and recorded diff/details; it does not pretend Cursor's editing schema is pi's schema and it fails closed if a recorded replay result is missing. Cursor `StrReplace` with recorded replacement text displays as native-looking `edit`; path-only Cursor `edit` and notebook edit activity fall back to neutral Cursor activity so pi does not reject the replay before recorded-result handling. Cursor `write` displays as native-looking `write`. Diagnostics, delete, todos/plans, task/subagent, image, MCP, semantic search, screen recording, and web search/fetch activity use neutral Cursor activity cards with pi's default success/error tool shell. Cursor SDK `task` activity is labeled **Cursor subagent** by default because it represents Cursor-spawned child-agent work; `PI_CURSOR_TASK_PRESENTATION=task` keeps the older **Cursor task** wording for comparison. MCP completions whose `toolName` is `WebSearch` / `web_search` / `WebFetch` / similar are labeled **Cursor web search** or **Cursor web fetch** instead of generic **Cursor MCP**. Neutral Cursor activity cards carry display metadata such as `activityTitle` and `activitySummary`, so partial/collapsed cards can say `Cursor plan`, `Cursor todos`, `Cursor subagent`, `Cursor MCP`, `Cursor semantic search`, `Cursor screen recording`, `Cursor web search`, `Cursor web fetch`, or `Cursor edit` instead of only `Cursor activity`. These replay tools only display recorded Cursor results; they never mutate files or execute tool work directly. Replay paths are normalized to workspace-relative paths when possible. Most collapsed replay cards include bounded previews for diffs and text details so small edits, todos, task output, and MCP results are visible without expanding; web search/fetch activity stays summary-only while collapsed because those cards often arrive after final text and can otherwise bury the answer. Ctrl+O expansion shows the recorded details. Edit previews omit raw unified diff headers and show compact numbered changed/context lines using pi's native diff added/removed/context colors, and write previews use syntax highlighting when pi can infer a language from the path. Image generation replay cards show the saved image path in the collapsed summary and render the image inline when pi terminal image display is enabled and the generated file is still readable. +Built-in-equivalent activity replays through `read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls` only when replay arguments truthfully satisfy the matching pi schema. With `@iurysza/pi-ext` installed and compact tools enabled, pi-ext owns those cards and consumes the recorded Cursor result through a versioned callback protocol. Without that host, this package can register its standalone wrappers. Both paths are one-shot and fail closed: every `cursor-replay-*` built-in call requires one matching recorded result, and missing, malformed, mismatched, or already-consumed state throws before the underlying tool executor can run. + +Diagnostics, delete, todos/plans, task/subagent, image, MCP, semantic search, screen recording, web search/fetch, and future unknown activity use the neutral `cursor` replay tool. These cards are always one bounded summary line, including when the transcript is expanded; they do not render detail previews or inline images. Cursor SDK `task` activity is labeled **Cursor subagent** by default. MCP completions with web-shaped names become **Cursor web search** or **Cursor web fetch**. Replay paths are normalized to workspace-relative paths when possible. ## SDK ToolType replay matrix @@ -74,7 +76,7 @@ Source of truth for SDK tool names: `@cursor/sdk@1.0.23` conversation `ToolType` Implementation owners: `src/cursor-tool-presentation-registry.ts` (canonical names, labels, visibility, replay policy, bridge exclusions for internal replay wrappers, alias normalization, and display-spec key completeness), `src/cursor-transcript-tool-specs.ts` (registry-keyed display implementations for transcript formatting and pi display builders), `src/cursor-native-tool-display-replay.ts` (replay card rendering derived from registry replay metadata), and `src/cursor-web-tool-activity.ts` (MCP/web alias remapping before display lookup). -**Maintainer invariants — edit/write replay previews:** All colored diff rendering (native `edit` cards and `Cursor edit` activity fallbacks) flows through the single `formatCursorReplayDiff()` in `src/cursor-native-tool-display-replay.ts`. Activity write fallbacks with structured `fileContentAfterWrite` use the same `formatCursorReplayFilePreview()` path as native `write` cards. Structured `diffString` (and `diff`/`lines*`) or `fileContentAfterWrite` on `CursorReplay*Details` (including activity variants) is the source of truth for TUI preview coloring/highlighting. `expandedText` on activity details is for summary/expansion and as a fallback when the current SDK reports a unified diff only in text; it is never the primary preview source when structured fields are present. No parallel +/- coloring loops exist. +**Maintainer invariant — execution ownership:** Built-in replay result consumption must happen before any native executor. The pi-ext host advertises capability only after Tidy successfully owns the built-in tools. The Cursor producer deletes recorded state before callback delivery; retries and duplicate consumption therefore fail closed. Standalone Cursor wrappers enforce the same rule. Neutral `cursor` cards never execute work. This matrix covers **Cursor native tool replay only**. It does not describe the [live pi MCP bridge](#live-bridge-vs-replay) or Cursor-native host tools, settings, plugins, and configured MCP servers from the Cursor SDK local-agent path. @@ -104,7 +106,7 @@ This matrix covers **Cursor native tool replay only**. It does not describe the **Replay detail disposition model:** `src/cursor-replay-tool-details.ts` stores replay card disposition separately from SDK source tool identity. Variants are `nativeEdit`, `nativeWrite`, `activity` (`sourceToolName` + display `title`), `generateImage`, and `genericFallback`. Path-only or notebook edit/write fallbacks produce `activity` details (neutral `cursor` cards) instead of structured edit/write variants with optional `title` escape hatches. Native edit/write cards use `nativeEdit` / `nativeWrite` only when pi-facing replay args satisfy the matching schema. The renderer dispatches on `variant` only. -Neutral activity rows use pi tool name `cursor` with `activityTitle` / `activitySummary` metadata. User-visible collapsed cards use labels like **Cursor semantic search**. +Neutral activity rows use pi tool name `cursor` with `activityTitle` / `activitySummary` metadata. User-visible cards use labels like **Cursor semantic search** and remain one bounded line in collapsed and expanded views. ## Runtime alias normalization @@ -192,13 +194,13 @@ JSON and RPC consumers receive structured replay for completed Cursor host tools ## Replay-name policy -Cursor native replay has one neutral replay tool name, `cursor`, plus native-compatible card names when renderer-compatible: `read`, `bash`, `grep`, `find`, `ls`, `edit`, and `write`. Neutral replay identity lives in `activityTitle`, `activitySummary`, and typed replay details, not in extra registered tool names. +Cursor native replay has one neutral replay tool name, `cursor`, plus built-in-compatible names when schema-compatible: `read`, `bash`, `grep`, `find`, `ls`, `edit`, and `write`. Neutral replay identity lives in `activityTitle`, `activitySummary`, and typed replay details, not in extra registered tool names. Built-in names may be rendered by this package's standalone wrappers or by the shared `@iurysza/pi-ext` owner; recorded execution semantics are identical. Bridge MCP names are also not pi tool names. Cursor may see names such as `pi__sem_reindex` inside the local MCP bridge, but pi session output uses the real pi tool name. ## Conflicts and opt out -Native replay wrappers are registered only for tool names not already owned by another extension. If another extension already owns a wrapper name needed for replay, pi-cursor-sdk skips only the conflicting wrapper and uses the scrubbed Cursor activity transcript for that tool instead. +Native replay wrappers are registered only for tool names not already owned by another extension. `@iurysza/pi-ext` is the cooperative exception: a load-order-safe handshake lets it own the built-in card while this package supplies the recorded result. Other conflicts still skip that wrapper and use a bounded scrubbed Cursor activity trace instead. Disable native replay registration entirely: diff --git a/packages/pi-cursor-sdk/src/cursor-model-lifecycle.ts b/packages/pi-cursor-sdk/src/cursor-model-lifecycle.ts index 0e90542..ca5fbd9 100644 --- a/packages/pi-cursor-sdk/src/cursor-model-lifecycle.ts +++ b/packages/pi-cursor-sdk/src/cursor-model-lifecycle.ts @@ -3,6 +3,7 @@ import type { BeforeAgentStartEventResult, ExtensionContext, ExtensionHandler, + SessionShutdownEvent, SessionStartEvent, TurnStartEvent, } from "@earendil-works/pi-coding-agent"; @@ -19,6 +20,7 @@ type CursorModelBeforeAgentStartHandler = ExtensionHandler): void; + on(event: "session_shutdown", handler: ExtensionHandler): void; on(event: "before_agent_start", handler: CursorModelBeforeAgentStartHandler): void; on(event: "model_select", handler: (event: CursorModelSelectEvent, ctx: ExtensionContext) => Promise | void): void; on(event: "turn_start", handler: ExtensionHandler): void; diff --git a/packages/pi-cursor-sdk/src/cursor-native-tool-display-registration.ts b/packages/pi-cursor-sdk/src/cursor-native-tool-display-registration.ts index dac6d29..3575962 100644 --- a/packages/pi-cursor-sdk/src/cursor-native-tool-display-registration.ts +++ b/packages/pi-cursor-sdk/src/cursor-native-tool-display-registration.ts @@ -18,6 +18,7 @@ import { skippedNativeToolNames, } from "./cursor-native-tool-display-state.js"; import { isCursorReplayToolName } from "./cursor-tool-presentation-registry.js"; +import { createCursorSharedReplayProducer } from "./cursor-shared-replay-producer.js"; export const CURSOR_CORE_PI_REPLAY_TOOL_NAMES = ["read", "bash", "edit", "write"] as const; const CORE_PI_TOOL_NAMES = new Set(CURSOR_CORE_PI_REPLAY_TOOL_NAMES); @@ -29,7 +30,9 @@ function isCursorCorePiReplayToolName(toolName: string): toolName is (typeof CUR type CursorNativeToolActivationApi = Pick; type CursorNativeToolRegistryApi = CursorNativeToolActivationApi & Pick; -export interface CursorNativeToolDisplayExtensionApi extends CursorNativeToolRegistryApi, CursorModelLifecycleExtensionApi {} +export interface CursorNativeToolDisplayExtensionApi extends CursorNativeToolRegistryApi, CursorModelLifecycleExtensionApi { + events: ExtensionAPI["events"]; +} function hasNonBuiltinTool(pi: Pick, toolName: NativeCursorToolName): boolean { const existingTool = pi.getAllTools().find((tool) => tool.name === toolName); @@ -128,9 +131,12 @@ async function ensureThenSyncNativeCursorToolsForModel(pi: CursorNativeToolRegis } export function registerCursorNativeToolDisplay(pi: CursorNativeToolDisplayExtensionApi): void { + const sharedReplay = createCursorSharedReplayProducer(pi.events); registerCursorModelLifecycle(pi, async (ctx) => { + sharedReplay.register(); await ensureThenSyncNativeCursorToolsForModel(pi, ctx); }); + pi.on("session_shutdown", async () => sharedReplay.dispose()); } export { isNativeCursorToolName, isCursorNativeToolDisplayRequested }; diff --git a/packages/pi-cursor-sdk/src/cursor-native-tool-display-replay.ts b/packages/pi-cursor-sdk/src/cursor-native-tool-display-replay.ts index 36dade4..3208ccb 100644 --- a/packages/pi-cursor-sdk/src/cursor-native-tool-display-replay.ts +++ b/packages/pi-cursor-sdk/src/cursor-native-tool-display-replay.ts @@ -1,9 +1,10 @@ import { readFileSync, statSync } from "node:fs"; import { basename } from "node:path"; import { getLanguageFromPath, highlightCode, keyHint, type ToolDefinition } from "@earendil-works/pi-coding-agent"; -import { Image, Text, type Component } from "@earendil-works/pi-tui"; +import { Image, Text, truncateToWidth, type Component } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { resolveCursorEditDiff } from "./cursor-edit-diff.js"; +import { truncateCursorDisplayLine } from "./cursor-display-text.js"; import { inferImageMimeType } from "./cursor-tool-result-display-readers.js"; import { LOCAL_READ_PREVIEW_NOTICE, isLocalReadPreviewContent } from "./cursor-transcript-utils.js"; import { @@ -323,9 +324,19 @@ export function formatCursorReplayFilePreview( return renderedLines.join("\n"); } +function boundedOneLine(text: string): Component { + return { + render(width: number) { + if (width <= 0) return []; + return [truncateToWidth(text, width, "…")]; + }, + invalidate() {}, + }; +} + function getCursorReplayCardTitle(toolName: CursorReplayToolName, args: Record | undefined): string { if (toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME && typeof args?.activityTitle === "string" && args.activityTitle.trim()) { - return args.activityTitle.trim(); + return truncateCursorDisplayLine(args.activityTitle); } return "Cursor activity"; } @@ -335,12 +346,12 @@ export function renderCursorReplayCall( args: Record | undefined, theme: CursorReplayRenderTheme, isPartial: boolean, -): Text { - if (!isPartial) return new Text("", 0, 0); +): Component { + if (!isPartial) return boundedOneLine(""); let text = theme.fg("toolTitle", theme.bold(`${getCursorReplayCardTitle(toolName, args)} `)); const summary = getCursorReplayCallSummary(toolName, args); - if (summary) text += theme.fg("accent", summary); - return new Text(text.trimEnd(), 0, 0); + if (summary) text += theme.fg("muted", truncateCursorDisplayLine(summary)); + return boundedOneLine(text.trimEnd()); } function countDisplayLines(text: string): number { @@ -541,6 +552,28 @@ function renderCursorReplayDetails( return new Text(text || theme.fg("success", "Cursor tool result replayed"), 0, 0); } +function renderNeutralCursorReplayResult( + details: CursorReplayToolDetails | undefined, + text: string, + theme: Parameters[2], + isError: boolean, +): Component { + const title = details?.variant === "activity" + ? details.title + : details?.variant === "generateImage" + ? CURSOR_REPLAY_GENERATE_IMAGE_RESULT_TITLE + : details?.variant === "genericFallback" + ? `Cursor ${details.sourceToolName}` + : "Cursor activity"; + const errorSummary = isError + ? text.split("\n").find((line) => /^error\s*:/i.test(line.trim())) + : undefined; + const summary = errorSummary ?? details?.summary ?? text.split("\n").find((line) => line.trim()) ?? (isError ? "failed" : "completed"); + return boundedOneLine( + `${theme.fg("toolTitle", theme.bold(truncateCursorDisplayLine(title)))} ${theme.fg(isError ? "error" : "muted", truncateCursorDisplayLine(summary))}`, + ); +} + export function renderCursorReplayResult( result: Parameters[0], options: Parameters[1], @@ -548,13 +581,12 @@ export function renderCursorReplayResult( context: Parameters[3], isError: boolean, ): Component { - if (options.isPartial) return new Text(theme.fg("warning", "Replaying Cursor tool result..."), 0, 0); + if (options.isPartial) return boundedOneLine(theme.fg("muted", "Cursor activity running")); const details = parseCursorReplayToolDetails(result.details); const text = firstContentText(result); - if (isError && !hasCursorReplayDisplayTitle(details)) { - return new Text(theme.fg("error", text.split("\n")[0] || "Cursor replay failed"), 0, 0); + if (!details || details.variant === "activity" || details.variant === "generateImage" || details.variant === "genericFallback") { + return renderNeutralCursorReplayResult(details, text, theme, isError); } - if (!details) return new Text(text || theme.fg("success", "Cursor tool result replayed"), 0, 0); return renderCursorReplayDetails(details, result, options, theme, context, isError, text); } diff --git a/packages/pi-cursor-sdk/src/cursor-native-tool-display-state.ts b/packages/pi-cursor-sdk/src/cursor-native-tool-display-state.ts index 3d0e338..5dfb4b7 100644 --- a/packages/pi-cursor-sdk/src/cursor-native-tool-display-state.ts +++ b/packages/pi-cursor-sdk/src/cursor-native-tool-display-state.ts @@ -11,6 +11,7 @@ export const NATIVE_CURSOR_TOOL_REGISTRATION_ENV = "PI_CURSOR_REGISTER_NATIVE_TO export const registeredNativeToolNames = new Set(); export const skippedNativeToolNames = new Set(); +export const sharedReplayToolNames = new Set(); export const nativeToolResults = new Map(); let nativeToolDisplayRuntimeRequested = false; @@ -35,21 +36,32 @@ export function setCursorNativeToolDisplayRuntimeRequested(requested: boolean): } export function isCursorNativeToolDisplayEnabled(): boolean { - return registeredNativeToolNames.size > 0; + return registeredNativeToolNames.size > 0 || sharedReplayToolNames.size > 0; } export function isCursorNativeToolDisplayRuntimeEnabled(): boolean { - return nativeToolDisplayRuntimeRequested && readBooleanEnv(NATIVE_CURSOR_TOOL_DISPLAY_ENV) !== false && registeredNativeToolNames.size > 0; + return nativeToolDisplayRuntimeRequested + && readBooleanEnv(NATIVE_CURSOR_TOOL_DISPLAY_ENV) !== false + && (registeredNativeToolNames.size > 0 || sharedReplayToolNames.size > 0); } export function canRenderCursorToolNatively(toolName: string): boolean { - return registeredNativeToolNames.has(toolName); + return registeredNativeToolNames.has(toolName) || sharedReplayToolNames.has(toolName); } export function isRegisteredCursorNativeToolName(toolName: string): boolean { return registeredNativeToolNames.has(toolName); } +export function setSharedCursorReplayToolNames(toolNames: Iterable): void { + sharedReplayToolNames.clear(); + for (const toolName of toolNames) sharedReplayToolNames.add(toolName); +} + +export function clearSharedCursorReplayToolNames(): void { + sharedReplayToolNames.clear(); +} + export function recordCursorNativeToolDisplay(item: CursorNativeToolDisplayItem): boolean { if (!canRenderCursorToolNatively(item.toolName)) return false; nativeToolResults.set(item.id, item); @@ -84,6 +96,7 @@ export const __testUtils = { nativeToolDisplayRuntimeRequested = false; registeredNativeToolNames.clear(); skippedNativeToolNames.clear(); + sharedReplayToolNames.clear(); nativeToolResults.clear(); }, }; diff --git a/packages/pi-cursor-sdk/src/cursor-native-tool-display-tools.ts b/packages/pi-cursor-sdk/src/cursor-native-tool-display-tools.ts index 4a3f2a1..09840df 100644 --- a/packages/pi-cursor-sdk/src/cursor-native-tool-display-tools.ts +++ b/packages/pi-cursor-sdk/src/cursor-native-tool-display-tools.ts @@ -42,7 +42,6 @@ type RenderResult = NonNullable; type NativeReplayStrategy = { createDefinition: (cwd: string) => AnyToolDefinition; - missingReplayPolicy?: "block-file-mutation"; renderReplayCall?: ( args: Parameters[0], theme: Parameters[1], @@ -130,14 +129,12 @@ const NATIVE_CURSOR_TOOL_STRATEGIES: Record createBashToolDefinition(cwd) as AnyToolDefinition }, edit: { createDefinition: (cwd) => createEditToolDefinition(cwd) as AnyToolDefinition, - missingReplayPolicy: "block-file-mutation", renderReplayCall: (args, theme, context) => renderNativeLookingCursorFileMutationCall("edit", args as Record, theme, context.isPartial), renderReplayResult: renderEditReplayResult, }, write: { createDefinition: (cwd) => createWriteToolDefinition(cwd) as AnyToolDefinition, - missingReplayPolicy: "block-file-mutation", renderReplayCall: (args, theme, context) => renderNativeLookingCursorFileMutationCall("write", args as Record, theme, context.isPartial), renderReplayResult: renderWriteReplayResult, @@ -178,8 +175,8 @@ export function wrapNativeCursorTool( terminate: cursorDisplay.terminate ?? true, }; } - if (strategy?.missingReplayPolicy === "block-file-mutation" && isCursorReplayToolCallId(toolCallId)) { - throw new Error(`No recorded Cursor ${definition.name} result was available. This replay-only call does not execute file mutations.`); + if (isCursorReplayToolCallId(toolCallId)) { + throw new Error(`No recorded Cursor ${definition.name} result was available. This replay-only call never executes the underlying tool.`); } return getCurrentDefinition().execute(toolCallId, params, signal, onUpdate, ctx); }, diff --git a/packages/pi-cursor-sdk/src/cursor-shared-replay-producer.ts b/packages/pi-cursor-sdk/src/cursor-shared-replay-producer.ts new file mode 100644 index 0000000..e7f3a2b --- /dev/null +++ b/packages/pi-cursor-sdk/src/cursor-shared-replay-producer.ts @@ -0,0 +1,83 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { BUILTIN_NATIVE_CURSOR_TOOL_NAMES } from "./cursor-native-tool-names.js"; +import { + clearSharedCursorReplayToolNames, + consumeCursorNativeToolDisplay, + setSharedCursorReplayToolNames, +} from "./cursor-native-tool-display-state.js"; + +const PROTOCOL_VERSION = 1 as const; +const HOST_READY = "@iurysza/pi-ext/cursor-replay/ready/v1"; +const PRODUCER_REGISTER = "@iurysza/pi-ext/cursor-replay/register/v1"; +const CONSUME = "@iurysza/pi-ext/cursor-replay/consume/v1"; +const BUILTIN_NAMES = new Set(BUILTIN_NATIVE_CURSOR_TOOL_NAMES); + +interface ConsumeRequest { + protocolVersion: typeof PROTOCOL_VERSION; + toolCallId: string; + toolName: string; + accept(delivery: unknown): void; +} + +function parseConsumeRequest(data: unknown): ConsumeRequest | undefined { + if (!data || typeof data !== "object") return undefined; + const request = data as Partial; + if ( + request.protocolVersion !== PROTOCOL_VERSION + || typeof request.toolCallId !== "string" + || typeof request.toolName !== "string" + || !BUILTIN_NAMES.has(request.toolName) + || typeof request.accept !== "function" + ) return undefined; + return request as ConsumeRequest; +} + +export function createCursorSharedReplayProducer(events: ExtensionAPI["events"]) { + const register = () => events.emit(PRODUCER_REGISTER, { + protocolVersion: PROTOCOL_VERSION, + producer: "@iurysza/pi-cursor-sdk", + }); + const disposeReady = events.on(HOST_READY, (data) => { + if (!data || typeof data !== "object") return; + const ready = data as { protocolVersion?: unknown; toolNames?: unknown }; + if (ready.protocolVersion !== PROTOCOL_VERSION || !Array.isArray(ready.toolNames)) return; + setSharedCursorReplayToolNames(ready.toolNames.filter( + (toolName): toolName is string => typeof toolName === "string" && BUILTIN_NAMES.has(toolName), + )); + }); + const disposeConsume = events.on(CONSUME, (data) => { + const request = parseConsumeRequest(data); + if (!request) return; + const item = consumeCursorNativeToolDisplay(request.toolCallId); + if (!item) return; + const mismatchedTool = item.toolName !== request.toolName; + request.accept({ + protocolVersion: PROTOCOL_VERSION, + toolCallId: request.toolCallId, + toolName: request.toolName, + isError: mismatchedTool || item.isError, + result: mismatchedTool + ? { + content: [{ + type: "text", + text: `Recorded Cursor replay expected ${request.toolName} but contained ${item.toolName}.`, + }], + } + : { + content: item.result.content, + details: item.result.details, + terminate: item.terminate ?? true, + }, + }); + }); + register(); + + return { + register, + dispose() { + disposeReady(); + disposeConsume(); + clearSharedCursorReplayToolNames(); + }, + }; +} diff --git a/packages/pi-cursor-sdk/test/cursor-native-tool-display-replay.test.ts b/packages/pi-cursor-sdk/test/cursor-native-tool-display-replay.test.ts index 879be57..80abee8 100644 --- a/packages/pi-cursor-sdk/test/cursor-native-tool-display-replay.test.ts +++ b/packages/pi-cursor-sdk/test/cursor-native-tool-display-replay.test.ts @@ -1,249 +1,133 @@ import { describe, expect, it } from "vitest"; +import { visibleWidth, Text } from "@earendil-works/pi-tui"; import { CURSOR_REPLAY_ACTIVITY_TOOL_NAME } from "../src/cursor-tool-presentation-registry.js"; import { - CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES, - CURSOR_REPLAY_PREVIEW_MAX_LINE_CHARS, - formatCursorReplayDiff, - formatCursorReplayFilePreview, - renderCursorReplayCall, - renderCursorReplayResult, - renderNativeLookingCursorReadReplayResult, + CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES, + CURSOR_REPLAY_PREVIEW_MAX_LINE_CHARS, + formatCursorReplayDiff, + formatCursorReplayFilePreview, + renderCursorReplayCall, + renderCursorReplayResult, + renderNativeLookingCursorReadReplayResult, } from "../src/cursor-native-tool-display-replay.js"; import { LOCAL_READ_PREVIEW_NOTICE } from "../src/cursor-transcript-utils.js"; -import { Text } from "@earendil-works/pi-tui"; import { createRenderContext, createRenderTheme } from "./helpers/render-fixtures.js"; const theme = createRenderTheme(); -const taggedTheme = createRenderTheme({ - fg: (name: string, value: string) => `<${name}>${value}`, -}); - -function renderReplayResultWithDetails(details: unknown): string { - return renderCursorReplayResult( - { content: [{ type: "text", text: "ok" }], details }, - { expanded: false, isPartial: false }, - taggedTheme, - createRenderContext({ isError: false, showImages: false }), - false, - ) - .render(240) - .join("\n"); +function renderActivity(details: unknown, width = 120, expanded = false): string[] { + return renderCursorReplayResult( + { content: [{ type: "text", text: "fallback result\nsecond line" }], details }, + { expanded, isPartial: false }, + theme, + createRenderContext({ isError: false, showImages: false }), + false, + ).render(width); } describe("cursor native replay rendering", () => { - it("bounds huge single-line diffs in collapsed replay cards", () => { - const hugeLine = "x".repeat(20_000); - const rendered = formatCursorReplayDiff(`--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-${hugeLine}\n+${hugeLine}`, theme, CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES); - - expect(rendered).not.toContain(hugeLine); - expect(rendered.length).toBeLessThan(CURSOR_REPLAY_PREVIEW_MAX_LINE_CHARS * 4); - expect(rendered).toContain("…"); - }); - - it("bounds huge write previews before rendering", () => { - const hugeLine = "y".repeat(20_000); - const rendered = formatCursorReplayFilePreview(hugeLine, "generated.txt", theme); - - expect(rendered).toBeDefined(); - expect(rendered).not.toContain(hugeLine); - expect(rendered!.length).toBeLessThan(CURSOR_REPLAY_PREVIEW_MAX_LINE_CHARS * 2); - expect(rendered).toContain("more chars"); - }); - - it("uses honest truncation copy for expanded diffs that still exceed the display budget", () => { - const diff = ["--- a/file.txt", "+++ b/file.txt", "@@ -1,60 +1,60 @@", ...Array.from({ length: 60 }, (_, index) => `+line ${index}`)].join("\n"); - const rendered = formatCursorReplayDiff(diff, theme, 40); - - expect(rendered).toContain("more diff lines hidden"); - expect(rendered).not.toContain("full diff"); - }); - - it("shows the standard expand affordance on collapsed expandable replay cards", () => { - const rendered = renderReplayResultWithDetails({ - variant: "activity", - sourceToolName: "task", - title: "Cursor subagent", - summary: "Inspect package.json · Explore · composer-2.5-fast · ID: agent-1", - expandedText: "subagent Inspect package.json\n\n1. Package name: pi-cursor-sdk\n2. Risk: peer ranges", - }); - - expect(rendered).toContain("to expand"); - expect(rendered).toContain("Cursor subagent"); - }); - - it("colors unified diff body lines in neutral Cursor edit activity cards", () => { - // Unstructured path (no diffString): still exercises extract + canonical renderer. - const rendered = renderReplayResultWithDetails({ - variant: "activity", - sourceToolName: "edit", - title: "Cursor edit", - summary: "file.txt added 1 line, removed 1 line", - expandedText: "edit file.txt\n\n+1 -1\n\n--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n-old line\n+new line\n keep line\n final line", - }); - - // Canonical output: numbered lines, raw headers omitted (consistent with nativeEdit + structured activity). - expect(rendered).toContain("-1 old line"); - expect(rendered).toContain("+1 new line"); - expect(rendered).toContain(" 2 keep line"); - expect(rendered).toContain(" 3 final line"); - expect(rendered).not.toContain("--- a/file.txt"); - expect(rendered).not.toContain("+++ b/file.txt"); - }); - - it("colors collapsed diff lines when transcript preamble exceeds preview budget", () => { - // This exercises the extract path for activity details that only have diff in expandedText - // and no diffString. It routes through the single canonical renderer while proving preamble is ignored. - const preamble = Array.from({ length: 12 }, (_, index) => `note ${index + 1}`).join("\n"); - const rendered = renderReplayResultWithDetails({ - variant: "activity", - sourceToolName: "edit", - title: "Cursor edit", - summary: "src/file.ts added 1 line", - expandedText: `${preamble}\n\nedit src/file.ts\n\n+1 -0\n\n--- a/src/file.ts\n+++ b/src/file.ts\n@@ -1,3 +1,3 @@\n-old line\n+new line\n context line`, - }); - - // Canonical numbered output from formatCursorReplayDiff after extract. - expect(rendered).toContain("-1 old line"); - expect(rendered).toContain("+1 new line"); - }); - - it("activity edit with structured diffString uses canonical colored diff renderer (ignores expandedText preamble; text extraction not used)", () => { - // Structured primary path (new sessions): diffString present on details -> formatCursorReplayDiff directly. - // Long preamble in expandedText must not affect; no reliance on extractUnifiedDiffSection for coloring. - const preamble = Array.from({ length: 20 }, (_, index) => `preamble note ${index + 1}`).join("\n"); - const structuredDiff = "--- a/src/file.ts\n+++ b/src/file.ts\n@@ -1,2 +1,2 @@\n-old\n+new\n context"; - const rendered = renderReplayResultWithDetails({ - variant: "activity", - sourceToolName: "edit", - title: "Cursor edit", - summary: "src/file.ts updated", - diffString: structuredDiff, - // expandedText has a long preamble; structured diffString must win for colors. - expandedText: `${preamble}\n\nsome transcript\n${structuredDiff}`, - }); - - // Uses formatCursorReplayDiff output shape (numbered, headers skipped, context/added/removed tags). - expect(rendered).toContain("-1 old"); - expect(rendered).toContain("+1 new"); - expect(rendered).toContain(" 2 context"); - // Headers from structured are omitted by canonical renderer (consistent with nativeEdit). - expect(rendered).not.toContain("--- a/src/file.ts"); - expect(rendered).not.toContain("+++ b/src/file.ts"); - // Preamble must not leak into diff preview. - expect(rendered).not.toContain("preamble note"); - }); - - it("colors unified diff body lines in neutral Cursor write activity cards", () => { - // Unstructured write path (no diffString) still exercises extract + canonical renderer. - const rendered = renderReplayResultWithDetails({ - variant: "activity", - sourceToolName: "write", - title: "Cursor write", - summary: "created 2 lines", - expandedText: "write file.txt\n\n+2 -0\n\n--- /dev/null\n+++ b/file.txt\n@@ -0,0 +1,2 @@\n+first line\n+second line", - }); - - expect(rendered).toContain("Cursor write"); - // Canonical output (numbered, headers omitted). - expect(rendered).toContain("+1 first line"); - expect(rendered).toContain("+2 second line"); - expect(rendered).not.toContain("--- /dev/null"); - expect(rendered).not.toContain("+++ b/file.txt"); - }); - - it("activity write with structured fileContentAfterWrite uses canonical file preview (ignores expandedText preamble)", () => { - const preamble = Array.from({ length: 20 }, (_, index) => `preamble note ${index + 1}`).join("\n"); - const rendered = renderReplayResultWithDetails({ - variant: "activity", - sourceToolName: "write", - title: "Cursor write", - summary: "new.txt", - path: "new.txt", - fileContentAfterWrite: "hello world\n", - expandedText: `${preamble}\n\nwrite new.txt\n\nCreated 1 lines\n\nhello world\n`, - }); - - expect(rendered).toContain("hello world"); - expect(rendered).not.toContain("preamble note"); - expect(rendered).not.toContain("hello world"); - }); - - it("shows local read preview disclaimer in collapsed native read replay results", () => { - const result = { - content: [{ type: "text" as const, text: `${LOCAL_READ_PREVIEW_NOTICE}\n# Local preview\n` }], - details: { localReadPreview: true }, - }; - const rendered = renderNativeLookingCursorReadReplayResult( - result, - { expanded: false, isPartial: false }, - theme, - createRenderContext({ isError: false, args: { path: "README.md", localReadPreview: true } }), - () => new Text("", 0, 0), - ) - .render(120) - .join("\n"); - - expect(rendered).toContain(LOCAL_READ_PREVIEW_NOTICE); - expect(rendered).not.toContain("# Local preview"); - }); - - it("renders collapsed activity summaries from metadata for neutral cursor cards", () => { - const rendered = [ - renderCursorReplayCall( - CURSOR_REPLAY_ACTIVITY_TOOL_NAME, - { activityTitle: "Cursor diagnostics", activitySummary: "0 diagnostics in src/index.ts" }, - theme, - true, - ), - renderCursorReplayCall( - CURSOR_REPLAY_ACTIVITY_TOOL_NAME, - { activityTitle: "Cursor todos", activitySummary: "1/2 completed, 1 pending" }, - theme, - true, - ), - renderCursorReplayCall( - CURSOR_REPLAY_ACTIVITY_TOOL_NAME, - { activityTitle: "Cursor MCP", activitySummary: "git · ## Git Status ✅", toolName: "git" }, - theme, - true, - ), - ] - .map((component) => component.render(120).join("\n")) - .join("\n"); - - expect(rendered).toContain("Cursor diagnostics 0 diagnostics in src/index.ts"); - expect(rendered).toContain("Cursor todos 1/2 completed, 1 pending"); - expect(rendered).toContain("Cursor MCP git · ## Git Status ✅"); - }); - - it("renders neutral cursor partial calls from activity metadata", () => { - const rendered = [ - renderCursorReplayCall( - CURSOR_REPLAY_ACTIVITY_TOOL_NAME, - { activityTitle: "Cursor semantic search", activitySummary: "main entrypoint (1 dir)" }, - theme, - true, - ), - renderCursorReplayCall( - CURSOR_REPLAY_ACTIVITY_TOOL_NAME, - { activityTitle: "Cursor screen recording", activitySummary: ".cursor/recordings/demo.webm · 4.2s" }, - theme, - true, - ), - renderCursorReplayCall( - CURSOR_REPLAY_ACTIVITY_TOOL_NAME, - { activityTitle: "Cursor delete", activitySummary: ".debug/delete-me.txt" }, - theme, - true, - ), - ] - .map((component) => component.render(120).join("\n")) - .join("\n"); - - expect(rendered).toContain("Cursor semantic search main entrypoint (1 dir)"); - expect(rendered).toContain("Cursor screen recording .cursor/recordings/demo.webm · 4.2s"); - expect(rendered).toContain("Cursor delete .debug/delete-me.txt"); - expect(rendered).not.toContain("cursor_"); - }); + it("bounds huge single-line diffs in standalone native replay cards", () => { + const hugeLine = "x".repeat(20_000); + const rendered = formatCursorReplayDiff( + `--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-${hugeLine}\n+${hugeLine}`, + theme, + CURSOR_REPLAY_COLLAPSED_PREVIEW_LINES, + ); + expect(rendered).not.toContain(hugeLine); + expect(rendered.length).toBeLessThan(CURSOR_REPLAY_PREVIEW_MAX_LINE_CHARS * 4); + expect(rendered).toContain("…"); + }); + + it("bounds huge write previews before rendering", () => { + const hugeLine = "y".repeat(20_000); + const rendered = formatCursorReplayFilePreview(hugeLine, "generated.txt", theme); + expect(rendered).toBeDefined(); + expect(rendered).not.toContain(hugeLine); + expect(rendered!.length).toBeLessThan(CURSOR_REPLAY_PREVIEW_MAX_LINE_CHARS * 2); + expect(rendered).toContain("more chars"); + }); + + it("uses honest truncation copy for oversized standalone diffs", () => { + const diff = [ + "--- a/file.txt", + "+++ b/file.txt", + "@@ -1,60 +1,60 @@", + ...Array.from({ length: 60 }, (_, index) => `+line ${index}`), + ].join("\n"); + const rendered = formatCursorReplayDiff(diff, theme, 40); + expect(rendered).toContain("more diff lines hidden"); + expect(rendered).not.toContain("full diff"); + }); + + it("keeps Cursor-only activity to one bounded neutral line even when expanded", () => { + const details = { + variant: "activity", + sourceToolName: "task", + title: "Cursor subagent", + summary: "Inspect package.json · Explore · composer-2.5-fast · ID: agent-1", + expandedText: "subagent Inspect package.json\n\n1. Package name\n2. Risk", + }; + for (const expanded of [false, true]) { + for (const width of [12, 32, 80]) { + const lines = renderActivity(details, width, expanded); + expect(lines).toHaveLength(1); + expect(visibleWidth(lines[0]!)).toBeLessThanOrEqual(width); + expect(lines[0]).not.toContain("Package name"); + } + } + }); + + it.each([ + ["mcp", "Cursor MCP", "git · status"], + ["createPlan", "Cursor plan", "Ship footer integration"], + ["task", "Cursor subagent", "Inspect package"], + ["webSearch", "Cursor web search", "Pi extension API"], + ["generateImage", "Cursor image generation", "saved image.png"], + ["futureTool", "Cursor futureTool", "future completed"], + ])("renders %s as a neutral one-line result", (sourceToolName, title, summary) => { + const details = sourceToolName === "generateImage" + ? { variant: "generateImage", summary, expandedText: "hidden image details" } + : sourceToolName === "futureTool" + ? { variant: "genericFallback", sourceToolName, summary, expandedText: "hidden future details" } + : { variant: "activity", sourceToolName, title, summary, expandedText: "hidden activity details" }; + const lines = renderActivity(details, 120); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain(title); + expect(lines[0]).toContain(summary); + expect(lines[0]).not.toContain("hidden"); + }); + + it("bounds partial activity calls and sanitizes multiline metadata", () => { + const component = renderCursorReplayCall( + CURSOR_REPLAY_ACTIVITY_TOOL_NAME, + { + activityTitle: "Cursor MCP\nignored", + activitySummary: "git status\n" + "x".repeat(500), + }, + theme, + true, + ); + for (const width of [8, 24, 72]) { + const lines = component.render(width); + expect(lines).toHaveLength(1); + expect(visibleWidth(lines[0]!)).toBeLessThanOrEqual(width); + expect(lines[0]).not.toContain("\n"); + } + }); + + it("shows local read preview disclaimer in collapsed standalone read replay", () => { + const result = { + content: [{ type: "text" as const, text: `${LOCAL_READ_PREVIEW_NOTICE}\n# Local preview\n` }], + details: { localReadPreview: true }, + }; + const rendered = renderNativeLookingCursorReadReplayResult( + result, + { expanded: false, isPartial: false }, + theme, + createRenderContext({ isError: false, args: { path: "README.md", localReadPreview: true } }), + () => new Text("", 0, 0), + ).render(120).join("\n"); + expect(rendered).toContain(LOCAL_READ_PREVIEW_NOTICE); + expect(rendered).not.toContain("# Local preview"); + }); }); diff --git a/packages/pi-cursor-sdk/test/cursor-native-tool-display-tools.test.ts b/packages/pi-cursor-sdk/test/cursor-native-tool-display-tools.test.ts index 92d771d..c219320 100644 --- a/packages/pi-cursor-sdk/test/cursor-native-tool-display-tools.test.ts +++ b/packages/pi-cursor-sdk/test/cursor-native-tool-display-tools.test.ts @@ -4,6 +4,11 @@ import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import * as replay from "../src/cursor-native-tool-display-replay.js"; import { wrapNativeCursorTool } from "../src/cursor-native-tool-display-tools.js"; +import { + __testUtils, + recordCursorNativeToolDisplay, +} from "../src/cursor-native-tool-display-state.js"; +import { BUILTIN_NATIVE_CURSOR_TOOL_NAMES } from "../src/cursor-native-tool-names.js"; import { createRenderContext, createRenderOptions, createRenderTheme } from "./helpers/render-fixtures.js"; describe("wrapNativeCursorTool", () => { @@ -42,4 +47,65 @@ describe("wrapNativeCursorTool", () => { expect(delegateRenderResult).toHaveBeenCalledOnce(); replaySpy.mockRestore(); }); + + it.each(BUILTIN_NATIVE_CURSOR_TOOL_NAMES)("fails closed when %s replay state is missing", async (toolName) => { + __testUtils.reset(); + const parameters = Type.Object({}); + const execute = vi.fn(async () => ({ content: [], details: undefined })); + const definition = { + name: toolName, + label: toolName, + description: toolName, + parameters, + execute, + } satisfies ToolDefinition; + const wrapped = wrapNativeCursorTool(definition, () => definition); + + await expect(wrapped.execute( + `cursor-replay-${toolName}-missing`, + {}, + new AbortController().signal, + undefined, + {} as never, + )).rejects.toThrow(`No recorded Cursor ${toolName} result`); + expect(execute).not.toHaveBeenCalled(); + }); + + it("consumes standalone replay state once", async () => { + __testUtils.reset(); + __testUtils.registerNativeToolNameForTests("read"); + recordCursorNativeToolDisplay({ + id: "cursor-replay-read-once", + toolName: "read", + args: { path: "README.md" }, + result: { content: [{ type: "text", text: "recorded" }], details: { source: "Cursor" } }, + isError: false, + }); + const parameters = Type.Object({}); + const execute = vi.fn(async () => ({ content: [], details: undefined })); + const definition = { + name: "read", + label: "read", + description: "read", + parameters, + execute, + } satisfies ToolDefinition; + const wrapped = wrapNativeCursorTool(definition, () => definition); + + await expect(wrapped.execute( + "cursor-replay-read-once", + {}, + new AbortController().signal, + undefined, + {} as never, + )).resolves.toMatchObject({ content: [{ type: "text", text: "recorded" }] }); + await expect(wrapped.execute( + "cursor-replay-read-once", + {}, + new AbortController().signal, + undefined, + {} as never, + )).rejects.toThrow("No recorded Cursor read result"); + expect(execute).not.toHaveBeenCalled(); + }); }); diff --git a/packages/pi-cursor-sdk/test/cursor-provider-replay-tool-display.test.ts b/packages/pi-cursor-sdk/test/cursor-provider-replay-tool-display.test.ts index 363631d..352a8d9 100644 --- a/packages/pi-cursor-sdk/test/cursor-provider-replay-tool-display.test.ts +++ b/packages/pi-cursor-sdk/test/cursor-provider-replay-tool-display.test.ts @@ -397,7 +397,7 @@ it("replays Cursor grep activity through native grep display", async () => { undefined, createExtensionTestContext(), ), - ).rejects.toThrow("replay-only call does not execute file mutations"); + ).rejects.toThrow("replay-only call never executes the underlying tool."); expect(readFileSync(targetPath, "utf-8")).toBe("old\n"); resolveRun({ id: "run-1", status: "finished", result: "Done." }); @@ -685,7 +685,7 @@ it("replays Cursor grep activity through native grep display", async () => { await expect( writeTool!.execute("cursor-replay-1-1-tool-998", { path: targetPath, content: "mutated\n" }, undefined, undefined, createExtensionTestContext()), - ).rejects.toThrow("replay-only call does not execute file mutations"); + ).rejects.toThrow("replay-only call never executes the underlying tool."); expect(readFileSync(targetPath, "utf-8")).toBe("old\n"); resolveRun({ id: "run-1", status: "finished", result: "Done." }); diff --git a/packages/pi-cursor-sdk/test/cursor-replay-tool-details.test.ts b/packages/pi-cursor-sdk/test/cursor-replay-tool-details.test.ts index 645723d..6aebcae 100644 --- a/packages/pi-cursor-sdk/test/cursor-replay-tool-details.test.ts +++ b/packages/pi-cursor-sdk/test/cursor-replay-tool-details.test.ts @@ -241,7 +241,7 @@ describe("cursor replay tool details contract", () => { sourceToolName: "write", title: "Cursor write", }); - expect(rendered).toContain("write src/a.ts"); + expect(rendered).toContain("Cursor write"); expect(rendered).toContain("Error: permission denied"); expect(rendered).not.toMatch(/^write src\/a\.ts$/m); }); diff --git a/packages/pi-cursor-sdk/test/cursor-shared-replay-producer.test.ts b/packages/pi-cursor-sdk/test/cursor-shared-replay-producer.test.ts new file mode 100644 index 0000000..ed66e2e --- /dev/null +++ b/packages/pi-cursor-sdk/test/cursor-shared-replay-producer.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { createCursorSharedReplayProducer } from "../src/cursor-shared-replay-producer.js"; +import { + __testUtils, + canRenderCursorToolNatively, + recordCursorNativeToolDisplay, +} from "../src/cursor-native-tool-display-state.js"; + +function fakeEvents() { + const handlers = new Map void>>(); + const emitted: Array<{ channel: string; data: unknown }> = []; + return { + emitted, + events: { + emit(channel: string, data: unknown) { + emitted.push({ channel, data }); + for (const handler of handlers.get(channel) ?? []) handler(data); + }, + on(channel: string, handler: (data: unknown) => void) { + const channelHandlers = handlers.get(channel) ?? new Set(); + channelHandlers.add(handler); + handlers.set(channel, channelHandlers); + return () => channelHandlers.delete(handler); + }, + } as ExtensionAPI["events"], + }; +} + +afterEach(() => __testUtils.reset()); + +describe("Cursor shared replay producer", () => { + it("registers repeatedly and accepts only advertised built-in capabilities", () => { + const harness = fakeEvents(); + const producer = createCursorSharedReplayProducer(harness.events); + expect(harness.emitted.filter(({ channel }) => channel.endsWith("/register/v1"))).toHaveLength(1); + + harness.events.emit("@iurysza/pi-ext/cursor-replay/ready/v1", { + protocolVersion: 1, + toolNames: ["read", "edit", "cursor", "future"], + }); + expect(canRenderCursorToolNatively("read")).toBe(true); + expect(canRenderCursorToolNatively("edit")).toBe(true); + expect(canRenderCursorToolNatively("cursor")).toBe(false); + producer.register(); + expect(harness.emitted.filter(({ channel }) => channel.endsWith("/register/v1"))).toHaveLength(2); + }); + + it("delivers a recorded result once and preserves recorded errors", () => { + const harness = fakeEvents(); + createCursorSharedReplayProducer(harness.events); + harness.events.emit("@iurysza/pi-ext/cursor-replay/ready/v1", { + protocolVersion: 1, + toolNames: ["read", "bash"], + }); + expect(recordCursorNativeToolDisplay({ + id: "cursor-replay-read-1", + toolName: "read", + args: { path: "README.md" }, + result: { content: [{ type: "text", text: "recorded" }], details: { lines: 1 } }, + isError: false, + terminate: true, + })).toBe(true); + + const accept = vi.fn(); + const consume = () => harness.events.emit("@iurysza/pi-ext/cursor-replay/consume/v1", { + protocolVersion: 1, + toolCallId: "cursor-replay-read-1", + toolName: "read", + accept, + }); + consume(); + expect(accept).toHaveBeenCalledWith({ + protocolVersion: 1, + toolCallId: "cursor-replay-read-1", + toolName: "read", + isError: false, + result: { + content: [{ type: "text", text: "recorded" }], + details: { lines: 1 }, + terminate: true, + }, + }); + consume(); + expect(accept).toHaveBeenCalledTimes(1); + expect(__testUtils.nativeToolResultCount()).toBe(0); + }); + + it("consumes mismatched state as an error instead of returning another tool's result", () => { + const harness = fakeEvents(); + createCursorSharedReplayProducer(harness.events); + __testUtils.registerNativeToolNameForTests("edit"); + recordCursorNativeToolDisplay({ + id: "cursor-replay-shared-id", + toolName: "edit", + args: {}, + result: { content: [{ type: "text", text: "wrong" }] }, + isError: false, + }); + const accept = vi.fn(); + harness.events.emit("@iurysza/pi-ext/cursor-replay/consume/v1", { + protocolVersion: 1, + toolCallId: "cursor-replay-shared-id", + toolName: "read", + accept, + }); + expect(accept.mock.calls[0]?.[0]).toMatchObject({ + isError: true, + result: { content: [{ type: "text", text: expect.stringContaining("contained edit") }] }, + }); + expect(__testUtils.nativeToolResultCount()).toBe(0); + }); +}); diff --git a/packages/pi-cursor-sdk/test/helpers/cursor-provider-harness.ts b/packages/pi-cursor-sdk/test/helpers/cursor-provider-harness.ts index 015742c..5cc4cb3 100644 --- a/packages/pi-cursor-sdk/test/helpers/cursor-provider-harness.ts +++ b/packages/pi-cursor-sdk/test/helpers/cursor-provider-harness.ts @@ -259,6 +259,7 @@ export async function createNativeToolDisplayPiForTest(registeredTools: Register ), }); const nativePi: CursorNativeToolDisplayExtensionApi = { + events: pi.events, on: pi.on as CursorNativeToolDisplayExtensionApi["on"], registerTool: (tool) => { registeredTools.push(tool as RegisteredTool); diff --git a/packages/pi-cursor-sdk/test/helpers/pi-harness-types.ts b/packages/pi-cursor-sdk/test/helpers/pi-harness-types.ts index 5490cdd..1c80c01 100644 --- a/packages/pi-cursor-sdk/test/helpers/pi-harness-types.ts +++ b/packages/pi-cursor-sdk/test/helpers/pi-harness-types.ts @@ -197,6 +197,7 @@ export interface EventHarness { } export interface PiHarness extends EventHarness { + events: ExtensionAPI["events"]; registerProvider: MockFn; registerFlag: MockFn; registerCommand: MockFn; @@ -219,6 +220,7 @@ export interface PiHarness extends EventHarness { } export interface BridgePiHarness extends EventHarness { + events: ExtensionAPI["events"]; getActiveTools: MockFn; getAllTools: MockFn; setActiveTools: MockFn; diff --git a/packages/pi-cursor-sdk/test/helpers/pi-registration-harness.ts b/packages/pi-cursor-sdk/test/helpers/pi-registration-harness.ts index 665d8f8..ab3cbae 100644 --- a/packages/pi-cursor-sdk/test/helpers/pi-registration-harness.ts +++ b/packages/pi-cursor-sdk/test/helpers/pi-registration-harness.ts @@ -21,10 +21,26 @@ import type { /** Pi harness surface accepted by `src/index.ts` extension factory registration. */ export type CursorExtensionRegistrationPi = Parameters[0]; +function createHarnessEventBus(): ExtensionAPI["events"] { + const handlers = new Map void>>(); + return { + emit(channel, data) { + for (const handler of handlers.get(channel) ?? []) handler(data); + }, + on(channel, handler) { + const channelHandlers = handlers.get(channel) ?? new Set(); + channelHandlers.add(handler); + handlers.set(channel, channelHandlers); + return () => channelHandlers.delete(handler); + }, + }; +} + export function createBridgePiHarness(options: { active: string[]; tools: ToolInfo[] }): BridgePiHarness { const eventApi = createHarnessEventApi(); return { ...eventApi, + events: createHarnessEventBus(), getActiveTools: vi.fn(() => [...options.active]), getAllTools: vi.fn(() => [...options.tools]), setActiveTools: vi.fn(), @@ -66,6 +82,7 @@ export function createPiHarness(options: PiHarnessOptions = {}): PiHarness { return { ...eventApi, + events: createHarnessEventBus(), registerProvider: vi.fn((name: string, config: ProviderConfig) => { registered.push({ name, config }); }), diff --git a/packages/pi-cursor-sdk/test/index-native-tools.test.ts b/packages/pi-cursor-sdk/test/index-native-tools.test.ts index 434ce71..88203e6 100644 --- a/packages/pi-cursor-sdk/test/index-native-tools.test.ts +++ b/packages/pi-cursor-sdk/test/index-native-tools.test.ts @@ -203,7 +203,7 @@ describe("extension native Cursor tool replay", () => { } }); - it("renders Cursor generateImage replay results with a visible path and image fallback", async () => { + it("renders Cursor generateImage replay as a bounded path summary", async () => { process.env.PI_CURSOR_NATIVE_TOOL_DISPLAY = "1"; mockedDiscover.mockResolvedValueOnce([]); const dir = mkdtempSync(join(tmpdir(), "pi-cursor-image-replay-")); @@ -233,9 +233,10 @@ describe("extension native Cursor tool replay", () => { createRenderContext({ isError: false, showImages: true }), ); - const rendered = component?.render(120).join("\n") ?? ""; - expect(rendered).toContain(`Cursor image generation saved ${imagePath}`); - expect(rendered).toContain("[Image: badge.png [image/png] 1x1]"); + const rendered = component?.render(120) ?? []; + expect(rendered).toHaveLength(1); + expect(rendered[0]).toContain("Cursor image generation saved"); + expect(rendered.join("\n")).not.toContain("[Image:"); } finally { resetCapabilitiesCache(); rmSync(dir, { recursive: true, force: true }); @@ -266,7 +267,7 @@ describe("extension native Cursor tool replay", () => { expect(rendered).not.toContain("Cursor activity"); }); - it("renders Cursor web replay cards summary-only until expanded", async () => { + it("keeps Cursor web replay summary-only when expanded", async () => { process.env.PI_CURSOR_NATIVE_TOOL_DISPLAY = "1"; mockedDiscover.mockResolvedValueOnce([]); const pi = createExtensionPi(); @@ -291,10 +292,9 @@ describe("extension native Cursor tool replay", () => { const expanded = cursorTool!.renderResult?.(result, createRenderOptions({ expanded: true }), theme, context)?.render(120).join("\n") ?? ""; expect(collapsed).toBe("Cursor web search web search azure-functions python"); - expect(collapsed).not.toContain("Links:"); - expect(expanded).toContain("Cursor web search web search azure-functions python"); - expect(expanded).toContain("Links:"); - expect(expanded).toContain("https://example.com"); + expect(expanded).toBe(collapsed); + expect(expanded).not.toContain("Links:"); + expect(expanded).not.toContain("https://example.com"); }); it("renders canonical neutral Cursor activity labels", async () => { @@ -391,7 +391,7 @@ describe("extension native Cursor tool replay", () => { expect(rendered).not.toContain("cursor_"); }); - it("renders Cursor replay-only results with collapsed previews instead of summary-only cards", async () => { + it("renders Cursor-only results as summary-only cards", async () => { process.env.PI_CURSOR_NATIVE_TOOL_DISPLAY = "1"; mockedDiscover.mockResolvedValueOnce([]); const pi = createExtensionPi(); @@ -420,8 +420,9 @@ describe("extension native Cursor tool replay", () => { theme, context, )?.render(120).join("\n") ?? ""; - expect(todosRendered).toContain("Demo TodoWrite tool output"); - expect(todosRendered).toContain("Run remaining Cursor tools once"); + expect(todosRendered).toBe("Cursor todos 1/2 completed, 1 in progress"); + expect(todosRendered).not.toContain("Demo TodoWrite tool output"); + expect(todosRendered).not.toContain("Run remaining Cursor tools once"); const taskRendered = cursorTool.renderResult?.( { diff --git a/packages/pi-ext/README.md b/packages/pi-ext/README.md index bcd1a62..9df2a66 100644 --- a/packages/pi-ext/README.md +++ b/packages/pi-ext/README.md @@ -1,4 +1,4 @@ -# pi-ext +# @iurysza/pi-ext A practical extension pack for [Pi](https://pi.dev): command palettes, code review, session workflows, terminal integrations, compact UI, skills, and a @@ -40,8 +40,8 @@ Omit a resource type to load all of it. Use an empty array to load none. | [Code Review](extensions/review) | `/review` workflows for pull requests, branches, commits, and uncommitted changes. | | [pi-sem](extensions/pi-sem) | Entity-aware Git diff, context, history, blame, and impact tools powered by `sem`. | | [Pi Telescope](extensions/pi-telescope) | Native fuzzy finder for sessions, files, commands, and other providers. | -| [Custom Footer](extensions/custom-footer) | Compact status line with Git, token, context, timing, and model information. | -| [Tool Pills](extensions/tool-pills) | Compact tool labels, collapsible output, and highlighted write/edit diffs. | +| [Custom Footer](extensions/custom-footer) | Core session line plus one bounded prioritized extension-status line. | +| [Tool Presentation](extensions/tool-presentation) | Tidy compact built-in cards with lazy highlighted edit/write details. | | [Permissions](extensions/permissions) | Switchable `yolo`, `safe`, and `read-only` command policies. | | [Session Query](extensions/session-query) | Ask focused questions about previous Pi session files. | | [Session Store](extensions/session-store) | Search indexed session history with `/search`. | @@ -81,6 +81,24 @@ Use `/mode` to switch policy: Rules merge from project `.agents/permissions.json`, global `~/.pi/agent/permissions.json`, and built-ins. +### Compact tools and footer + +Tool Presentation is the sole owner of Pi's `read`, `bash`, `edit`, `write`, +`grep`, `find`, and `ls` cards. Collapsed output uses vendored Tidy cards; +expanded edit/write results lazily render highlighted diffs. `/tidy` controls +layout, icons, and optional pi-fff integration. Remove any separately installed +`@mobrienv/pi-tidy-tools` package to avoid ownership conflicts. + +When `@iurysza/pi-cursor-sdk` is also installed, built-in-equivalent Cursor +activity uses these same cards. Recorded results are consumed once through a +versioned callback protocol; missing or duplicate replay state fails before any +built-in executor runs. + +The footer keeps core session state on line one and packs native `setStatus()` +values onto one auxiliary line. Optional metadata sorts known slots by priority; +unregistered statuses remain compatible at priority zero. The highest-priority +slot is the only slot that may truncate. + ### Review and semantic tools `/review` can inspect a GitHub pull request, compare against a base branch, diff --git a/packages/pi-ext/extensions/tool-presentation/cursor-replay-broker.ts b/packages/pi-ext/extensions/tool-presentation/cursor-replay-broker.ts new file mode 100644 index 0000000..3bf30b8 --- /dev/null +++ b/packages/pi-ext/extensions/tool-presentation/cursor-replay-broker.ts @@ -0,0 +1,116 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export const CURSOR_REPLAY_PROTOCOL_VERSION = 1 as const; +export const CURSOR_REPLAY_HOST_READY = "@iurysza/pi-ext/cursor-replay/ready/v1"; +export const CURSOR_REPLAY_PRODUCER_REGISTER = "@iurysza/pi-ext/cursor-replay/register/v1"; +export const CURSOR_REPLAY_CONSUME = "@iurysza/pi-ext/cursor-replay/consume/v1"; +export const CURSOR_REPLAY_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const; + +const REPLAY_TOOL_NAMES = new Set(CURSOR_REPLAY_TOOL_NAMES); +const REPLAY_ID_PREFIX = "cursor-replay-"; + +export interface CursorReplayResult { + content: Array>; + details?: unknown; + terminate?: boolean; +} + +interface CursorReplayDelivery { + protocolVersion: typeof CURSOR_REPLAY_PROTOCOL_VERSION; + toolCallId: string; + toolName: string; + isError: boolean; + result: CursorReplayResult; +} + +function parseDelivery( + value: unknown, + toolCallId: string, + toolName: string, +): CursorReplayDelivery | undefined { + if (!value || typeof value !== "object") return undefined; + const delivery = value as Partial; + if ( + delivery.protocolVersion !== CURSOR_REPLAY_PROTOCOL_VERSION + || delivery.toolCallId !== toolCallId + || delivery.toolName !== toolName + || typeof delivery.isError !== "boolean" + || !delivery.result + || typeof delivery.result !== "object" + || !Array.isArray(delivery.result.content) + ) return undefined; + return delivery as CursorReplayDelivery; +} + +function replayErrorMessage(delivery: CursorReplayDelivery): string { + for (const entry of delivery.result.content) { + if (entry.type === "text" && typeof entry.text === "string" && entry.text.trim()) return entry.text.trim(); + } + return `Recorded Cursor ${delivery.toolName} replay failed`; +} + +export function isCursorReplayCall(toolCallId: string): boolean { + return toolCallId.startsWith(REPLAY_ID_PREFIX); +} + +export function createCursorReplayBroker(events: ExtensionAPI["events"]) { + let producerRegistered = false; + let active = false; + const announceHost = () => { + if (!active) return; + events.emit(CURSOR_REPLAY_HOST_READY, { + protocolVersion: CURSOR_REPLAY_PROTOCOL_VERSION, + toolNames: CURSOR_REPLAY_TOOL_NAMES, + }); + }; + const disposeProducer = events.on(CURSOR_REPLAY_PRODUCER_REGISTER, (data) => { + if ( + !data + || typeof data !== "object" + || (data as { protocolVersion?: unknown }).protocolVersion !== CURSOR_REPLAY_PROTOCOL_VERSION + ) return; + producerRegistered = true; + announceHost(); + }); + + return { + activate() { + active = true; + announceHost(); + }, + announceHost, + isProducerRegistered: () => producerRegistered, + isReplayCall: isCursorReplayCall, + consume(toolCallId: string, toolName: string): CursorReplayResult | undefined { + if (!isCursorReplayCall(toolCallId)) return undefined; + if (!REPLAY_TOOL_NAMES.has(toolName)) { + throw new Error(`Cursor replay cannot target unsupported tool ${toolName}.`); + } + + let delivery: CursorReplayDelivery | undefined; + events.emit(CURSOR_REPLAY_CONSUME, { + protocolVersion: CURSOR_REPLAY_PROTOCOL_VERSION, + toolCallId, + toolName, + accept(value: unknown) { + if (delivery) return; + delivery = parseDelivery(value, toolCallId, toolName); + }, + }); + if (!delivery) { + throw new Error(`No recorded Cursor ${toolName} result was available. This replay-only call never executes the underlying tool.`); + } + if (delivery.isError) throw new Error(replayErrorMessage(delivery)); + return { + content: delivery.result.content, + details: delivery.result.details, + terminate: delivery.result.terminate ?? true, + }; + }, + dispose() { + disposeProducer(); + producerRegistered = false; + active = false; + }, + }; +} diff --git a/packages/pi-ext/extensions/tool-presentation/index.ts b/packages/pi-ext/extensions/tool-presentation/index.ts index 6ec46de..a703848 100644 --- a/packages/pi-ext/extensions/tool-presentation/index.ts +++ b/packages/pi-ext/extensions/tool-presentation/index.ts @@ -1,28 +1,64 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { createCursorReplayBroker } from "./cursor-replay-broker.js"; import { - createTidyExtension, - type TidyExtensionDependencies, + createTidyExtension, + type TidyExtensionDependencies, } from "./tidy/index.js"; +import type { SourceToolDefinition } from "./tidy/tool-composition.js"; const BUILT_INS = new Set(["read", "write", "edit", "bash", "grep", "find", "ls"]); +type ReplayConsumer = (toolCallId: string, toolName: string) => unknown; + +export function wrapSourceForCursorReplay( + source: SourceToolDefinition, + consume: ReplayConsumer, +): SourceToolDefinition { + return { + ...source, + execute(this: SourceToolDefinition, toolCallId: string, ...args: any[]) { + const recorded = consume(toolCallId, source.name); + if (recorded) return recorded; + return source.execute.call(source, toolCallId, ...args); + }, + }; +} + export function createToolPresentation(dependencies: TidyExtensionDependencies = {}) { - const tidy = createTidyExtension(dependencies); - return async (pi: ExtensionAPI): Promise => { - pi.on("session_start", (_event, ctx) => { - const externalTidyTools = pi.getAllTools().filter((tool) => - BUILT_INS.has(tool.name) - && /(?:@mobrienv\/)?pi-tidy-tools/i.test(`${tool.sourceInfo.source} ${tool.sourceInfo.path}`), - ); - if (externalTidyTools.length > 0) { - ctx.ui.notify( - "Remove the separate @mobrienv/pi-tidy-tools package; pi-ext now owns compact built-in tool cards.", - "warning", - ); - } - }); - await tidy(pi); - }; + return async (pi: ExtensionAPI): Promise => { + const replay = createCursorReplayBroker(pi.events); + const tidy = createTidyExtension({ + ...dependencies, + isReplayCall(toolCallId) { + return replay.isReplayCall(toolCallId) || dependencies.isReplayCall?.(toolCallId) === true; + }, + decorateSource(source: SourceToolDefinition) { + const decorated = dependencies.decorateSource?.(source) ?? source; + return wrapSourceForCursorReplay(decorated, (toolCallId, toolName) => + replay.consume(toolCallId, toolName)); + }, + onToolsReady() { + dependencies.onToolsReady?.(); + replay.activate(); + }, + }); + + pi.on("session_start", (_event, ctx) => { + replay.announceHost(); + const externalTidyTools = pi.getAllTools().filter((tool) => + BUILT_INS.has(tool.name) + && /(?:@mobrienv\/)?pi-tidy-tools/i.test(`${tool.sourceInfo.source} ${tool.sourceInfo.path}`), + ); + if (externalTidyTools.length > 0) { + ctx.ui.notify( + "Remove the separate @mobrienv/pi-tidy-tools package; pi-ext now owns compact built-in tool cards.", + "warning", + ); + } + }); + pi.on("session_shutdown", () => replay.dispose()); + await tidy(pi); + }; } export default createToolPresentation(); diff --git a/packages/pi-ext/extensions/tool-presentation/tidy/index.ts b/packages/pi-ext/extensions/tool-presentation/tidy/index.ts index a1c7417..985999b 100644 --- a/packages/pi-ext/extensions/tool-presentation/tidy/index.ts +++ b/packages/pi-ext/extensions/tool-presentation/tidy/index.ts @@ -469,6 +469,7 @@ export interface TidyExtensionDependencies { createIntegration?: (pi: ExtensionAPI, cwd: string) => PiFffIntegrationController; decorateSource?: (source: SourceToolDefinition) => SourceToolDefinition; isReplayCall?: (toolCallId: string) => boolean; + onToolsReady?: () => void; } function previewText(preview: PiFffLifecyclePreview): string { @@ -644,6 +645,7 @@ export function createTidyExtension(dependencies: TidyExtensionDependencies = {} pi.registerTool(decorate(source) as any); } startupPlan.commit(decorate); + dependencies.onToolsReady?.(); }; } diff --git a/packages/pi-ext/tests/tool-presentation/cursor-replay-broker.test.ts b/packages/pi-ext/tests/tool-presentation/cursor-replay-broker.test.ts new file mode 100644 index 0000000..1965dc5 --- /dev/null +++ b/packages/pi-ext/tests/tool-presentation/cursor-replay-broker.test.ts @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { + CURSOR_REPLAY_CONSUME, + CURSOR_REPLAY_HOST_READY, + CURSOR_REPLAY_PRODUCER_REGISTER, + CURSOR_REPLAY_TOOL_NAMES, + createCursorReplayBroker, +} from "../../extensions/tool-presentation/cursor-replay-broker.js"; +import { wrapSourceForCursorReplay } from "../../extensions/tool-presentation/index.js"; +import type { SourceToolDefinition } from "../../extensions/tool-presentation/tidy/tool-composition.js"; + +function fakeEvents() { + const handlers = new Map void>>(); + return { + emit(channel: string, data: unknown) { + for (const handler of handlers.get(channel) ?? []) handler(data); + }, + on(channel: string, handler: (data: unknown) => void) { + const channelHandlers = handlers.get(channel) ?? new Set(); + channelHandlers.add(handler); + handlers.set(channel, channelHandlers); + return () => channelHandlers.delete(handler); + }, + } as ExtensionAPI["events"]; +} + +function registerProducer(events: ExtensionAPI["events"], replayItems = new Map()) { + const sharedTools = new Set(); + events.on(CURSOR_REPLAY_HOST_READY, (data) => { + const ready = data as { protocolVersion?: unknown; toolNames?: unknown }; + if (ready.protocolVersion !== 1 || !Array.isArray(ready.toolNames)) return; + sharedTools.clear(); + for (const toolName of ready.toolNames) if (typeof toolName === "string") sharedTools.add(toolName); + }); + events.on(CURSOR_REPLAY_CONSUME, (data) => { + const request = data as { + protocolVersion?: unknown; + toolCallId?: unknown; + toolName?: unknown; + accept?: (delivery: unknown) => void; + }; + if ( + request.protocolVersion !== 1 + || typeof request.toolCallId !== "string" + || typeof request.toolName !== "string" + || typeof request.accept !== "function" + ) return; + const item = replayItems.get(request.toolCallId); + if (!item) return; + replayItems.delete(request.toolCallId); + request.accept({ + protocolVersion: 1, + toolCallId: request.toolCallId, + toolName: request.toolName, + ...item, + }); + }); + const register = () => events.emit(CURSOR_REPLAY_PRODUCER_REGISTER, { + protocolVersion: 1, + producer: "@iurysza/pi-cursor-sdk", + }); + return { register, sharedTools, replayItems }; +} + +function sourceTool(name: string, onExecute: () => unknown): SourceToolDefinition { + return { + name, + parameters: { type: "object", properties: {} }, + execute: onExecute, + }; +} + +describe("Cursor replay handshake", () => { + it("does not advertise capabilities before tool ownership is active", () => { + const events = fakeEvents(); + let readyEvents = 0; + events.on(CURSOR_REPLAY_HOST_READY, () => { readyEvents += 1; }); + const broker = createCursorReplayBroker(events); + broker.announceHost(); + assert.equal(readyEvents, 0); + broker.activate(); + assert.equal(readyEvents, 1); + }); + + it("discovers a producer loaded before the host", () => { + const events = fakeEvents(); + const producer = registerProducer(events); + producer.register(); + const broker = createCursorReplayBroker(events); + broker.activate(); + assert.deepEqual([...producer.sharedTools], [...CURSOR_REPLAY_TOOL_NAMES]); + }); + + it("discovers a producer loaded after the host", () => { + const events = fakeEvents(); + const broker = createCursorReplayBroker(events); + broker.activate(); + const producer = registerProducer(events); + producer.register(); + assert.equal(broker.isProducerRegistered(), true); + assert.deepEqual([...producer.sharedTools], [...CURSOR_REPLAY_TOOL_NAMES]); + }); +}); + +describe("Cursor replay execution", () => { + it("consumes a recorded result exactly once without native execution", async () => { + const events = fakeEvents(); + const broker = createCursorReplayBroker(events); + const producer = registerProducer(events, new Map([ + ["cursor-replay-read-1", { + isError: false, + result: { content: [{ type: "text", text: "recorded" }], details: { source: "Cursor" } }, + }], + ])); + producer.register(); + let executions = 0; + const wrapped = wrapSourceForCursorReplay(sourceTool("read", () => { + executions += 1; + return { content: [{ type: "text", text: "native" }] }; + }), (id, name) => broker.consume(id, name)); + + assert.deepEqual(await wrapped.execute("cursor-replay-read-1", {}, undefined, undefined, undefined), { + content: [{ type: "text", text: "recorded" }], + details: { source: "Cursor" }, + terminate: true, + }); + assert.equal(executions, 0); + assert.throws( + () => wrapped.execute("cursor-replay-read-1", {}, undefined, undefined, undefined), + /No recorded Cursor read result/, + ); + assert.equal(executions, 0); + }); + + it("fails closed for missing replay state on every built-in", () => { + const events = fakeEvents(); + const broker = createCursorReplayBroker(events); + let executions = 0; + for (const name of CURSOR_REPLAY_TOOL_NAMES) { + const wrapped = wrapSourceForCursorReplay(sourceTool(name, () => { + executions += 1; + }), (id, toolName) => broker.consume(id, toolName)); + assert.throws( + () => wrapped.execute(`cursor-replay-${name}-missing`, {}, undefined, undefined, undefined), + new RegExp(`No recorded Cursor ${name} result`), + ); + } + assert.equal(executions, 0); + }); + + it("delegates ordinary Pi calls and rethrows recorded Cursor errors", async () => { + const events = fakeEvents(); + const broker = createCursorReplayBroker(events); + const producer = registerProducer(events, new Map([ + ["cursor-replay-bash-error", { + isError: true, + result: { content: [{ type: "text", text: "recorded command failed" }] }, + }], + ])); + producer.register(); + let executions = 0; + const wrapped = wrapSourceForCursorReplay(sourceTool("bash", () => { + executions += 1; + return { content: [] }; + }), (id, name) => broker.consume(id, name)); + + await wrapped.execute("ordinary-bash", {}, undefined, undefined, undefined); + assert.equal(executions, 1); + assert.throws( + () => wrapped.execute("cursor-replay-bash-error", {}, undefined, undefined, undefined), + /recorded command failed/, + ); + assert.equal(executions, 1); + }); +}); diff --git a/packages/pi-ext/tests/tool-presentation/tsconfig.json b/packages/pi-ext/tests/tool-presentation/tsconfig.json index 0d55e7b..d3e0856 100644 --- a/packages/pi-ext/tests/tool-presentation/tsconfig.json +++ b/packages/pi-ext/tests/tool-presentation/tsconfig.json @@ -17,6 +17,7 @@ "../../extensions/custom-footer/**/*.ts", "../../extensions/tool-presentation/**/*.ts", "../custom-footer/**/*.test.ts", + "./*.test.ts", "./tidy/**/*.test.ts" ] } diff --git a/packages/pi-token-tank/README.md b/packages/pi-token-tank/README.md index 7d4d9e1..10afdd2 100644 --- a/packages/pi-token-tank/README.md +++ b/packages/pi-token-tank/README.md @@ -1,4 +1,4 @@ -# pi-token-tank +# @iurysza/pi-token-tank See your subscription mileage without leaving Pi. Token Tank follows the active model and adds provider quota, usage pressure, and reset timing to the footer. @@ -30,7 +30,7 @@ Authenticate the providers you use, then restart Pi or run `/reload`: | GitHub Copilot | Pi `/login github-copilot` | Monthly premium requests | | Cursor | Registered Pi Cursor provider plus `CURSOR_SESSION_TOKEN` | Billing-cycle total, Auto, and API | -Unsupported providers produce no footer status. +Unsupported providers produce no footer status. Token Tank always publishes through Pi's native `setStatus()` API, so it works alone. When `@iurysza/pi-ext` is installed, it also advertises priority 100 metadata for pi-ext's bounded auxiliary footer line. ## Footer modes