From 5407d4ee7838790e6b461c1fc83f42808b662cc2 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 18 Jun 2026 02:58:50 -0700 Subject: [PATCH 1/2] Extend copy context past shared-UI wrappers and add maxContextLines option (#478) * Extend copy context past shared-UI wrappers and add maxContextLines option Co-authored-by: Aiden Bai * Align CLI maxContextLines description with restored option semantics Co-authored-by: Aiden Bai * Harden maxContextLines against non-finite values that disabled the trace cap Co-authored-by: Aiden Bai * Collapse consecutive duplicate trace lines from shared-UI frames Co-authored-by: Aiden Bai * Document trace hardening in changeset Co-authored-by: Aiden Bai * Trim duplicated rationale comments in context budget logic Co-authored-by: Aiden Bai * Scope shared-UI detection to components/ui and packages/ui A bare `/ui/` segment also matched Next's `app/ui/` feature convention, demoting real feature code to budget-free and inflating the trace. Narrow to the actual shared-UI conventions, document the maxContextLines budget edge cases, and add an e2e test covering the option end-to-end. * fix * Make install-skill test path assertions cross-platform The skill source path and removal dirs are built with node:path.join / fileURLToPath, which emit backslashes on Windows. Assert against join() output instead of hardcoded forward-slash paths so the test passes on windows-latest. --------- Co-authored-by: Cursor Agent Co-authored-by: Aiden Bai --- .changeset/configurable-copy-context-depth.md | 7 ++ packages/cli/src/commands/configure.ts | 2 +- packages/cli/test/install-skill.test.ts | 114 ++++++++++++++++++ packages/react-grab/docs/architecture.md | 10 +- packages/react-grab/e2e/api-methods.spec.ts | 28 +++++ packages/react-grab/src/constants.ts | 12 ++ packages/react-grab/src/core/context.ts | 59 ++++++--- packages/react-grab/src/core/copy.ts | 22 +++- packages/react-grab/src/core/index.tsx | 4 +- .../react-grab/src/core/plugin-registry.ts | 5 +- packages/react-grab/src/types.ts | 10 ++ .../src/utils/get-script-options.ts | 3 + .../src/utils/is-shared-ui-source-path.ts | 13 ++ .../src/utils/resolve-max-context-lines.ts | 10 ++ packages/react-grab/tests/context.test.ts | 103 ++++++++++++++++ .../tests/is-shared-ui-source-path.test.ts | 41 +++++++ .../tests/resolve-max-context-lines.test.ts | 22 ++++ 17 files changed, 437 insertions(+), 28 deletions(-) create mode 100644 .changeset/configurable-copy-context-depth.md create mode 100644 packages/cli/test/install-skill.test.ts create mode 100644 packages/react-grab/src/utils/is-shared-ui-source-path.ts create mode 100644 packages/react-grab/src/utils/resolve-max-context-lines.ts create mode 100644 packages/react-grab/tests/is-shared-ui-source-path.test.ts create mode 100644 packages/react-grab/tests/resolve-max-context-lines.test.ts diff --git a/.changeset/configurable-copy-context-depth.md b/.changeset/configurable-copy-context-depth.md new file mode 100644 index 000000000..9c3940a40 --- /dev/null +++ b/.changeset/configurable-copy-context-depth.md @@ -0,0 +1,7 @@ +--- +"react-grab": patch +--- + +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. diff --git a/packages/cli/src/commands/configure.ts b/packages/cli/src/commands/configure.ts index 40a5ed883..f83114ffa 100644 --- a/packages/cli/src/commands/configure.ts +++ b/packages/cli/src/commands/configure.ts @@ -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)", }, ]; diff --git a/packages/cli/test/install-skill.test.ts b/packages/cli/test/install-skill.test.ts new file mode 100644 index 000000000..933f4151c --- /dev/null +++ b/packages/cli/test/install-skill.test.ts @@ -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, + }); + }); +}); diff --git a/packages/react-grab/docs/architecture.md b/packages/react-grab/docs/architecture.md index 3ed54a103..6e3bff852 100644 --- a/packages/react-grab/docs/architecture.md +++ b/packages/react-grab/docs/architecture.md @@ -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. @@ -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 `[ 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 `[ 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. diff --git a/packages/react-grab/e2e/api-methods.spec.ts b/packages/react-grab/e2e/api-methods.spec.ts index 02df3d1fd..92d5a1493 100644 --- a/packages/react-grab/e2e/api-methods.spec.ts +++ b/packages/react-grab/e2e/api-methods.spec.ts @@ -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 }; + } + ).__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(); diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 80c021140..37ac5ba32 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -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; diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 0549f8f62..165668cf5 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -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"; @@ -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 = { + isAppSource: false, + consumesBudget: false, +}; + const formatStackFrameLine = ( frame: StackFrame, sourceClassification: SourcePathClassification, @@ -282,7 +293,7 @@ const formatStackFrameLine = ( const serverTag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; return { text: `\n in ${componentName ?? ""} (${serverTag})`, - isTrustedSource: false, + ...LOW_SIGNAL_FRAME, }; } @@ -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) { @@ -310,7 +321,8 @@ const formatStackFrameLine = ( }, isNextProject, ), - isTrustedSource: true, + isAppSource: true, + consumesBudget: !isSharedUiSourcePath(appSourceFilePath), }; } @@ -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[] = []; @@ -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); @@ -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; } @@ -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 { diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index 203c76495..2f9cd6b25 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -8,6 +8,7 @@ import type { ReactGrabEntry, ReactGrabStackFrame } from "../types.js"; interface CopyFlowOptions { getContent?: (elements: Element[]) => Promise | string; componentName?: string; + maxContextLines?: number; } interface CopyFlowHooks { @@ -33,10 +34,14 @@ const formatStackFramePayload = (frame: StackFrame): ReactGrabStackFrame => ({ isSymbolicated: frame.isSymbolicated, }); -const buildElementPayloadEntry = async (element: Element): Promise => { +const buildElementPayloadEntry = async ( + element: Element, + maxContextLines?: number, +): Promise => { + const stackOptions = { maxLines: maxContextLines }; const [referenceContext, stackContext, source, stack] = await Promise.all([ - getElementReferenceContext(element), - getStackContext(element), + getElementReferenceContext(element, stackOptions), + getStackContext(element, stackOptions), resolveSource(element), getStack(element), ]); @@ -50,8 +55,13 @@ const buildElementPayloadEntry = async (element: Element): Promise => { - const rawEntries = await Promise.all(elements.map(buildElementPayloadEntry)); +const buildClipboardPayload = async ( + elements: Element[], + maxContextLines?: number, +): Promise => { + const rawEntries = await Promise.all( + elements.map((element) => buildElementPayloadEntry(element, maxContextLines)), + ); const entriesByContent = new Map(); for (const entry of rawEntries) { if (!entriesByContent.has(entry.content)) { @@ -99,7 +109,7 @@ export const runCopyFlow = async ( try { const payload: CopyPayload | null = options.getContent ? { content: await options.getContent(elements) } - : await buildClipboardPayload(elements); + : await buildClipboardPayload(elements, options.maxContextLines); const rawContent = payload?.content; if (rawContent?.trim()) { diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 8b76b2840..f03d015e9 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -653,6 +653,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { { getContent: pluginRegistry.store.options.getContent, componentName: elementName, + maxContextLines: pluginRegistry.store.options.maxContextLines, }, pluginRegistry.hooks, elements, @@ -3786,7 +3787,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { componentName: source.componentName, }; }, - getStackContext, + getStackContext: (element: Element) => + getStackContext(element, { maxLines: pluginRegistry.store.options.maxContextLines }), getState: (): ReactGrabState => ({ isActive: isActivated(), isDragging: isDragging(), diff --git a/packages/react-grab/src/core/plugin-registry.ts b/packages/react-grab/src/core/plugin-registry.ts index 690affc03..2d2f07c3e 100644 --- a/packages/react-grab/src/core/plugin-registry.ts +++ b/packages/react-grab/src/core/plugin-registry.ts @@ -20,7 +20,7 @@ import type { ActionContext, } from "../types.js"; import { DEFAULT_THEME, deepMergeTheme } from "./theme.js"; -import { DEFAULT_KEY_HOLD_DURATION_MS } from "../constants.js"; +import { DEFAULT_KEY_HOLD_DURATION_MS, DEFAULT_MAX_CONTEXT_LINES } from "../constants.js"; interface RegisteredPlugin { plugin: Plugin; @@ -33,6 +33,7 @@ interface OptionsState { allowActivationInsideInput: boolean; activationKey: ActivationKey | undefined; getContent: ((elements: Element[]) => Promise | string) | undefined; + maxContextLines: number; freezeReactUpdates: boolean; } @@ -42,6 +43,7 @@ const DEFAULT_OPTIONS: OptionsState = { allowActivationInsideInput: true, activationKey: undefined, getContent: undefined, + maxContextLines: DEFAULT_MAX_CONTEXT_LINES, freezeReactUpdates: true, }; @@ -105,6 +107,7 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { "allowActivationInsideInput", "activationKey", "getContent", + "maxContextLines", "freezeReactUpdates", ]; diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 93c80c392..accefb823 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -349,6 +349,16 @@ export interface Options { allowActivationInsideInput?: boolean; activationKey?: ActivationKey; getContent?: (elements: Element[]) => Promise | string; + /** + * Maximum number of source-location lines included in the copied / prompted + * context for a grabbed element. Larger apps often render a target through + * several wrapper components, so the compact default can point an agent at a + * wrapper instead of the meaningful surface. Raise this to opt into a deeper, + * more detailed trace. Low-signal library frames are always surfaced for free + * and never count against this budget. + * @default 3 + */ + maxContextLines?: number; /** * Whether to freeze React state updates while React Grab is active. * This prevents UI changes from interfering with element selection. diff --git a/packages/react-grab/src/utils/get-script-options.ts b/packages/react-grab/src/utils/get-script-options.ts index e0a267786..d5b15084a 100644 --- a/packages/react-grab/src/utils/get-script-options.ts +++ b/packages/react-grab/src/utils/get-script-options.ts @@ -24,6 +24,9 @@ const parseOptionsFromJson = (rawValue: unknown): Partial | null => { if (typeof rawValue.activationKey === "string") { parsedOptions.activationKey = rawValue.activationKey; } + if (typeof rawValue.maxContextLines === "number" && Number.isFinite(rawValue.maxContextLines)) { + parsedOptions.maxContextLines = rawValue.maxContextLines; + } if (typeof rawValue.freezeReactUpdates === "boolean") { parsedOptions.freezeReactUpdates = rawValue.freezeReactUpdates; } diff --git a/packages/react-grab/src/utils/is-shared-ui-source-path.ts b/packages/react-grab/src/utils/is-shared-ui-source-path.ts new file mode 100644 index 000000000..dcbd63475 --- /dev/null +++ b/packages/react-grab/src/utils/is-shared-ui-source-path.ts @@ -0,0 +1,13 @@ +import { SHARED_UI_SOURCE_PATH_SEGMENTS } from "../constants.js"; +import { normalizeFilePath } from "./normalize-file-path.js"; + +// Reusable UI building blocks (shadcn components/ui, a monorepo design system, +// headless primitives) are app-owned but low-signal: they wrap many features +// without being any one feature's source. We surface them but, like package +// frames, exempt them from the compact line budget so a wrapper-heavy trace can +// reach the meaningful surface underneath. +export const isSharedUiSourcePath = (fileName: string | null | undefined): boolean => { + if (!fileName) return false; + const normalizedPath = `/${normalizeFilePath(fileName)}/`.toLowerCase(); + return SHARED_UI_SOURCE_PATH_SEGMENTS.some((segment) => normalizedPath.includes(segment)); +}; diff --git a/packages/react-grab/src/utils/resolve-max-context-lines.ts b/packages/react-grab/src/utils/resolve-max-context-lines.ts new file mode 100644 index 000000000..6e14cea2e --- /dev/null +++ b/packages/react-grab/src/utils/resolve-max-context-lines.ts @@ -0,0 +1,10 @@ +import { DEFAULT_MAX_CONTEXT_LINES } from "../constants.js"; + +// A NaN/Infinity maxLines would make the budget comparisons never break (and +// disable the hard cap), dumping the entire owner stack; a negative or +// fractional value is equally nonsensical. Coerce to a non-negative integer and +// fall back to the default for anything non-finite. +export const resolveMaxContextLines = (maxLines: number | undefined): number => { + if (maxLines === undefined || !Number.isFinite(maxLines)) return DEFAULT_MAX_CONTEXT_LINES; + return Math.max(0, Math.floor(maxLines)); +}; diff --git a/packages/react-grab/tests/context.test.ts b/packages/react-grab/tests/context.test.ts index 2d53b52c6..329223cf3 100644 --- a/packages/react-grab/tests/context.test.ts +++ b/packages/react-grab/tests/context.test.ts @@ -5,6 +5,7 @@ import { selectResolvedSource, type ResolvedSource, } from "../src/core/context.js"; +import { MAX_TRACE_CONTEXT_LINES } from "../src/constants.js"; const fiberSource: ResolvedSource = { filePath: "/src/app/page.tsx", @@ -111,6 +112,108 @@ describe("formatStackContext", () => { expect(result.text).not.toContain("app/layout.tsx"); }); + it("does not let shared-UI wrapper frames spend the compact line budget", () => { + const result = formatStackContext( + [ + { fileName: "src/components/ui/sidebar.tsx", functionName: "Sidebar" }, + { fileName: "src/components/ui/sidebar.tsx", functionName: "SidebarContent" }, + { fileName: "src/components/ui/button.tsx", functionName: "Button" }, + { fileName: "src/app/dashboard/page.tsx", functionName: "DashboardPage" }, + { fileName: "src/app/layout.tsx", functionName: "RootLayout" }, + ], + { maxLines: 3 }, + ); + + expect(result.text).toContain("components/ui/sidebar.tsx"); + expect(result.text).toContain("components/ui/button.tsx"); + expect(result.text).toContain("app/dashboard/page.tsx"); + expect(result.text).toContain("app/layout.tsx"); + expect(result.shouldAppendSelectorHint).toBe(false); + }); + + it("surfaces deeper feature source past a wrapper chain without a selector hint", () => { + const result = formatStackContext( + [ + { fileName: "src/components/ui/dialog.tsx", functionName: "Dialog" }, + { fileName: "src/components/ui/dialog.tsx", functionName: "DialogContent" }, + { fileName: "src/components/ui/scroll-area.tsx", functionName: "ScrollArea" }, + { fileName: "src/features/builder/builder.tsx", functionName: "Builder" }, + ], + { maxLines: 1 }, + ); + + expect(result.text).toContain("features/builder/builder.tsx"); + expect(result.shouldAppendSelectorHint).toBe(false); + }); + + it("honors a raised maxLines to surface more feature source", () => { + const stack: StackFrame[] = [ + { fileName: "src/app/a.tsx", functionName: "A" }, + { fileName: "src/app/b.tsx", functionName: "B" }, + { fileName: "src/app/c.tsx", functionName: "C" }, + { fileName: "src/app/d.tsx", functionName: "D" }, + { fileName: "src/app/e.tsx", functionName: "E" }, + ]; + + const compact = formatStackContext(stack, { maxLines: 3 }); + expect(compact.text.split("\n").filter(Boolean)).toHaveLength(3); + + const detailed = formatStackContext(stack, { maxLines: 5 }); + expect(detailed.text.split("\n").filter(Boolean)).toHaveLength(5); + expect(detailed.text).toContain("app/e.tsx"); + }); + + it("collapses consecutive duplicate trace lines", () => { + const result = formatStackContext([ + { fileName: "src/components/ui/sidebar.tsx", functionName: "SidebarMenu" }, + { fileName: "src/components/ui/sidebar.tsx", functionName: "SidebarMenu" }, + { fileName: "src/components/ui/sidebar.tsx", functionName: "SidebarMenu" }, + { fileName: "src/app/page.tsx", functionName: "Page" }, + ]); + + const sidebarLineCount = result.text + .split("\n") + .filter((line) => line.includes("SidebarMenu")).length; + expect(sidebarLineCount).toBe(1); + expect(result.text).toContain("app/page.tsx"); + }); + + it("keeps non-consecutive repeats of the same line", () => { + const result = formatStackContext([ + { fileName: "src/components/ui/card.tsx", functionName: "Card" }, + { fileName: "src/app/section.tsx", functionName: "Section" }, + { fileName: "src/components/ui/card.tsx", functionName: "Card" }, + ]); + + const cardLineCount = result.text.split("\n").filter((line) => line.includes("in Card")).length; + expect(cardLineCount).toBe(2); + }); + + it("keeps the hard cap when maxLines is non-finite", () => { + const stack: StackFrame[] = Array.from({ length: 40 }, (_unused, index) => ({ + fileName: `src/app/feature-${index}.tsx`, + functionName: `Feature${index}`, + })); + + for (const invalidMaxLines of [Number.NaN, Number.POSITIVE_INFINITY, -5]) { + const result = formatStackContext(stack, { maxLines: invalidMaxLines }); + const lines = result.text.split("\n").filter(Boolean); + expect(lines.length).toBeLessThanOrEqual(MAX_TRACE_CONTEXT_LINES); + } + }); + + it("falls back to the default budget when maxLines is NaN", () => { + const stack: StackFrame[] = [ + { fileName: "src/app/a.tsx", functionName: "A" }, + { fileName: "src/app/b.tsx", functionName: "B" }, + { fileName: "src/app/c.tsx", functionName: "C" }, + { fileName: "src/app/d.tsx", functionName: "D" }, + ]; + + const result = formatStackContext(stack, { maxLines: Number.NaN }); + expect(result.text.split("\n").filter(Boolean)).toHaveLength(3); + }); + it("digs past low-signal package frames to surface a deeper app source", () => { const result = formatStackContext([ { fileName: "node_modules/react-tabs/dist/index.js", functionName: "Tabs" }, diff --git a/packages/react-grab/tests/is-shared-ui-source-path.test.ts b/packages/react-grab/tests/is-shared-ui-source-path.test.ts new file mode 100644 index 000000000..bb748f7a7 --- /dev/null +++ b/packages/react-grab/tests/is-shared-ui-source-path.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; +import { isSharedUiSourcePath } from "../src/utils/is-shared-ui-source-path.js"; + +describe("isSharedUiSourcePath", () => { + it("matches shadcn-style components/ui paths", () => { + expect(isSharedUiSourcePath("src/components/ui/sidebar.tsx")).toBe(true); + expect(isSharedUiSourcePath("/Users/me/app/src/components/ui/button.tsx")).toBe(true); + }); + + it("matches monorepo design-system packages and headless primitives", () => { + expect(isSharedUiSourcePath("packages/ui/src/card.tsx")).toBe(true); + expect(isSharedUiSourcePath("src/design-system/tokens.ts")).toBe(true); + expect(isSharedUiSourcePath("src/primitives/dialog.tsx")).toBe(true); + }); + + it("is case-insensitive", () => { + expect(isSharedUiSourcePath("src/components/UI/Sidebar.tsx")).toBe(true); + }); + + it("does not match feature source paths", () => { + expect(isSharedUiSourcePath("app/(dashboard)/builder/[id]/page.tsx")).toBe(false); + expect(isSharedUiSourcePath("src/features/builder/builder.tsx")).toBe(false); + expect(isSharedUiSourcePath("src/components/header.tsx")).toBe(false); + }); + + it("does not match Next's app/ui feature convention", () => { + expect(isSharedUiSourcePath("app/ui/dashboard/cards.tsx")).toBe(false); + expect(isSharedUiSourcePath("src/app/(dashboard)/ui/settings.tsx")).toBe(false); + expect(isSharedUiSourcePath("ui/button.tsx")).toBe(false); + }); + + it("does not match a filename that merely starts with ui", () => { + expect(isSharedUiSourcePath("src/components/uikit-banner.tsx")).toBe(false); + }); + + it("handles empty input", () => { + expect(isSharedUiSourcePath(null)).toBe(false); + expect(isSharedUiSourcePath(undefined)).toBe(false); + expect(isSharedUiSourcePath("")).toBe(false); + }); +}); diff --git a/packages/react-grab/tests/resolve-max-context-lines.test.ts b/packages/react-grab/tests/resolve-max-context-lines.test.ts new file mode 100644 index 000000000..2f67fe938 --- /dev/null +++ b/packages/react-grab/tests/resolve-max-context-lines.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; +import { resolveMaxContextLines } from "../src/utils/resolve-max-context-lines.js"; +import { DEFAULT_MAX_CONTEXT_LINES } from "../src/constants.js"; + +describe("resolveMaxContextLines", () => { + it("passes through valid non-negative integers", () => { + expect(resolveMaxContextLines(0)).toBe(0); + expect(resolveMaxContextLines(3)).toBe(3); + expect(resolveMaxContextLines(20)).toBe(20); + }); + + it("falls back to the default for undefined or non-finite values", () => { + expect(resolveMaxContextLines(undefined)).toBe(DEFAULT_MAX_CONTEXT_LINES); + expect(resolveMaxContextLines(Number.NaN)).toBe(DEFAULT_MAX_CONTEXT_LINES); + expect(resolveMaxContextLines(Number.POSITIVE_INFINITY)).toBe(DEFAULT_MAX_CONTEXT_LINES); + }); + + it("clamps negatives to zero and floors fractions", () => { + expect(resolveMaxContextLines(-5)).toBe(0); + expect(resolveMaxContextLines(3.9)).toBe(3); + }); +}); From d5e8fc8452685af108c9aec6a02c74f19b010217 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:13:05 -0700 Subject: [PATCH 2/2] chore: version packages (#480) --- .changeset/configurable-copy-context-depth.md | 7 ------- packages/cli/CHANGELOG.md | 2 ++ packages/cli/package.json | 2 +- packages/grab/CHANGELOG.md | 6 ++++++ packages/grab/package.json | 2 +- packages/react-grab/CHANGELOG.md | 10 ++++++++++ packages/react-grab/package.json | 2 +- 7 files changed, 21 insertions(+), 10 deletions(-) delete mode 100644 .changeset/configurable-copy-context-depth.md diff --git a/.changeset/configurable-copy-context-depth.md b/.changeset/configurable-copy-context-depth.md deleted file mode 100644 index 9c3940a40..000000000 --- a/.changeset/configurable-copy-context-depth.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"react-grab": patch ---- - -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. diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 16d83a85e..e6f3d92d1 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,7 @@ # @react-grab/cli +## 0.1.47 + ## 0.1.46 ## 0.1.45 diff --git a/packages/cli/package.json b/packages/cli/package.json index 95efa453a..2de006c2f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" diff --git a/packages/grab/CHANGELOG.md b/packages/grab/CHANGELOG.md index d4b9ba27c..b038fdb6e 100644 --- a/packages/grab/CHANGELOG.md +++ b/packages/grab/CHANGELOG.md @@ -1,5 +1,11 @@ # grab +## 0.1.47 + +### Patch Changes + +- @react-grab/cli@0.1.47 + ## 0.1.46 ### Patch Changes diff --git a/packages/grab/package.json b/packages/grab/package.json index 0d19e6001..ca89a9f13 100644 --- a/packages/grab/package.json +++ b/packages/grab/package.json @@ -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", diff --git a/packages/react-grab/CHANGELOG.md b/packages/react-grab/CHANGELOG.md index b7ba66412..92977b048 100644 --- a/packages/react-grab/CHANGELOG.md +++ b/packages/react-grab/CHANGELOG.md @@ -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 diff --git a/packages/react-grab/package.json b/packages/react-grab/package.json index 6ff5a2f20..8584510a8 100644 --- a/packages/react-grab/package.json +++ b/packages/react-grab/package.json @@ -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",