Compare commits

..

1 Commits

Author SHA1 Message Date
Shoubhit Dash e6c9b6bef7 feat(core): add firecrawl web search (#41042) 2026-08-07 16:51:09 +05:30
7 changed files with 91 additions and 125 deletions
@@ -134,22 +134,6 @@ test("does not pull a keyboard-scrolled user during shell remeasurement", async
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
})
test("accumulates rapid page key presses", async ({ page }) => {
await setupTimeline(page, {
messages: history(80),
viewport: { width: 1400, height: 700 },
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await scroller.focus()
const before = await scroller.evaluate((element) => ({ top: element.scrollTop, height: element.clientHeight }))
for (let index = 0; index < 3; index++) await scroller.press("PageUp")
await page.waitForTimeout(150)
expect(before.top - (await scroller.evaluate((element) => element.scrollTop))).toBeGreaterThan(before.height * 2.2)
})
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
const shellID = "prt_descendant_keyboard_01_shell"
const timeline = await setupTimeline(page, {
@@ -0,0 +1,84 @@
export * as WebSearchFirecrawl from "./firecrawl"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Option, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://mcp.firecrawl.dev/v2/mcp"
const McpInput = Schema.Struct({
query: Schema.String,
limit: Schema.Number.pipe(Schema.optional),
})
const McpOutput = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
})
const SearchResponse = Schema.fromJsonString(
Schema.Struct({
success: Schema.Boolean,
data: Schema.Struct({
web: Schema.Array(
Schema.Struct({
url: Schema.String,
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
description: Schema.NullOr(Schema.String).pipe(Schema.optional),
}),
),
}),
}),
)
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.firecrawl",
effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
draft.method.update({
integrationID: "firecrawl",
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "firecrawl",
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
id: "firecrawl",
name: "Firecrawl",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("firecrawl")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"firecrawl_search",
{ input: McpInput, output: McpOutput },
{ query: input.query, limit: 8 },
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
const content = result?.content.find((item) => item.text)
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
return (
response?.data.web.map((item) => ({
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.description ? { content: item.description } : {}),
time: {},
})) ?? []
)
}),
})
})
}),
})
+2 -1
View File
@@ -1,4 +1,5 @@
import { WebSearchExa } from "./exa"
import { WebSearchFirecrawl } from "./firecrawl"
import { WebSearchParallel } from "./parallel"
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const
@@ -462,6 +462,7 @@ function newLayout() {
function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search"
if (provider === "firecrawl") return "Firecrawl Web Search"
return "Web Search"
}
+1
View File
@@ -22,6 +22,7 @@ export function primitiveInputSummary(input: Record<string, unknown>, omit: read
export function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search"
if (provider === "firecrawl") return "Firecrawl Web Search"
return "Web Search"
}
+1 -48
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { canScrollKey, createKeyboardScroll, scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
import { canScrollKey, scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
describe("scrollKey", () => {
test("maps plain navigation keys", () => {
@@ -39,53 +39,6 @@ describe("canScrollKey", () => {
})
})
describe("createKeyboardScroll", () => {
test("accumulates repeated page movement and settles quickly", () => {
const harness = keyboardScrollHarness(0)
harness.scroll.move(800)
harness.advance(30)
harness.scroll.move(800)
harness.advance(150)
expect(harness.element.scrollTop).toBe(1_600)
})
test("reverses from the current position instead of the queued target", () => {
const harness = keyboardScrollHarness(1_000)
harness.scroll.move(800)
harness.advance(30)
const current = harness.element.scrollTop
harness.scroll.move(-800)
harness.advance(150)
expect(harness.element.scrollTop).toBe(current - 800)
})
})
function keyboardScrollHarness(scrollTop: number) {
const element = { scrollTop, clientHeight: 1_000, scrollHeight: 10_000 }
const callbacks = new Map<number, FrameRequestCallback>()
let time = 0
let handle = 0
const scroll = createKeyboardScroll(element, {
now: () => time,
requestFrame: (callback) => {
callbacks.set(++handle, callback)
return handle
},
cancelFrame: (id) => callbacks.delete(id),
})
const advance = (next: number) => {
time = next
const queued = [...callbacks.values()]
callbacks.clear()
queued.forEach((callback) => callback(time))
}
return { element, scroll, advance }
}
describe("scrollTopFromThumbPointer", () => {
test("keeps downward thumb movement monotonic when content height changes", () => {
const first = scrollTopFromThumbPointer({
+2 -60
View File
@@ -79,56 +79,6 @@ export function isScrollKeyTarget(target: EventTarget | null, key: NonNullable<R
return true
}
export function createKeyboardScroll(
element: Pick<HTMLElement, "scrollTop" | "scrollHeight" | "clientHeight">,
options: {
now?: () => number
requestFrame?: (callback: FrameRequestCallback) => number
cancelFrame?: (handle: number) => void
duration?: number
} = {},
) {
const now = options.now ?? (() => performance.now())
const requestFrame = options.requestFrame ?? ((callback) => requestAnimationFrame(callback))
const cancelFrame = options.cancelFrame ?? ((handle) => cancelAnimationFrame(handle))
const duration = options.duration ?? 120
let frame: number | undefined
let start = 0
let from = element.scrollTop
let target = element.scrollTop
let direction = 0
const tick = (time: number) => {
const progress = Math.min(1, (time - start) / duration)
element.scrollTop = from + (target - from) * (1 - Math.pow(1 - progress, 3))
if (progress < 1) {
frame = requestFrame(tick)
return
}
frame = undefined
}
const move = (amount: number) => {
const nextDirection = Math.sign(amount)
const base = frame !== undefined && direction === nextDirection ? target : element.scrollTop
if (frame !== undefined) cancelFrame(frame)
from = element.scrollTop
target = Math.max(0, Math.min(base + amount, element.scrollHeight - element.clientHeight))
direction = nextDirection
start = now()
frame = requestFrame(tick)
}
const cancel = () => {
if (frame !== undefined) cancelFrame(frame)
frame = undefined
target = element.scrollTop
direction = 0
}
return { move, cancel }
}
export function scrollTopFromThumbPointer(input: {
pointer: number
viewportTop: number
@@ -203,7 +153,6 @@ export function ScrollView(props: ScrollViewProps) {
const showThumb = () => state.showThumb
let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined
let keyboardScroll: ReturnType<typeof createKeyboardScroll> | undefined
const markScrolling = () => {
setState("isScrolling", true)
@@ -219,7 +168,6 @@ export function ScrollView(props: ScrollViewProps) {
onCleanup(() => {
if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer)
keyboardScroll?.cancel()
})
const updateThumb = () => {
@@ -254,7 +202,6 @@ export function ScrollView(props: ScrollViewProps) {
}
onMount(() => {
keyboardScroll = createKeyboardScroll(viewportRef)
if (local.viewportRef) {
local.viewportRef(viewportRef)
}
@@ -359,30 +306,26 @@ export function ScrollView(props: ScrollViewProps) {
switch (next) {
case "page-down":
e.preventDefault()
keyboardScroll?.move(scrollAmount)
viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" })
break
case "page-up":
e.preventDefault()
keyboardScroll?.move(-scrollAmount)
viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" })
break
case "home":
e.preventDefault()
keyboardScroll?.cancel()
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
break
case "end":
e.preventDefault()
keyboardScroll?.cancel()
viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
break
case "up":
e.preventDefault()
keyboardScroll?.cancel()
viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" })
break
case "down":
e.preventDefault()
keyboardScroll?.cancel()
viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" })
break
}
@@ -412,7 +355,6 @@ export function ScrollView(props: ScrollViewProps) {
if (typeof events.onScroll === "function") events.onScroll(e as any)
}}
onWheel={(e) => {
keyboardScroll?.cancel()
markScrolling()
const handler = events.onWheel
if (typeof handler === "function") handler(e as any)