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/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/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/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/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", 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); + }); +});