diff --git a/CONTEXT.md b/CONTEXT.md index 6c94d90da6a..1c12ba641c1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,6 +52,9 @@ _Avoid_: Request body, wire options **Generation Controls**: Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog. +**PTY Environment**: +The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. + ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. @@ -99,6 +102,8 @@ Provider-neutral sampling and output controls, partitioned from provider semanti - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. - **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. - **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. +- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. +- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. - Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible. diff --git a/bun.lock b/bun.lock index 7c8c2bbb3ca..c78211b4604 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.17.4", + "version": "1.17.7", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -110,7 +110,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -146,7 +146,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -173,7 +173,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -195,7 +195,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -219,7 +219,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -239,7 +239,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.4", + "version": "1.17.7", "bin": { "opencode": "./bin/opencode", }, @@ -330,7 +330,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -384,7 +384,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -398,7 +398,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "effect": "catalog:", }, @@ -410,7 +410,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -441,7 +441,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -457,7 +457,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@effect/platform-node": "4.0.0-beta.74", "@effect/platform-node-shared": "4.0.0-beta.74", @@ -476,7 +476,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -494,7 +494,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.4", + "version": "1.17.7", "bin": { "opencode": "./bin/opencode", }, @@ -622,7 +622,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -660,7 +660,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "cross-spawn": "catalog:", }, @@ -675,7 +675,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@opencode-ai/core": "workspace:*", "drizzle-orm": "catalog:", @@ -689,7 +689,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -702,7 +702,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", @@ -735,7 +735,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -754,7 +754,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -794,7 +794,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -821,7 +821,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -870,7 +870,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.4", + "version": "1.17.7", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -918,8 +918,9 @@ "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", }, "overrides": { diff --git a/nix/hashes.json b/nix/hashes.json index 0015eb44c3c..876e798288c 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-u3CBUflGrE1unVRTvvLKaDkvx5ZHXyRLd/SJOUUsRdc=", - "aarch64-linux": "sha256-hUu70IlOXXQ9apQe3IPOdQGf3ZSVCpvRuAWSKxipk/k=", - "aarch64-darwin": "sha256-N6Mqmo94q0rsskLcXoUGWCMJjQ9wCuuo0Lby3j6+Dps=", - "x86_64-darwin": "sha256-5691VKK2zWqAwe94CCjZuRjXEaswedevFtHxensVLfg=" + "x86_64-linux": "sha256-Y+8gz+NfFSb4dysjho4CCaQpx012vvajnptfd3cdh/k=", + "aarch64-linux": "sha256-dfkEJ57XvxCM4huM5Ij7tSy+JUPKYmBMZDGZKZ5xSXA=", + "aarch64-darwin": "sha256-CJ8dvzGzJp5sgaNJYHwxRc1xK9bWA8zyTmDFi4wTI3o=", + "x86_64-darwin": "sha256-7/kwFObujL4z4544Q0D2LN/SxT8sHnJyVrzmBXbJajA=" } } diff --git a/package.json b/package.json index 86c8738bda4..e59dff3211c 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch" + "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch" } } diff --git a/packages/app/package.json b/packages/app/package.json index d21b4ee6b9e..e5438261b64 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.4", + "version": "1.17.7", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5bf18b657a1..75f0c6b4446 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -69,8 +69,8 @@ const SessionRoute = Object.assign( createEffect(() => { if (!settings.general.newLayoutDesigns()) return if (params.id || search.draftId) return - if (!tabs.ready() || !sdk.directory) return - tabs.newDraft({ server: server.key, directory: sdk.directory }, search.prompt) + if (!tabs.ready() || !sdk().directory) return + tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt) }) return ( @@ -82,6 +82,45 @@ const SessionRoute = Object.assign( { preload: Session.preload }, ) +// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected +// server via ServerKey, then provide the server-scoped shell (Permission/Layout/ +// Notification/Models + the visual Layout) for that server. +function SelectedServerLayout(props: ParentProps) { + return ( + + + + {props.children} + + + + ) +} + +// Wraps /new-session. It resolves the draft's target server and provides the +// server-scoped shell for that server — without ServerKey, so the page never depends +// on the globally "selected" server. +function DraftServerLayout(props: ParentProps) { + const server = useServer() + const tabs = useTabs() + const [search] = useSearchParams<{ draftId?: string }>() + const conn = createMemo(() => { + const id = search.draftId + if (!id) return undefined + const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id) + if (!draft) return undefined + return server.list.find((c) => ServerConnection.key(c) === draft.server) + }) + + return ( + + + {props.children} + + + ) +} + function DraftRoute() { const [search] = useSearchParams<{ draftId?: string }>() const tabs = useTabs() @@ -95,19 +134,15 @@ function DraftRoute() { } function ResolvedDraftRoute(props: { draftID: string }) { - const server = useServer() const tabs = useTabs() const draft = createMemo(() => tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID), ) - createEffect(() => { - const current = draft() - if (current && current.server !== server.key) server.setActive(current.server) - }) - // Key on the directory so retargeting the draft's project re-instantiates the - // SDK/data providers for the new directory while keeping the same draft id. + // directory-scoped providers while keeping the same draft id. The draft's target + // server is provided by DraftServerLayout, so changing only the server updates the + // SDK/sync hooks without remounting the composer. const directory = () => draft()?.directory return ( @@ -171,27 +206,36 @@ function BodyDesignClass() { return null } -function AppShellProviders(props: ParentProps) { +// Server-agnostic providers shared across every route. These live in the shared +// shell (router root) so they stay mounted regardless of the active server/route. +function SharedProviders(props: ParentProps) { return ( - - - - - - - {props.children} - - - - - - + + {props.children} + ) } +// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside +// each per-route server layout so they resolve to that route's server (selected vs +// draft). The Layout remounts when crossing between those groups. +function ServerScopedShell(props: ParentProps) { + return ( + + + + + {props.children} + + + + + ) +} + function SessionProviders(props: ParentProps) { return ( @@ -216,17 +260,6 @@ function DraftProviders(props: ParentProps) { ) } -function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { - return ( - - {/*}>*/} - {props.appChildren} - {props.children} - {/**/} - - ) -} - export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { return ( @@ -385,6 +418,20 @@ export function AppInterface(props: { router?: Component disableHealthCheck?: boolean }) { + // The shared shell holds only server-agnostic providers (QueryClient + Settings/ + // Command/Highlights) and stays mounted across every route. The server-scoped + // providers and the visual Layout live in the per-route layouts below, so they + // resolve to that route's server (selected for most routes, the draft's server for + // /new-session). appChildren is server-agnostic, so it renders here once. + const ServerShell = (shellProps: ParentProps) => ( + + + {props.children} + {shellProps.children} + + + ) + return ( ( - - - - - {routerProps.children} - - - - + {routerProps.children} )} > - - - - } /> - + + + + } /> + + + + + diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 4d477ea273f..76da7d5b429 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -41,7 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) { }) const provider = createMemo( - () => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!, + () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) const fallback = createMemo(() => [ { @@ -52,16 +52,16 @@ export function DialogConnectProvider(props: { provider: string }) { const [auth] = createResource( () => props.provider, async () => { - const cached = serverSync.data.provider_auth[props.provider] + const cached = serverSync().data.provider_auth[props.provider] if (cached) return cached - const res = await serverSDK.client.provider.auth() + const res = await serverSDK().client.provider.auth() if (!alive.value) return fallback() - serverSync.set("provider_auth", res.data ?? {}) + serverSync().set("provider_auth", res.data ?? {}) return res.data?.[props.provider] ?? fallback() }, ) - const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider]) - const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback()) + const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) + const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, @@ -158,8 +158,8 @@ export function DialogConnectProvider(props: { provider: string }) { } dispatch({ type: "auth.pending" }) const start = Date.now() - await serverSDK.client.provider.oauth - .authorize( + await serverSDK() + .client.provider.oauth.authorize( { providerID: props.provider, method: index, @@ -331,7 +331,7 @@ export function DialogConnectProvider(props: { provider: string }) { }) async function complete() { - await serverSDK.client.global.dispose() + await serverSDK().client.global.dispose() dialog.close() showToast({ variant: "success", @@ -409,7 +409,7 @@ export function DialogConnectProvider(props: { provider: string }) { } setFormStore("error", undefined) - await serverSDK.client.auth.set({ + await serverSDK().client.auth.set({ providerID: props.provider, auth: { type: "api", @@ -480,8 +480,8 @@ export function DialogConnectProvider(props: { provider: string }) { } setFormStore("error", undefined) - const result = await serverSDK.client.provider.oauth - .callback({ + const result = await serverSDK() + .client.provider.oauth.callback({ providerID: props.provider, method: store.methodIndex, code, @@ -533,8 +533,8 @@ export function DialogConnectProvider(props: { provider: string }) { onMount(() => { void (async () => { - const result = await serverSDK.client.provider.oauth - .callback({ + const result = await serverSDK() + .client.provider.oauth.callback({ providerID: props.provider, method: store.methodIndex, }) diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 38690bb2410..f2b0432dde3 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) { const output = validateCustomProvider({ form, t: language.t, - disabledProviders: serverSync.data.config.disabled_providers ?? [], - existingProviderIDs: new Set(serverSync.data.provider.all.keys()), + disabledProviders: serverSync().data.config.disabled_providers ?? [], + existingProviderIDs: new Set(serverSync().data.provider.all.keys()), }) batch(() => { setForm("err", output.err) @@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) { const saveMutation = useMutation(() => ({ mutationFn: async (result: NonNullable>) => { - const disabledProviders = serverSync.data.config.disabled_providers ?? [] + const disabledProviders = serverSync().data.config.disabled_providers ?? [] const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) if (result.key) { - await serverSDK.client.auth.set({ + await serverSDK().client.auth.set({ providerID: result.providerID, auth: { type: "api", @@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) { }) } - await serverSync.updateConfig({ + await serverSync().updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled, }) diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index d0d105e078e..601f03084ce 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -35,13 +35,13 @@ export const DialogFork: Component = () => { const sessionID = params.id if (!sessionID) return [] - const msgs = sync.data.message[sessionID] ?? [] + const msgs = sync().data.message[sessionID] ?? [] const result: ForkableMessage[] = [] for (const message of msgs) { if (message.role !== "user") continue - const parts = sync.data.part[message.id] ?? [] + const parts = sync().data.part[message.id] ?? [] const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored) if (!textPart) continue @@ -61,15 +61,15 @@ export const DialogFork: Component = () => { const sessionID = params.id if (!sessionID) return - const parts = sync.data.part[item.id] ?? [] + const parts = sync().data.part[item.id] ?? [] const restored = extractPromptFromParts(parts, { - directory: sdk.directory, + directory: sdk().directory, attachmentName: language.t("common.attachment"), }) - const dir = base64Encode(sdk.directory) + const dir = base64Encode(sdk().directory) - sdk.client.session - .fork({ sessionID, messageID: item.id }) + sdk() + .client.session.fork({ sessionID, messageID: item.id }) .then((forked) => { if (!forked.data) { showToast({ title: language.t("common.requestFailed") }) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 4080c8ce856..272d3390179 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { useNavigate } from "@solidjs/router" import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" import { formatKeybind, useCommand, type CommandOption } from "@/context/command" -import { useServerSDK } from "@/context/server-sdk" +import { useServerSDK, type ServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" @@ -175,7 +175,7 @@ function createFileEntries(props: { function createSessionEntries(props: { workspaces: () => string[] label: (directory: string) => string - serverSDK: ReturnType + serverSDK: ServerSDK language: ReturnType }) { const state: { @@ -292,21 +292,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil if (directory && !dirs.includes(directory)) return [...dirs, directory] return dirs }) - const homedir = createMemo(() => serverSync.data.path.home) + const homedir = createMemo(() => serverSync().data.path.home) const label = (directory: string) => { const current = project() const kind = current && directory === current.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") - const [store] = serverSync.child(directory, { bootstrap: false }) + const [store] = serverSync().child(directory, { bootstrap: false }) const home = homedir() const path = home ? directory.replace(home, "~") : directory const name = store.vcs?.branch ?? getFilename(directory) return `${kind} : ${name || path}` } - const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language }) + const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language }) const items = async (text: string) => { const query = text.trim() diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 176817bfd18..05253381f0c 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -19,7 +19,7 @@ export const DialogSelectMcp: Component = () => { const language = useLanguage() const items = createMemo(() => - Object.entries(sync.data.mcp ?? {}) + Object.entries(sync().data.mcp ?? {}) .map(([name, status]) => ({ name, status: status.status })) .sort((a, b) => a.name.localeCompare(b.name)), ) @@ -48,7 +48,7 @@ export const DialogSelectMcp: Component = () => { }} > {(i) => { - const mcpStatus = () => sync.data.mcp[i.name] + const mcpStatus = () => sync().data.mcp[i.name] const status = () => mcpStatus()?.status const statusLabel = () => { const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx new file mode 100644 index 00000000000..b56287a8787 --- /dev/null +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -0,0 +1,117 @@ +// @ts-nocheck +import { createStore } from "solid-js/store" +import { createPromptState } from "@/context/prompt" +import { createPromptInputHistory, PromptInput } from "./prompt-input" + +function PromptInputExample() { + const state = createPromptState() + const history = createPromptInputHistory() + const [controls, setControls] = createStore({ + agent: "build", + variant: undefined as string | undefined, + comments: 0, + tabs: [] as string[], + activeTab: undefined as string | undefined, + reviewOpen: false, + }) + const model = { + current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), + variant: { + list: () => ["fast", "thinking"], + current: () => controls.variant, + set: (variant?: string) => setControls("variant", variant), + }, + } + const submission = { + abort() {}, + handleSubmit(event: Event) { + event.preventDefault() + state.reset() + }, + } + const inputControls = { + agents: { + available: [{ name: "review", hidden: false, mode: "subagent" }], + options: ["build", "review", "plan"], + get current() { + return controls.agent + }, + loading: false, + visible: true, + select: (agent?: string) => setControls("agent", agent ?? "build"), + }, + model: { + selection: model, + paid: true, + loading: false, + }, + projects: { + available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }], + directory: "/tmp/story", + select() {}, + add() {}, + }, + session: { + id: "story-session", + tabs: { + active: () => controls.activeTab, + all: () => controls.tabs, + open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])), + setActive: (tab: string) => setControls("activeTab", tab), + }, + reviewPanel: { + opened: () => controls.reviewOpen, + open: () => setControls("reviewOpen", true), + }, + }, + newLayoutDesigns: true, + } + const addReviewComment = () => { + const comment = controls.comments + 1 + setControls("comments", comment) + state.context.add({ + type: "file", + path: "src/components/prompt-input.tsx", + selection: { + startLine: 84 + comment, + startChar: 0, + endLine: 84 + comment, + endChar: 0, + }, + comment: `Review comment ${comment}`, + commentID: `review-comment-${comment}`, + commentOrigin: "review", + preview: "export const PromptInput = ...", + }) + } + + return ( +
+ +
+ +
+
+ ) +} + +export default { + title: "App/PromptInput", + id: "app-prompt-input", + component: PromptInput, +} + +export const Basic = { + render: () => ( +
+

Prompt Input

+ +
+ ), +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 61a5c55d3e1..cbec31d522f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -17,8 +17,8 @@ import { type JSX, } from "solid-js" import { Popover as KobaltePopover } from "@kobalte/core/popover" -import { createStore } from "solid-js/store" -import { useLocal } from "@/context/local" +import { createStore, type SetStoreFunction, type Store } from "solid-js/store" +import type { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { ContentPart, @@ -31,10 +31,7 @@ import { FileAttachmentPart, } from "@/context/prompt" import { useLayout } from "@/context/layout" -import { useNavigate, useSearchParams } from "@solidjs/router" import { useSDK } from "@/context/sdk" -import { useServer } from "@/context/server" -import { useTabs } from "@/context/tabs" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" @@ -46,14 +43,11 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ModelSelectorPopover } from "@/components/dialog-select-model" -import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { useSettings } from "@/context/settings" -import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments } from "./prompt-input/attachments" @@ -73,18 +67,104 @@ import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" -import { useDirectoryPicker } from "./directory-picker" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" -import { useQueries } from "@tanstack/solid-query" -import { useQueryOptions } from "@/context/server-sync" import { pathKey } from "@/utils/path-key" -import { base64Encode } from "@opencode-ai/core/util/encode" import { displayName } from "@/pages/layout/helpers" -interface PromptInputProps { +export type PromptInputState = ReturnType + +export type PromptInputHistory = { + entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] + add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void +} + +export type PromptInputSubmission = { + abort: () => Promise | void + handleSubmit: (event: Event) => Promise | void +} + +export type PromptInputControls = { + agents: { + available: { name: string; hidden?: boolean; mode: string }[] + options: string[] + current: string + loading: boolean + visible: boolean + select: (name: string | undefined) => void + } + model: { + selection: ReturnType["model"] + paid: boolean + loading: boolean + } + projects: { + available: { name?: string; worktree: string; sandboxes?: string[] }[] + directory: string + select: (worktree: string) => void + add: (title: string) => void + } + session: { + id?: string + tabs: { + active: () => string | undefined + all: () => string[] + open: (tab: string) => void | Promise + setActive: (tab: string) => void + } + reviewPanel: { + opened: () => boolean + open: () => void + } + } + newLayoutDesigns: boolean +} + +export function createPromptInputHistory(): PromptInputHistory { + const [normal, setNormal] = createStore({ entries: [] }) + const [shell, setShell] = createStore({ entries: [] }) + return createPromptInputHistoryStore(normal, setNormal, shell, setShell) +} + +type PromptHistoryState = { entries: PromptHistoryStoredEntry[] } + +function createPromptInputHistoryStore( + normal: Store, + setNormal: SetStoreFunction, + shell: Store, + setShell: SetStoreFunction, +): PromptInputHistory { + return { + entries: (mode) => (mode === "shell" ? shell.entries : normal.entries), + add(prompt, mode, comments) { + const current = mode === "shell" ? shell : normal + const setCurrent = mode === "shell" ? setShell : setNormal + const next = prependHistoryEntry(current.entries, prompt, comments) + if (next === current.entries) return + setCurrent("entries", next) + }, + } +} + +function createPersistedPromptInputHistory() { + const [normal, setNormal] = persisted( + Persist.global("prompt-history", ["prompt-history.v1"]), + createStore({ entries: [] }), + ) + const [shell, setShell] = persisted( + Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), + createStore({ entries: [] }), + ) + return createPromptInputHistoryStore(normal, setNormal, shell, setShell) +} + +export interface PromptInputProps { class?: string variant?: "dock" | "new-session" + state?: PromptInputState + history?: PromptInputHistory + submission?: PromptInputSubmission + controls: PromptInputControls ref?: (el: HTMLDivElement) => void newSessionWorktree?: string onNewSessionWorktreeReset?: () => void @@ -126,27 +206,18 @@ const EXAMPLES = [ export const PromptInput: Component = (props) => { const sdk = useSDK() - const navigate = useNavigate() - const queryOptions = useQueryOptions() const sync = useSync() - const local = useLocal() const files = useFile() - const prompt = usePrompt() + const prompt = props.state ?? usePrompt() const layout = useLayout() - const server = useServer() const comments = useComments() const dialog = useDialog() - const providers = useProviders() const command = useCommand() const permission = usePermission() const language = useLanguage() const platform = usePlatform() - const pickDirectory = useDirectoryPicker() - const settings = useSettings() - const tabsStore = useTabs() - const [search] = useSearchParams<{ draftId?: string }>() - const { params, tabs, view } = useSessionLayout() + const tabs = () => props.controls.session.tabs let editorRef!: HTMLDivElement let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement @@ -204,10 +275,10 @@ export const PromptInput: Component = (props) => { }).activeFileTab const commentInReview = (path: string) => { - const sessionID = params.id + const sessionID = props.controls.session.id if (!sessionID) return false - const diffs = sync.data.session_diff[sessionID] + const diffs = sync().data.session_diff[sessionID] if (!diffs) return false return diffs.some((diff) => diff.file === path) } @@ -237,14 +308,14 @@ export const PromptInput: Component = (props) => { const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) if (wantsReview) { - if (!view().reviewPanel.opened()) view().reviewPanel.open() + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() layout.fileTree.setTab("changes") tabs().setActive("review") queueCommentFocus() return } - if (!view().reviewPanel.opened()) view().reviewPanel.open() + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() layout.fileTree.setTab("all") const tab = files.tab(item.path) void tabs().open(tab) @@ -269,8 +340,8 @@ export const PromptInput: Component = (props) => { return paths }) - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const working = createMemo(() => sync.data.session_working(params.id ?? "")) + const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) + const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) const imageAttachments = createMemo(() => prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) @@ -347,29 +418,14 @@ export const PromptInput: Component = (props) => { }) const hasUserPrompt = createMemo(() => { - const sessionID = params.id + const sessionID = props.controls.session.id if (!sessionID) return false - const messages = sync.data.message[sessionID] + const messages = sync().data.message[sessionID] if (!messages) return false return messages.some((m) => m.role === "user") }) - const [history, setHistory] = persisted( - Persist.global("prompt-history", ["prompt-history.v1"]), - createStore<{ - entries: PromptHistoryStoredEntry[] - }>({ - entries: [], - }), - ) - const [shellHistory, setShellHistory] = persisted( - Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), - createStore<{ - entries: PromptHistoryStoredEntry[] - }>({ - entries: [], - }), - ) + const history = props.history ?? createPersistedPromptInputHistory() const suggest = createMemo(() => !hasUserPrompt()) @@ -474,7 +530,7 @@ export const PromptInput: Component = (props) => { const pick = () => { pickAttachmentFiles({ picker: platform.openAttachmentPickerDialog, - directory: () => sdk.directory, + directory: () => sdk().directory, fallback: () => fileInputRef?.click(), onFile: addAttachment, onError: (error) => @@ -573,8 +629,8 @@ export const PromptInput: Component = (props) => { } createEffect(() => { - params.id - if (params.id) return + props.controls.session.id + if (props.controls.session.id) return if (!suggest()) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) @@ -603,11 +659,10 @@ export const PromptInput: Component = (props) => { } const agentList = createMemo(() => - sync.data.agent + props.controls.agents.available .filter((agent) => !agent.hidden && agent.mode !== "primary") .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) - const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name)) const handleAtSelect = (option: AtOption | undefined) => { if (!option) return @@ -673,7 +728,7 @@ export const PromptInput: Component = (props) => { type: "builtin" as const, })) - const custom = sync.data.command.map((cmd) => ({ + const custom = sync().data.command.map((cmd) => ({ id: `custom.${cmd.name}`, trigger: cmd.name, title: cmd.name, @@ -1033,11 +1088,7 @@ export const PromptInput: Component = (props) => { } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { - const currentHistory = mode === "shell" ? shellHistory : history - const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory - const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments()) - if (next === currentHistory.entries) return - setCurrentHistory("entries", next) + history.add(prompt, mode, mode === "shell" ? [] : historyComments()) } createEffect( @@ -1082,7 +1133,7 @@ export const PromptInput: Component = (props) => { const navigateHistory = (direction: "up" | "down") => { const result = navigatePromptHistory({ direction, - entries: store.mode === "shell" ? shellHistory.entries : history.entries, + entries: history.entries(store.mode), historyIndex: store.historyIndex, currentPrompt: prompt.current(), currentComments: historyComments(), @@ -1096,6 +1147,7 @@ export const PromptInput: Component = (props) => { } const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ + prompt, editor: () => editorRef, isDialogActive: () => !!dialog.active, setDraggingType: (type) => setStore("draggingType", type), @@ -1122,38 +1174,41 @@ export const PromptInput: Component = (props) => { /> ) - const variants = createMemo(() => ["default", ...local.model.variant.list()]) + const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) // Check provider variants directly: `variants` also includes the UI-only default option. - const showVariantControl = createMemo(() => local.model.variant.list().length > 0) + const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0) const accepting = createMemo(() => { - const id = params.id - if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) - return permission.isAutoAccepting(id, sdk.directory) + const id = props.controls.session.id + if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) + return permission.isAutoAccepting(id, sdk().directory) }) - const { abort, handleSubmit } = createPromptSubmit({ - info, - imageAttachments, - commentCount, - autoAccept: () => accepting(), - mode: () => store.mode, - working, - editor: () => editorRef, - queueScroll, - promptLength, - addToHistory, - resetHistoryNavigation: () => { - resetHistoryNavigation(true) - }, - setMode: (mode) => setStore("mode", mode), - setPopover: (popover) => setStore("popover", popover), - newSessionWorktree: () => props.newSessionWorktree, - onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, - shouldQueue: props.shouldQueue, - onQueue: props.onQueue, - onAbort: props.onAbort, - onSubmit: props.onSubmit, - }) + const { abort, handleSubmit } = + props.submission ?? + createPromptSubmit({ + prompt, + info, + imageAttachments, + commentCount, + autoAccept: () => accepting(), + mode: () => store.mode, + working, + editor: () => editorRef, + queueScroll, + promptLength, + addToHistory, + resetHistoryNavigation: () => { + resetHistoryNavigation(true) + }, + setMode: (mode) => setStore("mode", mode), + setPopover: (popover) => setStore("popover", popover), + newSessionWorktree: () => props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, + onSubmit: props.onSubmit, + }) const handleKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { @@ -1317,17 +1372,9 @@ export const PromptInput: Component = (props) => { } } - const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ - queries: [ - queryOptions.agents(pathKey(sdk.directory)), - queryOptions.providers(null), - queryOptions.providers(pathKey(sdk.directory)), - ], - })) - - const agentsLoading = () => agentsQuery.isLoading + const agentsLoading = () => props.controls.agents.loading const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) - const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading + const providersLoading = () => props.controls.model.loading const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) const [promptReady] = createResource( @@ -1342,23 +1389,23 @@ export const PromptInput: Component = (props) => { const modelControlState = createMemo(() => ({ loading: providersLoading(), - paid: providers.paid().length > 0, + paid: props.controls.model.paid, title: language.t("command.model.choose"), keybind: command.keybind("model.choose"), - model: local.model, - providerID: local.model.current()?.provider?.id, - modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"), + model: props.controls.model.selection, + providerID: props.controls.model.selection.current()?.provider?.id, + modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"), style: control(), onClose: restoreFocus, onUnpaidClick: () => { void import("@/components/dialog-select-model-unpaid").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) }, })) const newSession = () => props.variant === "new-session" - const projects = createMemo(() => layout.projects.list()) + const projects = createMemo(() => props.controls.projects.available) const projectForDirectory = (directory: string | undefined) => { if (!directory) return const key = pathKey(directory) @@ -1366,13 +1413,13 @@ export const PromptInput: Component = (props) => { (project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key), ) } - const selectedProject = createMemo(() => projectForDirectory(sdk.directory)) + const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory)) const projectResults = createMemo(() => { const search = picker.projectSearch.trim().toLowerCase() if (!search) return projects() return projects().filter((project) => displayName(project).toLowerCase().includes(search)) }) - const showAgentControl = createMemo(() => settings.visibility.customAgents() && agentNames().length > 0) + const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) const selectProject = (worktree: string) => { setPicker({ projectOpen: false, @@ -1382,33 +1429,11 @@ export const PromptInput: Component = (props) => { restoreFocus() return } - layout.projects.open(worktree) - server.projects.touch(worktree) - - // On the draft route, retarget the existing draft in place so we keep the same - // draft id (and its tab/prompt) instead of spawning a new draft for the new directory. - const draftID = search.draftId - if (draftID) { - tabsStore.updateDraft(draftID, { server: server.key, directory: worktree }) - restoreFocus() - return - } - - navigate(`/${base64Encode(worktree)}/session`) + props.controls.projects.select(worktree) + restoreFocus() } const addProject = () => { - const conn = server.current - if (!conn) return - const select = (result: string | string[] | null) => { - const directory = Array.isArray(result) ? result[0] : result - if (!directory) return - selectProject(directory) - } - pickDirectory({ - server: conn, - title: language.t("command.project.open"), - onSelect: select, - }) + props.controls.projects.add(language.t("command.project.open")) } const projectPickerState = createMemo(() => ({ @@ -1449,11 +1474,11 @@ export const PromptInput: Component = (props) => { const agentControlState = createMemo(() => ({ title: language.t("command.agent.cycle"), keybind: command.keybind("agent.cycle"), - options: agentNames(), - current: local.agent.current()?.name ?? "", + options: props.controls.agents.options, + current: props.controls.agents.current, style: control(), onSelect: (value) => { - local.agent.set(value) + props.controls.agents.select(value) restoreFocus() }, })) @@ -1485,7 +1510,7 @@ export const PromptInput: Component = (props) => { t={(key) => language.t(key as Parameters[0])} /> - +
= (props) => { data-component="prompt-variant-control" classList={{ "hidden group-hover/prompt-input:block group-focus-within/prompt-input:block": - !local.model.variant.current() && !store.variantOpen, + !props.controls.model.selection.variant.current() && !store.variantOpen, }} > = (props) => { { - local.agent.set(value) + props.controls.agents.select(value) restoreFocus() }} class="capitalize max-w-[160px] text-text-base" @@ -1883,7 +1908,7 @@ export const PromptInput: Component = (props) => { style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined} > 0} + when={props.controls.model.paid} fallback={ = (props) => { style={control()} onClick={() => { void import("@/components/dialog-select-model-unpaid").then((x) => { - dialog.show(() => ) + dialog.show(() => ( + + )) }) }} > - + - {local.model.current()?.name ?? language.t("dialog.model.select.title")} + {props.controls.model.selection.current()?.name ?? + language.t("dialog.model.select.title")} @@ -1926,7 +1954,7 @@ export const PromptInput: Component = (props) => { keybind={command.keybind("model.choose")} > = (props) => { }} onClose={restoreFocus} > - + - {local.model.current()?.name ?? language.t("dialog.model.select.title")} + {props.controls.model.selection.current()?.name ?? + language.t("dialog.model.select.title")} @@ -1966,10 +1995,10 @@ export const PromptInput: Component = (props) => {