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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# @react-grab/cli

## 0.1.47

## 0.1.46

## 0.1.45
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@react-grab/cli",
"version": "0.1.46",
"version": "0.1.47",
"repository": {
"type": "git",
"url": "git+https://github.com/aidenybai/react-grab.git"
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ const CONFIG_OPTIONS: ConfigOption[] = [
{
id: "maxContextLines",
title: "Max Context Lines",
description: "Number of surrounding code lines to include in context",
description: "Max source-location lines in copied context (raise for large apps)",
},
];

Expand Down
114 changes: 114 additions & 0 deletions packages/cli/test/install-skill.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { vi, describe, expect, it, beforeEach } from "vite-plus/test";
import { join } from "node:path";

vi.mock("node:fs", () => ({
existsSync: vi.fn(),
rmSync: vi.fn(),
}));

vi.mock("agent-install/skill", () => ({
add: vi.fn(),
getCanonicalSkillsDir: vi.fn(),
getSkillAgentConfig: vi.fn(),
getSkillAgentDir: vi.fn(),
isUniversalSkillAgent: vi.fn(),
}));

vi.mock("../src/utils/detect-agents.js", () => ({
detectAvailableAgents: vi.fn(),
}));

import { existsSync, rmSync } from "node:fs";
import {
add,
getCanonicalSkillsDir,
getSkillAgentDir,
isUniversalSkillAgent,
} from "agent-install/skill";
import { detectAvailableAgents } from "../src/utils/detect-agents.js";
import { installSkill, removeSkill } from "../src/utils/install-skill.js";

const mockExistsSync = vi.mocked(existsSync);
const mockRmSync = vi.mocked(rmSync);
const mockAdd = vi.mocked(add);
const mockGetCanonicalSkillsDir = vi.mocked(getCanonicalSkillsDir);
const mockGetSkillAgentDir = vi.mocked(getSkillAgentDir);
const mockIsUniversalSkillAgent = vi.mocked(isUniversalSkillAgent);
const mockDetectAvailableAgents = vi.mocked(detectAvailableAgents);

beforeEach(() => {
vi.clearAllMocks();
mockIsUniversalSkillAgent.mockReturnValue(false);
mockGetSkillAgentDir.mockImplementation((agent, options) => `${options.cwd}/.${agent}`);
mockGetCanonicalSkillsDir.mockImplementation((_global, cwd) => `${cwd}/.agents/skills`);
mockAdd.mockResolvedValue({ installed: [], failed: [] } as never);
});

describe("installSkill", () => {
it("installs to detected agents by default in copy mode", async () => {
mockDetectAvailableAgents.mockResolvedValue(["claude-code", "cursor"] as never);

await installSkill({ cwd: "/app" });

expect(mockDetectAvailableAgents).toHaveBeenCalledTimes(1);
expect(mockAdd).toHaveBeenCalledWith(
expect.objectContaining({
source: expect.stringContaining(join("skills", "react-grab")),
agents: ["claude-code", "cursor"],
global: false,
cwd: "/app",
mode: "copy",
}),
);
});

it("uses explicit agents without running detection", async () => {
await installSkill({ agents: ["codex"] as never, cwd: "/app", global: true });

expect(mockDetectAvailableAgents).not.toHaveBeenCalled();
expect(mockAdd).toHaveBeenCalledWith(
expect.objectContaining({ agents: ["codex"], global: true, cwd: "/app", mode: "copy" }),
);
});
});

describe("removeSkill", () => {
it("removes skill directories that exist and returns the removed agents", async () => {
mockDetectAvailableAgents.mockResolvedValue(["claude-code", "cursor"] as never);
mockExistsSync.mockImplementation((path) => `${path}`.includes(".claude-code"));

const removed = await removeSkill({ cwd: "/app" });

expect(removed).toEqual(["claude-code"]);
expect(mockRmSync).toHaveBeenCalledTimes(1);
expect(mockRmSync).toHaveBeenCalledWith(join("/app/.claude-code", "react-grab"), {
recursive: true,
force: true,
});
});

it("returns an empty array and removes nothing when no skill is installed", async () => {
mockDetectAvailableAgents.mockResolvedValue(["claude-code"] as never);
mockExistsSync.mockReturnValue(false);

const removed = await removeSkill({ cwd: "/app" });

expect(removed).toEqual([]);
expect(mockRmSync).not.toHaveBeenCalled();
});

it("resolves universal agents to the canonical skills directory", async () => {
mockDetectAvailableAgents.mockResolvedValue(["universal"] as never);
mockIsUniversalSkillAgent.mockReturnValue(true);
mockExistsSync.mockReturnValue(true);

const removed = await removeSkill({ cwd: "/app", global: true });

expect(removed).toEqual(["universal"]);
expect(mockGetCanonicalSkillsDir).toHaveBeenCalledWith(true, "/app");
expect(mockRmSync).toHaveBeenCalledWith(join("/app/.agents/skills", "react-grab"), {
recursive: true,
force: true,
});
});
});
6 changes: 6 additions & 0 deletions packages/grab/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# grab

## 0.1.47

### Patch Changes

- @react-grab/cli@0.1.47

## 0.1.46

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/grab/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "grab",
"version": "0.1.46",
"version": "0.1.47",
"description": "Select context for coding agents directly from your website",
"keywords": [
"agent",
Expand Down
10 changes: 10 additions & 0 deletions packages/react-grab/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# react-grab

## 0.1.47

### Patch Changes

- 5407d4e: Surface deeper copy context for wrapper-heavy elements. App-owned shared-UI / design-system frames (files under `components/ui/`, `packages/ui/`, `design-system(s)/`, or `primitives/`, e.g. shadcn's `components/ui` or a monorepo `packages/ui`) are now treated like `node_modules` frames: still shown, but exempt from the compact line budget, so a grabbed wrapper digs through its UI primitives to the meaningful feature source by default. Adds a `maxContextLines` option (also settable via the script `data-options` attribute) to raise the budget further for large apps and agent/edit prompts — restoring the option the CLI already writes.

Also hardens the trace: a non-finite/negative `maxContextLines` no longer disables the hard line cap (it falls back to the default), and consecutive duplicate trace lines from shared-UI frames are collapsed so the output stays readable.

- @react-grab/cli@0.1.47

## 0.1.46

### Patch Changes
Expand Down
10 changes: 9 additions & 1 deletion packages/react-grab/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,14 @@ Even after resolving source locations, the component hierarchy often contains fr

`getComponentDisplayName` walks up the fiber tree from the target element and returns the first composite fiber whose display name passes all of these filters. The result is typically the user's own component - the one they'd actually want to find in their editor.

### Context line budget

`formatStackContext` in [core/context.ts](../src/core/context.ts) caps how many lines the copied trace contains using two limits. The _soft budget_ (`maxLines`, defaulting to `DEFAULT_MAX_CONTEXT_LINES` = 3) keeps the common case compact, and the _hard cap_ (`MAX_TRACE_CONTEXT_LINES` = 20) bounds the worst case. Only high-signal app-source frames spend the soft budget; low-signal frames are surfaced "for free" and count only against the hard cap, so wrapper noise never crowds out the meaningful source.

Low-signal covers two kinds of frame. The first is library frames from `node_modules`, which render by component name (`in Tabs (@radix-ui/react-tabs)`) rather than competing with app paths. The second is app-owned shared-UI / design-system frames - files under a `components/ui/`, `packages/ui/`, `design-system(s)/`, or `primitives/` segment (shadcn's `components/ui`, a monorepo `packages/ui`, headless primitives), detected by [utils/is-shared-ui-source-path.ts](../src/utils/is-shared-ui-source-path.ts). A bare `ui/` segment is deliberately excluded so Next's `app/ui/` feature convention is not mistaken for a primitive library. These are real source files, so they're still shown with their path and still satisfy the "trusted source" check that suppresses the selector-hint fallback - they just don't spend the budget. This is what lets a wrapper-heavy element (common in large Next apps) dig through its UI primitives to the actual feature surface by default.

When the default still isn't deep enough, the public `maxContextLines` option raises the soft budget. It flows from `Options` through the plugin registry into the copy flow and the `getStackContext` API, and can also be set via the script tag's `data-options` attribute.

### Opening in editor

The final step of the pipeline is [utils/open-file.ts](../src/utils/open-file.ts), which takes a resolved file path and optional line number and tries to open it in the user's code editor. It first attempts the dev server's built-in open-in-editor endpoint: `/__open-in-editor` for Vite or `/__nextjs_launch-editor` for Next.js. Both of these dev servers include middleware that launches the user's configured `$EDITOR` (or `$VISUAL`) with the file path and line number, so the file opens directly in their editor without any browser interaction.
Expand Down Expand Up @@ -199,6 +207,6 @@ The registry provides several ways to call hooks, each suited to a different use

The three built-in plugins are registered during `init()` through the same `register()` path that external plugins use, so there is nothing architecturally special about them:

- **copy** registers the default "Copy" context-menu action that copies a single-line `[<tag …> in Component (at path:line) …]` reference per selected element, including up to `DEFAULT_MAX_CONTEXT_LINES` (3) frames from the component stack.
- **copy** registers the default "Copy" context-menu action that copies a single-line `[<tag …> in Component (at path:line) …]` reference per selected element, including up to `DEFAULT_MAX_CONTEXT_LINES` (3) budgeted frames from the component stack (raise via the `maxContextLines` option; see "Context line budget" below).
- **comment** registers the "Comment" action that enters prompt mode.
- **open** registers the "Open in editor" action that calls `openFile` with the resolved source location, running the URL through the `transformOpenFileUrl` hook pipeline first.
28 changes: 28 additions & 0 deletions packages/react-grab/e2e/api-methods.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,34 @@ test.describe("API Methods", () => {
});
});

test.describe("maxContextLines via setOptions", () => {
test("raising maxContextLines surfaces more source lines than the compact default", async ({
reactGrab,
}) => {
const getStackLineCount = (selector: string) =>
reactGrab.page.evaluate(async (sel) => {
const api = (
window as {
__REACT_GRAB__?: { getStackContext: (el: Element) => Promise<string> };
}
).__REACT_GRAB__;
const element = document.querySelector(sel);
if (!api || !element) return -1;
const text = await api.getStackContext(element);
return text.split("\n").filter(Boolean).length;
}, selector);

await reactGrab.updateOptions({ maxContextLines: 1 });
const compactLineCount = await getStackLineCount("[data-testid='nested-button']");

await reactGrab.updateOptions({ maxContextLines: 12 });
const detailedLineCount = await getStackLineCount("[data-testid='nested-button']");

expect(compactLineCount).toBeGreaterThanOrEqual(1);
expect(detailedLineCount).toBeGreaterThan(compactLineCount);
});
});

test.describe("dispose()", () => {
test("should set hasInited to false on dispose", async ({ reactGrab }) => {
await reactGrab.activate();
Expand Down
2 changes: 1 addition & 1 deletion packages/react-grab/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-grab",
"version": "0.1.46",
"version": "0.1.47",
"description": "Select context for coding agents directly from your website",
"keywords": [
"agent",
Expand Down
12 changes: 12 additions & 0 deletions packages/react-grab/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ export const INPUT_TEXT_SELECTION_ACTIVATION_DELAY_MS = 600;
export const DEFAULT_KEY_HOLD_DURATION_MS = 100;
export const DEFAULT_MAX_CONTEXT_LINES = 3;
export const MAX_TRACE_CONTEXT_LINES = 20;
// Path segments marking app-owned reusable UI directories (shadcn's
// components/ui, a monorepo packages/ui, headless primitives). A bare `/ui/`
// is deliberately excluded: Next's App Router convention places feature code
// under `app/ui/`, so matching any `ui` segment would demote real features.
// See is-shared-ui-source-path for how these are treated.
export const SHARED_UI_SOURCE_PATH_SEGMENTS: readonly string[] = [
"/components/ui/",
"/packages/ui/",
"/design-system/",
"/design-systems/",
"/primitives/",
];
export const SYMBOLICATION_TIMEOUT_MS = 5000;
export const MIN_HOLD_FOR_ACTIVATION_AFTER_COPY_MS = 200;
export const FINDER_TIMEOUT_MS = 200;
Expand Down
59 changes: 41 additions & 18 deletions packages/react-grab/src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import {
traverseFiber,
type Fiber,
} from "bippy";
import { DEFAULT_MAX_CONTEXT_LINES, MAX_TRACE_CONTEXT_LINES } from "../constants.js";
import { MAX_TRACE_CONTEXT_LINES } from "../constants.js";
import { resolveMaxContextLines } from "../utils/resolve-max-context-lines.js";
import { normalizeFilePath } from "../utils/normalize-file-path.js";
import {
classifySourcePath,
type SourcePathClassification,
} from "../utils/classify-source-path.js";
import { createElementSelector } from "../utils/create-element-selector.js";
import { isSharedUiSourcePath } from "../utils/is-shared-ui-source-path.js";
import { isNextProjectRuntime } from "../utils/is-next-project-runtime.js";
import { enrichServerFrameLocations, symbolicateServerFrames } from "./next-server-frames.js";
import { getHTMLPreview, getInlineHTMLPreview } from "./html-preview.js";
Expand Down Expand Up @@ -263,9 +265,18 @@ const formatSourceContextLine = (source: SourceLocation, isNextProject: boolean)

interface StackFrameLine {
text: string;
isTrustedSource: boolean;
// A real app-owned source file: suppresses the CSS selector-hint fallback.
isAppSource: boolean;
// High-signal app source that spends the line budget. Shared-UI frames are
// app source but free, like package frames.
consumesBudget: boolean;
}

const LOW_SIGNAL_FRAME: Pick<StackFrameLine, "isAppSource" | "consumesBudget"> = {
isAppSource: false,
consumesBudget: false,
};

const formatStackFrameLine = (
frame: StackFrame,
sourceClassification: SourcePathClassification,
Expand All @@ -282,7 +293,7 @@ const formatStackFrameLine = (
const serverTag = libraryPackage ? `${libraryPackage} at Server` : "at Server";
return {
text: `\n in ${componentName ?? "<anonymous>"} (${serverTag})`,
isTrustedSource: false,
...LOW_SIGNAL_FRAME,
};
}

Expand All @@ -291,12 +302,12 @@ const formatStackFrameLine = (
text: libraryPackage
? `\n in ${componentName} (${libraryPackage})`
: `\n in ${componentName}`,
isTrustedSource: false,
...LOW_SIGNAL_FRAME,
};
}

if (libraryPackage) {
return { text: `\n in ${libraryPackage}`, isTrustedSource: false };
return { text: `\n in ${libraryPackage}`, ...LOW_SIGNAL_FRAME };
}

if (appSourceFilePath) {
Expand All @@ -310,7 +321,8 @@ const formatStackFrameLine = (
},
isNextProject,
),
isTrustedSource: true,
isAppSource: true,
consumesBudget: !isSharedUiSourcePath(appSourceFilePath),
};
}

Expand All @@ -322,9 +334,11 @@ export const formatStackContext = (
options: StackContextOptions = {},
leadingSource: ResolvedSource | null = null,
): TraceContextResult => {
const { maxLines = DEFAULT_MAX_CONTEXT_LINES } = options;
// max, not min: the extended cap must sit above the soft budget (min would
// collapse it onto maxLines and disable extension entirely).
const maxLines = resolveMaxContextLines(options.maxLines);
// max, not min: the extended cap must sit above the soft budget. A
// caller-raised maxContextLines is allowed to lift the hard cap past
// MAX_TRACE_CONTEXT_LINES on purpose (opting into a deeper trace); min would
// collapse the cap onto maxLines and disable the free low-signal extension.
const hardMaxLines = Math.max(maxLines, MAX_TRACE_CONTEXT_LINES);
const isNextProject = isNextProjectRuntime();
const lines: string[] = [];
Expand All @@ -335,14 +349,18 @@ export const formatStackContext = (

if (leadingSource) {
hasTrustedSource = leadingSource.origin === "app";
budgetedLineCount += 1;
// A shared-UI leading source means the user grabbed a primitive directly;
// keep its budget free so the feature ancestors that consume it surface.
if (!isSharedUiSourcePath(leadingSource.filePath)) budgetedLineCount += 1;
lines.push(formatSourceContextLine(leadingSource, isNextProject));
}

for (const frame of stack) {
// Low-signal lines (no app file path) are free: they never consume the
// soft budget, only the hard cap, so library noise never crowds out app
// source locations.
// maxLines is the budget for high-signal app-source frames. Low-signal
// lines (library frames and shared-UI/design-system app frames) are free:
// they never consume the soft budget, only the hard cap, so wrapper noise
// never crowds out the meaningful app source locations. maxLines of 0 is
// therefore the minimal trace: only the leading source line, if any.
if (budgetedLineCount >= maxLines || lines.length >= hardMaxLines) break;

const sourceClassification = classifySourcePath(frame.fileName);
Expand Down Expand Up @@ -373,10 +391,15 @@ export const formatStackContext = (
);
if (frameLine === null) continue;

if (frameLine.isTrustedSource) {
hasTrustedSource = true;
budgetedLineCount += 1;
}
// Shared-UI frames are now surfaced for free, so a single primitives file
// (e.g. several sidebar parts, or a recursive component) can emit the same
// line repeatedly - especially under bundlers where we omit line numbers and
// identical-looking frames collapse to the same text. Skip consecutive
// duplicates so the trace stays readable.
if (frameLine.text === lines[lines.length - 1]) continue;

if (frameLine.isAppSource) hasTrustedSource = true;
if (frameLine.consumesBudget) budgetedLineCount += 1;
lines.push(frameLine.text);
previousLibraryFrameKey = libraryFrameKey;
}
Expand Down Expand Up @@ -406,7 +429,7 @@ const getTraceContext = async (

const componentNames = getComponentNamesFromFiber(
findNearestFiberElement(element),
options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES,
resolveMaxContextLines(options.maxLines),
);
if (componentNames.length > 0) {
return {
Expand Down
Loading
Loading