diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 5330b52e9c..2e81848081 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -82,6 +82,7 @@ upstream maintainers — see README → Syncing with upstream for why and how. | Env-var rename, repo-wide (~230 files): both flag registries (`packages/core/src/flag/flag.ts`, `packages/opencode/src/effect/runtime-flags.ts`), every `process.env`/`Bun.env` read+set site across `packages/*`, `sdks/vscode`, `infra/enterprise.ts`, `nix/opencode.nix`, `turbo.json`, `.github/workflows/test.yml`, test preloads/fixtures/snapshots, docs (en + es), `SECURITY.md`, specs | `OPENCODE_*` env vars → `CODEV_*` (hard cutover, no legacy fallback), including the bundler define identifiers (`CODEV_VERSION`, `CODEV_CHANNEL`, `CODEV_MODELS_DEV`, `CODEV_LIBC`, `CODEV_CLI_NAME`, `CODEV_WORKER_PATH`), `VITE_OPENCODE_*` → `VITE_CODEV_*`, `import.meta.env.OPENCODE_CHANNEL` → `CODEV_CHANNEL`, the VSCode extension's `OPENCODE_CALLER`/`_EXTENSION_OPENCODE_PORT`, and the bare `OPENCODE=1` marker → `CODEV=1` | The fork's env surface uses the fork's name (reverses the earlier "internal names stay upstream" decision for env vars). NOT renamed: `__OPENCODE_PHOTON_WASM_PATH` and `__OPENCODE__` globals (not env vars; the photon name is baked into `patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch`), `patches/install-korean-ime-fix.sh` script-local shell vars, upstream secret names in workflow-deletion notes, lowercase identifiers (`@opencode-ai/*`, URLs, provider id) | Keep our names; rename any new upstream `OPENCODE_*` env var (or define/`VITE_` name) to `CODEV_*` when syncing. | | Locale dicts in `packages/{ui,app}/src/i18n/`, `packages/desktop/src/renderer/i18n/`, `packages/console/app/src/i18n/`, `packages/stats/app/src/i18n/`, `packages/web/src/content/i18n/`; translated docs dirs in `packages/web/src/content/docs/` | Deleted every locale except `en`/`es` (incl. `packages/app/src/i18n/parity.test.ts`) | Fork ships English + Spanish only. | Keep deleted (`git rm` the upstream side of modify/delete conflicts); also delete any locale files upstream adds. | | `packages/app/src/context/language.tsx`, `packages/app/src/entry.tsx`, `packages/desktop/src/renderer/i18n/index.ts`, `packages/console/app/src/i18n/index.ts`, `packages/console/app/src/lib/language.ts`, `packages/console/app/src/component/footer.tsx`, `packages/stats/app/src/i18n.ts`, `packages/web/src/i18n/locales.ts`, `packages/web/astro.config.mjs`, `packages/enterprise/src/app.tsx`, `packages/app/e2e/regression/session-timeline-locale-projection.spec.ts` | Locale registries trimmed to `en` + `es`: `Locale` types, LOCALES/LABEL/TAG/DOCS maps, dict loaders, accept-language matchers, starlight `locales` config; enterprise/entry `zh` special cases now use `es`; console footer always links Discord (Feishu was zh-only); e2e locale-projection spec tests `es` ("Explorado") instead of de/ar | Fork ships English + Spanish only. | Keep our trimmed lists; take upstream's structural changes and re-trim any new locale entries. | +| `packages/tui/src/component/prompt/autocomplete.tsx`, `packages/app/src/components/prompt-input.tsx`, `packages/opencode/src/cli/cmd/run/footer.prompt.tsx`, `packages/core/src/filesystem/search.ts`, `packages/tui/test/fixture/tui-sdk.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx`, `packages/core/test/filesystem/search.test.ts` | `@` mention autocomplete lists file/directory results before references/agents/MCP resources; TUI and run-CLI menus cap the leading files so up to 3 non-file matches stay in the first visible page; web app group rank reordered to recent → file → reference → agent → resource (extracted into fork-only `prompt-input/at-options.ts`). TUI and run-CLI file resources track menu visibility so opening on an empty query fetches (previously nothing refetched until the query changed); core's ripgrep-fallback `find` returns its first entries for an empty query to mirror fff. Tests: tui-sdk fixture's `FetchHandler` gains an optional `method` arg, `renderFooter` accepts `findFiles`/`agents` overrides + a mention-menu ordering test, core search tests cover the fallback's empty query (fork-only test files: `packages/tui/test/prompt-autocomplete.test.tsx`, `packages/app/test-browser/prompt-at-options.test.ts`, `packages/app/src/components/prompt-input/at-options{,.test}.ts`) | Files are the dominant `@` use case; they should rank first and appear on a bare `@`. | Keep our ordering/reserve logic, resource source tracking, empty-query branch, fixture/test additions; take upstream's surrounding changes. | ## Known rebrand gaps (deliberate, revisit before external distribution) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 324a789825..45310b7fa7 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -65,6 +65,7 @@ import { } from "./prompt-input/history" import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" +import { atOptionCategory, compareAtCategories } from "./prompt-input/at-options" import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" @@ -756,23 +757,8 @@ export const PromptInput: Component = (props) => { key: atKey, filterKeys: ["display"], skipFilter: (item) => item.type === "file" && !item.recent, - groupBy: (item) => { - if (item.type === "reference") return "reference" - if (item.type === "agent") return "agent" - if (item.type === "resource") return "resource" - if (item.recent) return "recent" - return "file" - }, - sortGroupsBy: (a, b) => { - const rank = (category: string) => { - if (category === "reference") return 0 - if (category === "agent") return 1 - if (category === "resource") return 2 - if (category === "recent") return 3 - return 4 - } - return rank(a.category) - rank(b.category) - }, + groupBy: atOptionCategory, + sortGroupsBy: compareAtCategories, onSelect: handleAtSelect, }) diff --git a/packages/app/src/components/prompt-input/at-options.test.ts b/packages/app/src/components/prompt-input/at-options.test.ts new file mode 100644 index 0000000000..7f8dcfbe6e --- /dev/null +++ b/packages/app/src/components/prompt-input/at-options.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test" +import type { AtOption } from "./slash-popover" +import { atOptionCategory, compareAtCategories } from "./at-options" + +const options: AtOption[] = [ + { type: "reference", name: "effect", path: "/refs/effect", display: "effect", description: "" }, + { type: "agent", name: "general", display: "general" }, + { type: "resource", name: "docs", uri: "mcp://docs", client: "mcp", display: "docs" }, + { type: "file", path: "src/open.ts", display: "src/open.ts", recent: true }, + { type: "file", path: "src/found.ts", display: "src/found.ts" }, +] + +describe("atOptionCategory", () => { + test("categorizes every option shape", () => { + expect(options.map(atOptionCategory)).toEqual(["reference", "agent", "resource", "recent", "file"]) + }) +}) + +describe("compareAtCategories", () => { + test("ranks file groups before references, agents, and resources", () => { + const sorted = ["reference", "agent", "resource", "recent", "file"] + .map((category) => ({ category })) + .sort(compareAtCategories) + .map((group) => group.category) + expect(sorted).toEqual(["recent", "file", "reference", "agent", "resource"]) + }) +}) diff --git a/packages/app/src/components/prompt-input/at-options.ts b/packages/app/src/components/prompt-input/at-options.ts new file mode 100644 index 0000000000..c0771335b4 --- /dev/null +++ b/packages/app/src/components/prompt-input/at-options.ts @@ -0,0 +1,23 @@ +import type { AtOption } from "./slash-popover" + +export function atOptionCategory(item: AtOption) { + if (item.type === "reference") return "reference" + if (item.type === "agent") return "agent" + if (item.type === "resource") return "resource" + if (item.recent) return "recent" + return "file" +} + +// Files are the dominant @ use case, so file groups (recent open tabs, then +// search results) rank ahead of references, agents, and MCP resources. +const rank = (category: string) => { + if (category === "recent") return 0 + if (category === "file") return 1 + if (category === "reference") return 2 + if (category === "agent") return 3 + return 4 +} + +export function compareAtCategories(a: { category: string }, b: { category: string }) { + return rank(a.category) - rank(b.category) +} diff --git a/packages/app/test-browser/prompt-at-options.test.ts b/packages/app/test-browser/prompt-at-options.test.ts new file mode 100644 index 0000000000..77ca3bb494 --- /dev/null +++ b/packages/app/test-browser/prompt-at-options.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import type { AtOption } from "@/components/prompt-input/slash-popover" +import { atOptionCategory, compareAtCategories } from "@/components/prompt-input/at-options" + +const options: AtOption[] = [ + { type: "reference", name: "effect", path: "/refs/effect", display: "effect", description: "" }, + { type: "agent", name: "general", display: "general" }, + { type: "resource", name: "docs", uri: "mcp://docs", client: "mcp", display: "docs" }, + { type: "file", path: "src/open.ts", display: "src/open.ts", recent: true }, + { type: "file", path: "src/found.ts", display: "src/found.ts" }, +] + +describe("@ menu ordering through useFilteredList", () => { + test("flattens with files first on an empty query", async () => { + await createRoot(async (dispose) => { + const list = useFilteredList({ + items: options, + key: (item) => `${item.type}:${item.display}`, + filterKeys: ["display"], + skipFilter: (item) => item.type === "file" && !item.recent, + groupBy: atOptionCategory, + sortGroupsBy: compareAtCategories, + }) + // grouped is a resource; wait for it to settle. + for (let i = 0; i < 100 && list.flat().length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + expect(list.flat().map((item) => item.display)).toEqual([ + "src/open.ts", + "src/found.ts", + "effect", + "general", + "docs", + ]) + dispose() + }) + }) +}) diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index bd7eb90b70..575b645dfa 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -106,14 +106,15 @@ export const ripgrepLayer = Layer.effect( : input.type === "directory" ? state.directories : [...state.files, ...state.directories] - return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => { - const relative = item.target - const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const) - return FileSystem.Entry.make({ + const entry = (relative: string) => + FileSystem.Entry.make({ path: RelativePath.make(relative), - type, + type: relative.endsWith(path.sep) ? "directory" : "file", }) - }) + // fuzzysort matches nothing on an empty needle; mirror fff, which + // returns its top-ranked entries for an empty query. + if (!input.query) return items.slice(0, input.limit ?? 50).map(entry) + return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => entry(item.target)) }), }) }), diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index 6c47c85e96..8d25e3a1ac 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -1,10 +1,15 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { FileSystemSearch } from "@opencode-ai/core/filesystem/search" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Location } from "@opencode-ai/core/location" import { Ripgrep } from "@opencode-ai/core/ripgrep" -import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" +import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" @@ -42,3 +47,71 @@ describe("Ripgrep", () => { ), ) }) + +const searchLayer = (directory: AbsolutePath) => + FileSystemSearch.ripgrepLayer.pipe( + Layer.provide( + Layer.mergeAll( + LayerNode.compile(FSUtil.node), + LayerNode.compile(Ripgrep.node), + Layer.succeed(Location.Service, Location.Service.of(location({ directory }))), + ), + ), + ) + +// The layer scans files in a background fiber; poll until entries appear. +const findSettled = (query: string, limit?: number) => + Effect.gen(function* () { + const search = yield* FileSystemSearch.Service + for (let i = 0; i < 100; i++) { + const found = yield* search.find( + FileSystem.FindInput.make({ query, limit: limit === undefined ? undefined : PositiveInt.make(limit) }), + ) + if (found.length > 0) return found + yield* Effect.sleep("50 millis") + } + return [] + }) + +describe("FileSystemSearch ripgrep fallback", () => { + it.live("returns entries for an empty query", () => + withTmp((cwd) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src"))) + yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n")) + yield* Effect.promise(() => fs.writeFile(path.join(cwd, "README.md"), "# readme\n")) + const entries = yield* findSettled("") + expect(entries.map((entry) => entry.path).sort()).toEqual([ + RelativePath.make("README.md"), + RelativePath.make("src" + path.sep), + RelativePath.make("src/match.ts"), + ]) + expect(entries.find((entry) => entry.path === "src" + path.sep)?.type).toBe("directory") + }).pipe(Effect.provide(searchLayer(cwd))), + ), + ) + + it.live("respects the limit for an empty query", () => + withTmp((cwd) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.writeFile(path.join(cwd, "a.ts"), "a\n")) + yield* Effect.promise(() => fs.writeFile(path.join(cwd, "b.ts"), "b\n")) + const entries = yield* findSettled("", 1) + expect(entries).toHaveLength(1) + }).pipe(Effect.provide(searchLayer(cwd))), + ), + ) + + it.live("still fuzzy-matches a non-empty query", () => + withTmp((cwd) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src"))) + yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n")) + yield* Effect.promise(() => fs.writeFile(path.join(cwd, "unrelated.md"), "x\n")) + const entries = yield* findSettled("match") + expect(entries.map((entry) => entry.path)).toContain(RelativePath.make("src/match.ts")) + expect(entries.map((entry) => entry.path)).not.toContain(RelativePath.make("unrelated.md")) + }).pipe(Effect.provide(searchLayer(cwd))), + ), + ) +}) diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 85531970a0..33bbd1dedf 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -358,13 +358,15 @@ export function createPromptState(input: PromptInput): PromptState { })) }) const [files] = createResource( - query, - async (value) => { - if (!visible() || mode() !== "mention") { + // Track visibility and mode so opening the mention menu with an empty + // query fetches; the fetcher body is untracked. + () => ({ value: query(), visible: visible(), mode: mode() }), + async (source) => { + if (!source.visible || source.mode !== "mention") { return [] } - const next = extractLineRange(value) + const next = extractLineRange(source.value) const list = await input.findFiles(next.base) return list.map((item): Auto => { const url = pathToFileURL(path.resolve(input.directory, item)) @@ -402,7 +404,13 @@ export function createPromptState(input: PromptInput): PromptState { }, { initialValue: [] as Auto[] }, ) - const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()]) + const mentionOptions = createMemo(() => { + // Files lead, but reserve a few visible rows so agents and MCP resources + // stay reachable without scrolling. + const nonFiles = [...agents(), ...resources()] + const head = AUTOCOMPLETE_ROWS - Math.min(nonFiles.length, 3) + return [...files().slice(0, head), ...nonFiles, ...files().slice(head)] + }) const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill")) const hasSkillsCommand = createMemo(() => (input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"), @@ -459,11 +467,13 @@ export function createPromptState(input: PromptInput): PromptState { const next = removeLineRange(query()) if (mode() === "mention") { - return [ + const nonFiles = [ ...fuzzysort.go(next, agents(), { keys: ["value", "display", "description"] }).map((item) => item.obj), - ...files(), ...fuzzysort.go(next, resources(), { keys: ["value", "display", "description"] }).map((item) => item.obj), ] + // Same row reservation as mentionOptions. + const head = AUTOCOMPLETE_ROWS - Math.min(nonFiles.length, 3) + return [...files().slice(0, head), ...nonFiles, ...files().slice(head)] } return fuzzysort diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 2e9fd8ef17..6b9802a9fb 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -24,6 +24,7 @@ import type { FooterSubagentState, FooterSubagentTab, FooterView, + RunAgent, RunCommand, RunInput, RunPrompt, @@ -166,6 +167,8 @@ async function renderFooter( state?: Partial onCycle?: () => void onSubmit?: (prompt: RunPrompt) => boolean + findFiles?: (query: string) => Promise + agents?: () => RunAgent[] } = {}, ) { const [view] = createSignal({ type: "prompt" }) @@ -185,8 +188,8 @@ async function renderFooter( []} - agents={() => []} + findFiles={input.findFiles ?? (async () => [])} + agents={input.agents ?? (() => [])} resources={() => []} commands={() => input.commands ?? []} providers={() => input.providers} @@ -805,6 +808,35 @@ test("direct footer submits slash autocomplete selections without dispatching sh } }) +test("mention menu fetches on a bare @ and lists files before agents", async () => { + const app = await renderFooter({ + findFiles: async () => ["src/alpha.ts", "src/beta.ts"], + agents: () => [{ name: "reviewer", mode: "subagent" } as RunAgent], + }) + + try { + await app.renderOnce() + app.mockInput.pressKey("@") + + // findFiles is an async resource; poll until the menu settles. + const start = Date.now() + let frame = "" + while (!frame.includes("@reviewer") || !frame.includes("@src/alpha.ts")) { + if (Date.now() - start > 5000) throw new Error("mention menu did not settle:\n" + frame) + await app.renderOnce() + frame = app.captureCharFrame() + } + + const rows = ["@src/alpha.ts", "@src/beta.ts", "@reviewer"] + for (const row of rows) { + expect(frame).toContain(row) + } + expect(rows.map((row) => frame.indexOf(row))).toEqual(rows.map((row) => frame.indexOf(row)).toSorted((a, b) => a - b)) + } finally { + app.cleanup() + } +}) + test("direct footer slash autocomplete keeps a real skills command", async () => { const submits: RunPrompt[] = [] const app = await renderFooter({ diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 099fa9d83e..c8af49c639 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -314,9 +314,11 @@ export function Autocomplete(props: { } const [files] = createResource( - () => ({ query: search(), location: location() }), + // Track visibility so opening the menu with an empty query fetches; + // the fetcher body is untracked, so reading store.visible there is not enough. + () => ({ query: search(), location: location(), visible: store.visible }), async (input) => { - if (!store.visible || store.visible === "/") return [] + if (input.visible !== "@") return [] if (referenceMatch()) return [] const { lineRange, baseQuery } = extractLineRange(input.query ?? "") @@ -492,7 +494,10 @@ export function Autocomplete(props: { store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue] if (!searchValue) { - return [...nonFileOptions, ...fileOptions] + // Files lead, but reserve up to 3 of the 10 visible rows so references, + // agents, and MCP resources stay reachable without scrolling. + const head = 10 - Math.min(nonFileOptions.length, 3) + return [...fileOptions.slice(0, head), ...nonFileOptions, ...fileOptions.slice(head)] } if (files.loading && prev && prev.length > 0) { @@ -521,7 +526,9 @@ export function Autocomplete(props: { }) .map((arr) => arr.obj) - return [...fuzziedNonFiles, ...fileOptions].slice(0, 10) + // Same reservation as the empty-query branch, within the 10-item cap. + const head = 10 - Math.min(fuzziedNonFiles.length, 3) + return [...fileOptions.slice(0, head), ...fuzziedNonFiles].slice(0, 10) }) createEffect(() => { diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index d1cf3c7dfc..2a94138379 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -59,14 +59,15 @@ export function createEventSource() { } } -export type FetchHandler = (url: URL) => Response | Promise | undefined +export type FetchHandler = (url: URL, method?: string) => Response | Promise | undefined export function createFetch(override?: FetchHandler, events?: ReturnType) { const session = [] as URL[] - const fetch = (async (input: RequestInfo | URL) => { + const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : String(input)) + const method = input instanceof Request ? input.method : (init?.method ?? "GET") if (url.pathname === "/session") session.push(url) - const overridden = await override?.(url) + const overridden = await override?.(url, method.toUpperCase()) if (overridden) return overridden if (url.pathname === "/api/event" && events) return events.response() diff --git a/packages/tui/test/prompt-autocomplete.test.tsx b/packages/tui/test/prompt-autocomplete.test.tsx new file mode 100644 index 0000000000..a34b36a538 --- /dev/null +++ b/packages/tui/test/prompt-autocomplete.test.tsx @@ -0,0 +1,111 @@ +import { expect, mock, test } from "bun:test" +import { createTestRenderer } from "@opentui/core/testing" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Global } from "@opencode-ai/core/global" +import { createTuiResolvedConfig } from "./fixture/tui-runtime" +import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk" + +async function waitForFrame(setup: Awaited>, fn: (frame: string) => boolean) { + const start = Date.now() + while (true) { + await setup.renderOnce() + const frame = setup.captureCharFrame() + if (fn(frame)) return frame + if (Date.now() - start > 15_000) throw new Error("timed out waiting for frame:\n" + frame) + await Bun.sleep(25) + } +} + +test("@ mention popup lists files before references and agents", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventSource() + // Listed session that the -c resolver navigates to during bootstrap. + const session = { + id: "ses_test", + title: "Demo session", + slug: "test", + projectID: "proj_test", + directory, + version: "0.0.0-test", + time: { created: 1, updated: 1 }, + } + const calls = createFetch((url) => { + if (url.pathname === "/session") return json([session]) + if (url.pathname.startsWith("/session/ses_test/message")) return json([]) + if (url.pathname === "/session/ses_test/todo") return json([]) + if (url.pathname === "/session/ses_test/diff") return json([]) + if (url.pathname === "/session/ses_test") return json(session) + if (url.pathname === "/project/proj_test/directories") return json([]) + if (url.pathname === "/api/fs/find") + return json({ + location: { directory, project: { id: "proj_test", directory } }, + data: [{ path: "src/alpha.ts", type: "file" }], + }) + if (url.pathname === "/api/reference") + return json({ + location: { directory, project: { id: "proj_test", directory } }, + data: [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }], + }) + if (url.pathname === "/agent") + return json([{ name: "reviewer", mode: "subagent", permission: {}, options: {} }]) + // Register a paid model so the fork's first-run configure dialog stays + // closed and focus remains on the prompt. + if (url.pathname === "/config/providers") + return json({ + providers: [{ id: "test", name: "Test", models: { m1: { name: "M1", cost: { input: 1 } } } }], + default: { test: "m1" }, + }) + return undefined + }, events) + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + url: "http://test", + directory, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + fetch: calls.fetch, + events: events.source, + args: { continue: true }, + pluginHost: { + // The session prompt is a slot default; the slot view renders + // nothing until the slot registry is set up. + async start(input) { + input.runtime.setupSlots(input.api) + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + await ready + await waitForFrame(setup, (frame) => frame.includes("Ask anything")) + + setup.mockInput.typeText("@") + // A bare "@" (empty query) must fetch files: the resource tracks menu + // visibility, and the popup lists files, then references, then agents. + const frame = await waitForFrame(setup, (f) => f.includes("src/alpha.ts") && f.includes("@reviewer")) + + // The popup is height-capped by the space above the prompt, so keep the + // option list small enough that every row is visible. + const rows = ["src/alpha.ts", "@docs", "@reviewer"] + for (const row of rows) { + expect(frame).toContain(row) + } + expect(rows.map((row) => frame.indexOf(row))).toEqual(rows.map((row) => frame.indexOf(row)).toSorted((a, b) => a - b)) + + process.emit("SIGHUP") + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + } +}, 30_000)