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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/app/component-tests/extension-host.fixture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const layout: Layout = {
scroll: { get: () => undefined, set() {} },
settings() {},
project() {},
draft() {},
command() {},
}

Expand Down Expand Up @@ -349,6 +350,7 @@ function standIn(overrides: Partial<Interface>): Interface {
},
settings() {},
project() {},
draft() {},
command() {},
font: () => "fixture mono",
routing: () => false,
Expand Down
51 changes: 51 additions & 0 deletions packages/app/e2e/regression/session-summary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,57 @@ test("Extensions lists open on hover, hold course into the open list, and follow
await expect(summary).toBeHidden()
})

test("Extensions lists open config files and skills in a side tab and start setup sessions", async ({ page }) => {
const global = "C:/Users/kit/.config/opencode/opencode.jsonc"
const skill = `${fixture.directory}/.opencode/skills/release-notes/SKILL.md`

await mockStressTimeline(page, {
mcp: [{ name: "atlas-search", status: { status: "connected" } }],
plugins: [],
skills: [{ id: "release-notes", name: "release-notes", path: skill, content: "Release notes" }],
configEntries: [
{
type: "document",
path: global,
info: { mcp: { servers: { "atlas-search": { type: "remote", url: "https://atlas.example" } } } },
},
// An empty project override used to win, so the servers' own file never opened.
{ type: "document", path: `${fixture.directory}/.opencode/opencode.jsonc`, info: { mcp: {} } },
],
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
})
await page.goto(sessionHref(fixture.targetID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const row = (name: string) => summary.getByRole("button", { name, exact: true })
const list = (name: string) => page.getByRole("group", { name, exact: true })

await trigger.click()
await row("MCP").hover()
// A file outside the workspace is read by name from its own directory.
const read = page.waitForRequest((request) => new URL(request.url()).pathname.endsWith("/api/fs/read/opencode.jsonc"))
await list("MCP").getByRole("button", { name: "Configuration file", exact: true }).click()
expect(new URL((await read).url()).searchParams.get("location[directory]")).toBe("C:/Users/kit/.config/opencode/")
await expect(summary).toBeHidden()
await expect(page.getByRole("tab", { name: "opencode.jsonc", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.getByText("contents:opencode.jsonc", { exact: true })).toBeVisible()

await trigger.click()
await row("Skills").hover()
await list("Skills").getByRole("button", { name: "release-notes", exact: true }).click()
await expect(summary).toBeHidden()
await expect(page.getByRole("tab", { name: "SKILL.md", exact: true })).toHaveAttribute("aria-selected", "true")

// An empty list offers a draft session in the global configuration's directory, its prompt filled in.
await trigger.click()
await row("Plugins").hover()
await list("Plugins").getByRole("button", { name: "Add a plugin with OpenCode", exact: true }).click()
await expect(page).toHaveURL(/\/new-session\?/)
await expect(page.locator('[data-component="composer-editor"]')).toContainText(
"Help me add a plugin to my global OpenCode config.",
)
})

test("multiple desktop connections show the session's server name", async ({ page }) => {
await mockStressTimeline(page)
await page.route("http://secondary.test/**", (route) =>
Expand Down
12 changes: 11 additions & 1 deletion packages/app/src/runtime/extension/host-apis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,12 @@ type Attached = {
scope: (server: string) => ServerScope
/** Records a session-scoped store so layout pruning drops it with the session. */
scoped: (name: string) => void
layout: Omit<Layout, "narrow" | "settings" | "project" | "command" | "stored"> & {
layout: Omit<Layout, "narrow" | "settings" | "project" | "draft" | "command" | "stored"> & {
stored(extension: string, session: SessionRef): readonly string[]
}
settings: (page?: string) => void
project: (server: string, title: string) => void
draft: Layout["draft"]
command: (id: string) => void
font: Accessor<string>
routing: Accessor<boolean>
Expand Down Expand Up @@ -322,6 +323,7 @@ export function createHostApis() {
},
settings: (page) => write((value) => value.settings(page)),
project: (server, title) => write((value) => value.project(server, title)),
draft: (input) => write((value) => value.draft(input)),
command: (id) => write((value) => value.command(id)),
}),
embeds: () => embeds,
Expand Down Expand Up @@ -651,6 +653,7 @@ export function createExtensionAttachment(apis: HostApis) {

// Writes made before a session's location is known wait for it rather than being dropped.
const located = createLocatedWrites()

// Kept per session beside its strip, so the new tab view still lists them after a reload.
const closed = createClosedTabs({
list(session) {
Expand Down Expand Up @@ -711,10 +714,12 @@ export function createExtensionAttachment(apis: HostApis) {
const value = stateKey(session)

if (!value) return located.hold(session, () => close(key, session))

const tab = listed(session, value).find((entry) => entry.key === key)?.tab
const all = layout.panel.state(value).all
const view = mountedSession(session)
const currentScreen = screen()

const entry =
view &&
tab &&
Expand Down Expand Up @@ -840,6 +845,11 @@ export function createExtensionAttachment(apis: HostApis) {
scope,
scoped: layout.sessionState.track,
project: (server, title) => setProjects((pending) => [...pending, { server, title }]),
draft: (input) => {
const server = servers.list.find((conn) => ServerConnection.key(conn) === input.server)

if (server) void tabs.newDraft({ server: ServerConnection.key(server), directory: input.directory }, input.prompt)
},
command: (id) => command.trigger(id),
font: () => terminalFontFamily(settings.appearance.terminalFont()),
routing: useIsRouting(),
Expand Down
1 change: 1 addition & 0 deletions packages/gui-extensions/src/browser/panel.fixture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,7 @@ export function mountBrowserRegion(input: RegionHost) {
scroll: { get: () => undefined, set() {} },
settings() {},
project() {},
draft() {},
command() {},
})

Expand Down
64 changes: 64 additions & 0 deletions packages/gui-extensions/src/details/config-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { expect, test } from "bun:test"
import { globalConfigDirectory, serviceConfigFile } from "./config-file"

const global = "/home/kit/.config/opencode/opencode.jsonc"

const project = "/work/acme/.opencode/opencode.jsonc"

test("a project file that sets a service without entries does not hide the global file's entries", () => {
const entries = [
{
type: "document" as const,
path: global,
info: {
mcp: { servers: { atlas: { type: "remote" as const, url: "https://atlas.example" } } },
plugins: ["opencode-tidewater"],
},
},
{
type: "document" as const,
path: project,
info: { mcp: {}, plugins: [], lsp: { zephyr: { command: ["zephyr"] } } },
},
{ type: "document" as const, path: "/work/acme/.opencode/agents/review.md", info: {} },
]

expect(serviceConfigFile(entries, "mcp")).toBe(global)
expect(serviceConfigFile(entries, "plugins")).toBe(global)
expect(serviceConfigFile(entries, "lsp")).toBe(project)
// Nothing lists skills, so the first file, the global one, is where they would go.
expect(serviceConfigFile(entries, "skills")).toBe(global)
})

test("a file that only sets a service still wins over one that never mentions it", () => {
const entries = [
{ type: "document" as const, path: global, info: {} },
{ type: "document" as const, path: project, info: { lsp: false } },
]

expect(serviceConfigFile(entries, "lsp")).toBe(project)
expect(serviceConfigFile([], "lsp")).toBeUndefined()
})

test("setup sessions run in the global configuration's directory, never an ancestor project's", () => {
// In the server's order: global files and root, an ancestor's config outside the worktree, then the project's.
const ancestor = "/work/opencode.json"
const projectRoot = { type: "directory" as const, path: "/work/acme/.opencode" }

const entries = [
{ type: "document" as const, path: global, info: {} },
{ type: "directory" as const, path: "/home/kit/.config/opencode" },
{ type: "document" as const, path: ancestor, info: {} },
{ type: "document" as const, path: project, info: {} },
projectRoot,
]

expect(globalConfigDirectory(entries, "/work/acme")).toBe("/home/kit/.config/opencode")
// With global configuration off, the ancestor's directory is still not taken for the global one.
expect(
globalConfigDirectory(
[{ type: "document", path: ancestor, info: {} }, { type: "document", path: project, info: {} }, projectRoot],
"/work/acme",
),
).toBe("/work/acme")
})
47 changes: 47 additions & 0 deletions packages/gui-extensions/src/details/config-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { ConfigEntry } from "@opencode/client"
import { Predicate } from "effect"

type ConfigInfo = Extract<ConfigEntry, { type: "document" }>["info"]

/**
* The configuration file that holds a service's entries: the last one that lists any, else the last one that sets the
* service, else the first. An empty `"mcp": {}` in a project file does not hide the servers the global file lists.
*/
export function serviceConfigFile(entries: readonly ConfigEntry[], service: "mcp" | "plugins" | "skills" | "lsp") {
const documents = configDocuments(entries)

return (
documents.findLast((document) => lists(document.info, service)) ??
documents.findLast((document) => document.info[service] !== undefined) ??
documents.at(0)
)?.path
}

/**
* The global configuration directory, else the location itself. The server lists a `directory` entry for each config
* root, the global one before any project's, and a project root is always a `.opencode` folder. So the first root is
* the global one unless it is a project's, which means global configuration is off. A project config in an ancestor of
* the worktree is not mistaken for it.
*/
export function globalConfigDirectory(entries: readonly ConfigEntry[], directory: string) {
const root = entries.find((entry) => entry.type === "directory")

return root && !/[\\/]\.opencode[\\/]?$/.test(root.path) ? root.path.replace(/[\\/]+$/, "") : directory
}

function configDocuments(entries: readonly ConfigEntry[]) {
return entries.flatMap((entry) =>
entry.type === "document" && entry.path !== undefined && /\.jsonc?$/.test(entry.path)
? [{ path: entry.path, info: entry.info }]
: [],
)
}

function lists(info: ConfigInfo, service: "mcp" | "plugins" | "skills" | "lsp") {
if (service === "mcp") return Object.keys(info.mcp?.servers ?? {}).length > 0

if (service === "lsp")
return info.lsp !== undefined && !Predicate.isBoolean(info.lsp) && Object.keys(info.lsp).length > 0

return (info[service]?.length ?? 0) > 0
}
15 changes: 2 additions & 13 deletions packages/gui-extensions/src/details/details.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@

.session-summary-row:focus-visible,
.session-service-config:focus-visible,
button.session-service-row:focus-visible,
.session-mcp-row:focus-within {
outline: none;
background: var(--v2-overlay-simple-overlay-hover);
Expand All @@ -78,6 +79,7 @@
@media (hover: hover) {
button.session-summary-row:hover,
.session-service-config:not(:disabled):hover,
button.session-service-row:hover,
.session-summary-move:has(> .session-summary-dismiss:hover) > .session-summary-row,
.session-mcp-row:not([data-disabled]):hover {
background: var(--v2-overlay-simple-overlay-hover);
Expand Down Expand Up @@ -315,15 +317,6 @@
text-decoration: none;
cursor: pointer;

.session-service-config-arrow {
opacity: 0;
transition: opacity 150ms ease-out;
}

&:is(:hover, :focus-visible) .session-service-config-arrow {
opacity: 1;
}

&:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
Expand Down Expand Up @@ -500,8 +493,4 @@ div.session-mcp-row {
translate: none;
transition: opacity 0.15s ease-out;
}

.session-service-config .session-service-config-arrow {
transition: none;
}
}
12 changes: 12 additions & 0 deletions packages/gui-extensions/src/details/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ export default {
configure: "Configuration file",
copyConfigPath: "Copy configuration file path",
configFileMissing: "No configuration file found",
"setup.mcp": "Add a server with OpenCode",
"setup.mcp.prompt":
"Help me add an MCP server to my global OpenCode config. Ask me which service I want to connect, then set it up and check that it connects.",
"setup.plugins": "Add a plugin with OpenCode",
"setup.plugins.prompt":
"Help me add a plugin to my global OpenCode config. Ask me what I want it to do, suggest a plugin, then add it.",
"setup.skills": "New skill with OpenCode",
"setup.skills.prompt":
"Help me write a new OpenCode skill in my global config. Ask me what it should teach you, then write its SKILL.md.",
"setup.lsp": "Set up with OpenCode",
"setup.lsp.prompt":
"Help me configure a language server in my global OpenCode config. Ask me which language I work in, then set it up.",
loadingChanges: "Loading changes…",
noChanges: "No changes",
basedOn: "Based on {{branch}}",
Expand Down
3 changes: 3 additions & 0 deletions packages/gui-extensions/src/details/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export type DetailsPanelProps = {
session: MountedSession
shown?: boolean
mobile?: boolean
/** Closes the popover that shows the panel, once an action opened something beside it. */
onClose?: () => void
}

export default function SessionDetailsPanel(props: DetailsPanelProps) {
Expand All @@ -15,6 +17,7 @@ export default function SessionDetailsPanel(props: DetailsPanelProps) {
directory={props.session.directory}
shown={props.shown !== false}
mobile={props.mobile}
onClose={props.onClose}
/>
</div>
)
Expand Down
2 changes: 1 addition & 1 deletion packages/gui-extensions/src/details/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function DetailsHeader(props: {
<Show when={props.session.project}>
<DetailsPopover menus={props.menus} active={props.active} open={open()} onOpenChange={setOpen}>
<Suspense>
<Panel session={props.session} shown={open()} />
<Panel session={props.session} shown={open()} onClose={() => setOpen(false)} />
</Suspense>
</DetailsPopover>
</Show>
Expand Down
Loading
Loading