From 233fc5b91017b119cca046b892d6dc39c233c0d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Cruz?= Date: Thu, 7 May 2026 00:57:56 +0100 Subject: [PATCH 0001/1034] fix(provider): preserve assistant message content when reasoning blocks present (#21370) Co-authored-by: Omer Koren <54630488+omer-koren@users.noreply.github.com> Co-authored-by: Aiden Cline --- packages/opencode/src/session/message-v2.ts | 22 +++- .../opencode/test/session/message-v2.test.ts | 102 ++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 237fb527c078..ed09262d0efe 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -854,13 +854,31 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "assistant", parts: [], } + // Anthropic adaptive thinking can persist assistant turns like: + // step-start, reasoning(signature), text(""), step-start, + // reasoning(signature). The empty text part is a structural separator, + // but it does not carry the signature metadata itself. Dropping it shifts + // signed thinking positions after step-start splitting/provider regrouping; + // keeping it as "" is filtered by the AI SDK and rejected by Anthropic. + // It is unclear whether this shape originates in our stream processing, + // a proxy, or a lower-level library, but preserving a non-empty separator + // here is the only safe replay point we have. + // Use a single space so the separator survives replay without changing + // the neighboring signed reasoning blocks. Bedrock-hosted Claude stores + // the same signature under the bedrock metadata namespace. + const hasSignedReasoning = msg.parts.some((part) => { + if (part.type !== "reasoning") return false + return part.metadata?.anthropic?.signature != null || part.metadata?.bedrock?.signature != null + }) for (const part of msg.parts) { - if (part.type === "text") + if (part.type === "text") { + const text = part.text === "" && hasSignedReasoning ? " " : part.text assistantMessage.parts.push({ type: "text", - text: part.text, + text, ...(differentModel ? {} : { providerMetadata: part.metadata }), }) + } if (part.type === "step-start") assistantMessage.parts.push({ type: "step-start", diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index a7853be0b8bf..999b61b48e56 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1098,6 +1098,108 @@ describe("session.message-v2.toModelMessage", () => { }, ]) }) + + test("substitutes space for empty text between signed reasoning blocks", async () => { + // Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)] + const assistantID = "m-assistant" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { ...basePart(assistantID, "p1"), type: "step-start" }, + { + ...basePart(assistantID, "p2"), + type: "reasoning", + text: "thinking-one", + metadata: { anthropic: { signature: "sig1" } }, + }, + { ...basePart(assistantID, "p3"), type: "text", text: "" }, + { ...basePart(assistantID, "p4"), type: "step-start" }, + { + ...basePart(assistantID, "p5"), + type: "reasoning", + text: "thinking-two", + metadata: { anthropic: { signature: "sig2" } }, + }, + { ...basePart(assistantID, "p6"), type: "text", text: "the answer" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + // step-start splits into two assistant messages; SDK's groupIntoBlocks merges them later + expect(result).toHaveLength(2) + expect((result[0].content as any[]).find((p) => p.type === "text").text).toBe(" ") + expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer") + }) + + test("substitutes space for empty text when reasoning signature is under 'bedrock' namespace", async () => { + // AWS Bedrock hosts Anthropic Claude but stores signatures under metadata.bedrock + const assistantID = "m-assistant-bedrock" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { + ...basePart(assistantID, "p1"), + type: "reasoning", + text: "thinking-bedrock", + metadata: { bedrock: { signature: "bedrock-sig" } }, + }, + { ...basePart(assistantID, "p2"), type: "text", text: "" }, + { ...basePart(assistantID, "p3"), type: "text", text: "answer" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(1) + const texts = (result[0].content as any[]).filter((p) => p.type === "text") + expect(texts.map((t) => t.text)).toStrictEqual([" ", "answer"]) + }) + + test("leaves empty text alone when reasoning has no Anthropic signature", async () => { + // Non-Anthropic providers' reasoning doesn't position-validate, so empty text + // should be filtered normally rather than substituted. + const assistantID = "m-assistant-unsigned" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { ...basePart(assistantID, "p1"), type: "reasoning", text: "thinking" }, + { ...basePart(assistantID, "p2"), type: "text", text: "" }, + { ...basePart(assistantID, "p3"), type: "text", text: "answer" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(1) + const texts = (result[0].content as any[]).filter((p) => p.type === "text") + expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"]) + }) + + test("leaves empty text alone in assistant messages without reasoning", async () => { + const assistantID = "m-assistant-no-reasoning" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { ...basePart(assistantID, "p1"), type: "text", text: "" }, + { ...basePart(assistantID, "p2"), type: "text", text: "hello" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(1) + const texts = (result[0].content as any[]).filter((p) => p.type === "text") + expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"]) + }) }) describe("session.message-v2.fromError", () => { From b2e3dc87ead239049b190973f7de05d0262e3eed Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 6 May 2026 19:33:52 -0500 Subject: [PATCH 0002/1034] feat: Update ACP support, modernize and fix misc issues (#25663) --- bun.lock | 4 +- packages/opencode/package.json | 2 +- packages/opencode/src/acp/agent.ts | 255 +++++++++++++----- packages/opencode/src/acp/session.ts | 6 + .../opencode/test/acp/agent-interface.test.ts | 5 +- 5 files changed, 197 insertions(+), 75 deletions(-) diff --git a/bun.lock b/bun.lock index 77ad4d982fea..bcf1405a9fab 100644 --- a/bun.lock +++ b/bun.lock @@ -335,7 +335,7 @@ "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", - "@agentclientprotocol/sdk": "0.16.1", + "@agentclientprotocol/sdk": "0.21.0", "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.96", "@ai-sdk/anthropic": "3.0.71", @@ -728,7 +728,7 @@ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], - "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="], + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="], "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index db42557616fe..3126804ae002 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -80,7 +80,7 @@ "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", - "@agentclientprotocol/sdk": "0.16.1", + "@agentclientprotocol/sdk": "0.21.0", "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.96", "@ai-sdk/anthropic": "3.0.71", diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index d66c1b258325..ad930680d11f 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -5,6 +5,8 @@ import { type AuthenticateRequest, type AuthMethod, type CancelNotification, + type CloseSessionRequest, + type CloseSessionResponse, type ForkSessionRequest, type ForkSessionResponse, type InitializeRequest, @@ -565,6 +567,7 @@ export class Agent implements ACPAgent { image: true, }, sessionCapabilities: { + close: {}, fork: {}, list: {}, resume: {}, @@ -627,6 +630,9 @@ export class Agent implements ACPAgent { // Store ACP session state await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) + const messages = await this.loadSessionMessages(directory, sessionId) + this.restoreSessionStateFromMessages(sessionId, messages) + log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) const result = await this.loadSessionMode({ @@ -635,39 +641,6 @@ export class Agent implements ACPAgent { sessionId, }) - // Replay session history - const messages = await this.sdk.session - .messages( - { - sessionID: sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined - }) - - const lastUser = messages?.findLast((m) => m.info.role === "user")?.info - if (lastUser?.role === "user") { - result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` - this.sessionManager.setModel(sessionId, { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - }) - if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) { - result.modes.currentModeId = lastUser.agent - this.sessionManager.setMode(sessionId, lastUser.agent) - } - result.configOptions = buildConfigOptions({ - currentModelId: result.models.currentModelId, - availableModels: result.models.availableModels, - modes: result.modes, - }) - } - for (const msg of messages ?? []) { log.debug("replay message", msg) await this.processMessage(msg) @@ -756,6 +729,9 @@ export class Agent implements ACPAgent { const sessionId = forked.id await this.sessionManager.load(sessionId, directory, mcpServers, model) + const messages = await this.loadSessionMessages(directory, sessionId) + this.restoreSessionStateFromMessages(sessionId, messages) + log.info("fork_session", { sessionId, mcpServers: mcpServers.length }) const mode = await this.loadSessionMode({ @@ -764,20 +740,6 @@ export class Agent implements ACPAgent { sessionId, }) - const messages = await this.sdk.session - .messages( - { - sessionID: sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined - }) - for (const msg of messages ?? []) { log.debug("replay message", msg) await this.processMessage(msg) @@ -797,7 +759,7 @@ export class Agent implements ACPAgent { } } - async unstable_resumeSession(params: ResumeSessionRequest): Promise { + async resumeSession(params: ResumeSessionRequest): Promise { const directory = params.cwd const sessionId = params.sessionId const mcpServers = params.mcpServers ?? [] @@ -806,6 +768,9 @@ export class Agent implements ACPAgent { const model = await defaultModel(this.config, directory) await this.sessionManager.load(sessionId, directory, mcpServers, model) + const messages = await this.loadSessionMessages(directory, sessionId, 20) + this.restoreSessionStateFromMessages(sessionId, messages) + log.info("resume_session", { sessionId, mcpServers: mcpServers.length }) const result = await this.loadSessionMode({ @@ -828,6 +793,27 @@ export class Agent implements ACPAgent { } } + async closeSession(params: CloseSessionRequest): Promise { + const session = this.sessionManager.remove(params.sessionId) + if (!session) return {} + + await this.sdk.session + .abort( + { + sessionID: params.sessionId, + directory: session.cwd, + }, + { throwOnError: true }, + ) + .catch((error) => { + log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId }) + }) + + this.permissionQueues.delete(params.sessionId) + log.info("close_session", { sessionId: params.sessionId }) + return {} + } + private async processMessage(message: SessionMessageResponse) { log.debug("process message", message) if (message.info.role !== "assistant" && message.info.role !== "user") return @@ -1159,23 +1145,26 @@ export class Agent implements ACPAgent { sessionId: string, ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> { const availableModes = await this.loadAvailableModes(directory) - const currentModeId = - this.sessionManager.get(sessionId).modeId || - (await (async () => { - if (!availableModes.length) return undefined - const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) - const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id - this.sessionManager.setMode(sessionId, resolvedModeId) - return resolvedModeId - })()) + const storedModeId = this.sessionManager.get(sessionId).modeId + if (storedModeId && availableModes.some((mode) => mode.id === storedModeId)) { + return { availableModes, currentModeId: storedModeId } + } + + const currentModeId = await (async () => { + if (!availableModes.length) return undefined + const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) + const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id + this.sessionManager.setMode(sessionId, resolvedModeId) + return resolvedModeId + })() return { availableModes, currentModeId } } private async loadSessionMode(params: LoadSessionRequest) { const directory = params.cwd - const model = await defaultModel(this.config, directory) const sessionId = params.sessionId + const model = this.sessionManager.get(sessionId).model ?? (await defaultModel(this.config, directory)) const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers) const entries = sortProvidersByName(providers) @@ -1184,7 +1173,7 @@ export class Agent implements ACPAgent { if (currentVariant && !availableVariants.includes(currentVariant)) { this.sessionManager.setVariant(sessionId, undefined) } - const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const availableModels = buildAvailableModels(entries) const modeState = await this.resolveModeState(directory, sessionId) const currentModeId = modeState.currentModeId const modes = currentModeId @@ -1267,13 +1256,15 @@ export class Agent implements ACPAgent { return { sessionId, models: { - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false), availableModels, }, modes, configOptions: buildConfigOptions({ - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false), availableModels, + currentVariant, + availableVariants, modes, }), _meta: buildVariantMeta({ @@ -1296,6 +1287,24 @@ export class Agent implements ACPAgent { const entries = sortProvidersByName(providers) const availableVariants = modelVariantsFromProviders(entries, selection.model) + const modeState = await this.resolveModeState(session.cwd, session.id) + const modes = modeState.currentModeId + ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } + : undefined + + await this.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "config_option_update", + configOptions: buildConfigOptions({ + currentModelId: formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false), + availableModels: buildAvailableModels(entries), + currentVariant: selection.variant, + availableVariants, + modes, + }), + }, + }) return { _meta: buildVariantMeta({ @@ -1327,6 +1336,14 @@ export class Agent implements ACPAgent { const selection = parseModelSelection(params.value, providers) this.sessionManager.setModel(session.id, selection.model) this.sessionManager.setVariant(session.id, selection.variant) + } else if (params.configId === "effort") { + if (typeof params.value !== "string") throw RequestError.invalidParams("effort value must be a string") + const current = session.model ?? (await defaultModel(this.config, session.cwd)) + const availableVariants = modelVariantsFromProviders(entries, current) + if (!availableVariants.includes(params.value)) { + throw RequestError.invalidParams(JSON.stringify({ error: `Effort not found: ${params.value}` })) + } + this.sessionManager.setVariant(session.id, params.value) } else if (params.configId === "mode") { if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string") const availableModes = await this.loadAvailableModes(session.cwd) @@ -1341,15 +1358,21 @@ export class Agent implements ACPAgent { const updatedSession = this.sessionManager.get(session.id) const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd)) const availableVariants = modelVariantsFromProviders(entries, model) - const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true) - const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, false) + const availableModels = buildAvailableModels(entries) const modeState = await this.resolveModeState(session.cwd, session.id) const modes = modeState.currentModeId ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } : undefined return { - configOptions: buildConfigOptions({ currentModelId, availableModels, modes }), + configOptions: buildConfigOptions({ + currentModelId, + availableModels, + currentVariant: updatedSession.variant, + availableVariants, + modes, + }), } } @@ -1546,6 +1569,37 @@ export class Agent implements ACPAgent { { throwOnError: true }, ) } + + private async loadSessionMessages(directory: string, sessionId: string, limit?: number) { + return this.sdk.session + .messages( + { + sessionID: sessionId, + directory, + limit, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((error) => { + log.error("unexpected error when fetching message", { error }) + return undefined + }) + } + + private restoreSessionStateFromMessages(sessionId: string, messages: SessionMessageResponse[] | undefined) { + const lastUser = messages?.findLast((message) => message.info.role === "user")?.info + if (lastUser?.role !== "user") return + + this.sessionManager.setModel(sessionId, { + providerID: ProviderID.make(lastUser.model.providerID), + modelID: ModelID.make(lastUser.model.modelID), + }) + this.sessionManager.setVariant(sessionId, lastUser.model.variant) + if (lastUser.agent) { + this.sessionManager.setMode(sessionId, lastUser.agent) + } + } } function toToolKind(toolName: string): ToolKind { @@ -1629,11 +1683,11 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider if (specified && !providers.length) return specified + const lastUsed = await lastUsedModel(sdk, directory, providers) + if (lastUsed) return lastUsed + const opencodeProvider = providers.find((p) => p.id === "opencode") if (opencodeProvider) { - if (opencodeProvider.models["big-pickle"]) { - return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") } - } const [best] = Provider.sort(Object.values(opencodeProvider.models)) if (best) { return { @@ -1653,8 +1707,38 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider } if (specified) return specified + throw new Error("No models available") +} - return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") } +async function lastUsedModel( + sdk: OpencodeClient, + directory: string, + providers: Array<{ id: string; models: Record }>, +): Promise<{ providerID: ProviderID; modelID: ModelID } | undefined> { + const session = await sdk.session + .list({ directory, roots: true, limit: 1 }, { throwOnError: true }) + .then((x) => x.data?.[0]) + .catch((error) => { + log.error("failed to list sessions for default model", { error }) + return undefined + }) + if (!session) return + + const lastUser = await sdk.session + .messages({ sessionID: session.id, directory, limit: 20 }, { throwOnError: true }) + .then((x) => x.data?.findLast((message) => message.info.role === "user")?.info) + .catch((error) => { + log.error("failed to load session messages for default model", { error, sessionID: session.id }) + return undefined + }) + if (lastUser?.role !== "user") return + + const provider = providers.find((entry) => entry.id === lastUser.model.providerID) + if (!provider?.models[lastUser.model.modelID]) return + return { + providerID: ProviderID.make(lastUser.model.providerID), + modelID: ModelID.make(lastUser.model.modelID), + } } function parseUri( @@ -1757,8 +1841,14 @@ function formatModelIdWithVariant( includeVariant: boolean, ) { const base = `${model.providerID}/${model.modelID}` - if (!includeVariant || !variant || !availableVariants.includes(variant)) return base - return `${base}/${variant}` + if (!includeVariant || availableVariants.length === 0) return base + const selectedVariant = + variant && availableVariants.includes(variant) + ? variant + : availableVariants.includes(DEFAULT_VARIANT_VALUE) + ? DEFAULT_VARIANT_VALUE + : availableVariants[0] + return `${base}/${selectedVariant}` } function buildVariantMeta(input: { @@ -1810,6 +1900,8 @@ function parseModelSelection( function buildConfigOptions(input: { currentModelId: string availableModels: ModelOption[] + currentVariant?: string + availableVariants?: string[] modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined }): SessionConfigOption[] { const options: SessionConfigOption[] = [ @@ -1822,6 +1914,22 @@ function buildConfigOptions(input: { options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })), }, ] + if (input.availableVariants?.length) { + options.push({ + id: "effort", + name: "Effort", + description: "Available effort levels for this model", + category: "thought_level", + type: "select", + currentValue: + input.currentVariant && input.availableVariants.includes(input.currentVariant) + ? input.currentVariant + : input.availableVariants.includes(DEFAULT_VARIANT_VALUE) + ? DEFAULT_VARIANT_VALUE + : input.availableVariants[0], + options: input.availableVariants.map((variant) => ({ value: variant, name: formatVariantName(variant) })), + }) + } if (input.modes) { options.push({ id: "mode", @@ -1839,4 +1947,11 @@ function buildConfigOptions(input: { return options } +function formatVariantName(variant: string) { + return variant + .split(/[_-]/) + .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) + .join(" ") +} + export * as ACP from "./agent" diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index d932b65701a4..cc1ed0be3098 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -113,4 +113,10 @@ export class ACPSessionManager { this.sessions.set(sessionId, session) return session } + + remove(sessionId: string): ACPSessionState | undefined { + const session = this.sessions.get(sessionId) + this.sessions.delete(sessionId) + return session + } } diff --git a/packages/opencode/test/acp/agent-interface.test.ts b/packages/opencode/test/acp/agent-interface.test.ts index 9fa67de82947..7c4633d7d828 100644 --- a/packages/opencode/test/acp/agent-interface.test.ts +++ b/packages/opencode/test/acp/agent-interface.test.ts @@ -34,10 +34,11 @@ describe("acp.agent interface compliance", () => { "loadSession", "setSessionMode", "authenticate", - // Unstable - SDK checks these with unstable_ prefix + // Capability-gated methods checked by the SDK router "listSessions", + "resumeSession", + "closeSession", "unstable_forkSession", - "unstable_resumeSession", "unstable_setSessionModel", ] From dcfe4b0d5184cb93dd2232f1461641d6530e1abb Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 7 May 2026 00:34:09 +0000 Subject: [PATCH 0003/1034] sync release versions for v1.14.40 --- bun.lock | 30 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 37 insertions(+), 37 deletions(-) diff --git a/bun.lock b/bun.lock index bcf1405a9fab..d481de8e8346 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.39", + "version": "1.14.40", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -312,7 +312,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -328,7 +328,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.39", + "version": "1.14.40", "bin": { "opencode": "./bin/opencode", }, @@ -470,7 +470,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -505,7 +505,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "cross-spawn": "catalog:", }, @@ -520,7 +520,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -555,7 +555,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -604,7 +604,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index def3f65fc251..45908e45b843 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.39", + "version": "1.14.40", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 78a4a1fd446e..71d37d15538a 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.39", + "version": "1.14.40", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index bdfc576fb9d7..c1acfab6e0b7 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.39", + "version": "1.14.40", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index dc56d8bc2996..9c0ce79d7477 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.39", + "version": "1.14.40", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 1600bb877d85..d9648b324314 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 88136cb51a3e..9d92e96e1d14 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.39", + "version": "1.14.40", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 60ccd6cfb6d7..431de79bc552 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.39", + "version": "1.14.40", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 49509aa07536..867d2155da7f 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.39", + "version": "1.14.40", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 81020231280a..666198d55eff 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.39" +version = "1.14.40" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 84219c551066..f5bd20d0be8b 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.39", + "version": "1.14.40", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 3126804ae002..245bb86621ba 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.39", + "version": "1.14.40", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 9bcf2a6f1fc4..fa9e4214e8a3 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.39", + "version": "1.14.40", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 4ec95155c337..8029d2c9ae98 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.39", + "version": "1.14.40", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index f70692d76ffa..6d2cd71e3040 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.39", + "version": "1.14.40", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index f16dfdf13412..3e875f7524c2 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.39", + "version": "1.14.40", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 295ac2ad1056..59390274d5dd 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.39", + "version": "1.14.40", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index c78e2a1486cb..4052393c0d5c 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.39", + "version": "1.14.40", "publisher": "sst-dev", "repository": { "type": "git", From 3480cef52e4bb8fd5d155069786d5207f967ad3f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 00:46:33 +0000 Subject: [PATCH 0004/1034] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 3792b80503e1..a765e803d2e8 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-ynZFX8eCamzBuVpauYLbju/Cqbt2260JNumMUj79PKA=", - "aarch64-linux": "sha256-JCu7JZkdAAHTufWEJRV1gJErKvHFirq+qmVNIRPZ/0w=", - "aarch64-darwin": "sha256-9Dkt/poYBpLdtqA6L9pLe6GS435zFGb5rOYWE5rEnjA=", - "x86_64-darwin": "sha256-Nd5j28gAcM7+0ETBchjk9VojViHy3N/z2MkdU42YuCg=" + "x86_64-linux": "sha256-cgqwEUyOYcOnh07Wz20qkPIrDeBaCBmKiis6HO1EAIU=", + "aarch64-linux": "sha256-AVy0RQuuXiseIwJV9f9API8OEo1jcy84dVidkEXgnX8=", + "aarch64-darwin": "sha256-RIS3/SuSXaMV9WcTXxOJWGDw96LcCFT6E8Ktc28/544=", + "x86_64-darwin": "sha256-GBwXIZQxy5F7tH9TJyWAonX5aETbZ/veAjeznDtsYmk=" } } From 0b702704ae199fc7952f6a81d3816b09a0ff4645 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 6 May 2026 23:01:14 -0400 Subject: [PATCH 0005/1034] zen: nano not used for title gen --- packages/web/src/content/docs/ar/zen.mdx | 2 +- packages/web/src/content/docs/bs/zen.mdx | 2 +- packages/web/src/content/docs/da/zen.mdx | 2 +- packages/web/src/content/docs/de/zen.mdx | 2 +- packages/web/src/content/docs/es/zen.mdx | 2 +- packages/web/src/content/docs/fr/zen.mdx | 2 +- packages/web/src/content/docs/it/zen.mdx | 2 +- packages/web/src/content/docs/ja/zen.mdx | 2 +- packages/web/src/content/docs/ko/zen.mdx | 2 +- packages/web/src/content/docs/nb/zen.mdx | 2 +- packages/web/src/content/docs/pl/zen.mdx | 2 +- packages/web/src/content/docs/pt-br/zen.mdx | 2 +- packages/web/src/content/docs/ru/zen.mdx | 2 +- packages/web/src/content/docs/th/zen.mdx | 2 +- packages/web/src/content/docs/tr/zen.mdx | 2 +- packages/web/src/content/docs/zen.mdx | 2 +- packages/web/src/content/docs/zh-cn/zen.mdx | 2 +- packages/web/src/content/docs/zh-tw/zen.mdx | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index a2e2aacfe9a3..33fd9493ba8e 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -165,7 +165,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | قد تلاحظ _Claude Haiku 3.5_ في سجل الاستخدام. هذا [نموذج منخفض التكلفة](/docs/config/#models) يُستخدم لتوليد عناوين جلساتك. diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index 89527763ca23..3723cbaa3cd5 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -172,7 +172,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Možda ćete primijetiti _Claude Haiku 3.5_ u historiji korištenja. To je [low cost model](/docs/config/#models) koji se koristi za generisanje naslova vaših sesija. diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index 009ad420235b..d45f785a5995 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -172,7 +172,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**. | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Du vil måske bemærke _Claude Haiku 3.5_ i din brugshistorik. Det er en [lavprismodel](/docs/config/#models), som bruges til at generere titlerne på dine sessioner. diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 11550f61c368..5e6c8eee806b 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -161,7 +161,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Möglicherweise siehst du _Claude Haiku 3.5_ in deinem Nutzungsverlauf. Das ist ein [kostengünstiges Modell](/docs/config/#models), das verwendet wird, um die Titel deiner Sessions zu generieren. diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index f1a08c7ba51c..15436226a52d 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -172,7 +172,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Puede que notes _Claude Haiku 3.5_ en tu historial de uso. Este es un [modelo de bajo costo](/docs/config/#models) que se usa para generar los títulos de tus sesiones. diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index 7710da225983..fdf14e8fb023 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -161,7 +161,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Vous remarquerez peut-être _Claude Haiku 3.5_ dans votre historique d'utilisation. Il s'agit d'un [modèle à faible coût](/docs/config/#models) utilisé pour générer les titres de vos sessions. diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index a3b87255359c..a53d6a2ba122 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -172,7 +172,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**. | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Potresti notare _Claude Haiku 3.5_ nella cronologia di utilizzo. È un [modello a basso costo](/docs/config/#models) usato per generare i titoli delle tue sessioni. diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index 8fcdc6d46bb2..64427a72ec5c 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -161,7 +161,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | 使用履歴に _Claude Haiku 3.5_ が表示されることがあります。これはセッションのタイトル生成に使われる [low cost model](/docs/config/#models) です。 diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index eb99c29fe6f9..e80a5e871085 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -161,7 +161,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | 사용 기록에서 *Claude Haiku 3.5*를 볼 수 있습니다. 이는 세션 제목을 생성할 때 사용되는 [저비용 모델](/docs/config/#models)입니다. diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index 8ab1762e1f6f..4bd1e6115ec3 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -172,7 +172,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**. | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Du vil kanskje legge merke til _Claude Haiku 3.5_ i brukshistorikken din. Dette er en [lavprismodell](/docs/config/#models) som brukes til å generere titlene på øktene dine. diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index 52906036c03b..ebd16d785605 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -172,7 +172,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów* | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Możesz zauważyć _Claude Haiku 3.5_ w historii użycia. To [niedrogi model](/docs/config/#models), który służy do generowania tytułów Twoich sesji. diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index b35cfdbde5cb..1dcc98c5d505 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -161,7 +161,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**. | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Você pode notar _Claude Haiku 3.5_ no seu histórico de uso. Este é um [low cost model](/docs/config/#models) usado para gerar os títulos das suas sessões. diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index 919026447fe5..10c55fc4ddeb 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -172,7 +172,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Вы можете заметить _Claude Haiku 3.5_ в истории использования. Это [недорогая модель](/docs/config/#models), которая используется для генерации заголовков ваших сессий. diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index b3914b73c2da..cb2556ef6346 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -163,7 +163,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | คุณอาจสังเกตเห็น _Claude Haiku 3.5_ ในประวัติการใช้งานของคุณ นี่คือ [low cost model](/docs/config/#models) ที่ใช้สร้างชื่อ session ของคุณ diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index 3e53ba40d4e2..36c1bfc66e7d 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -161,7 +161,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Kullanım geçmişinizde _Claude Haiku 3.5_ görebilirsiniz. Bu, oturum başlıklarınızı oluşturmak için kullanılan [düşük maliyetli bir modeldir](/docs/config/#models). diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index 58baceb25825..333e74434b46 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -172,7 +172,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | You might notice _Claude Haiku 3.5_ in your usage history. This is a [low cost model](/docs/config/#models) that's used to generate the titles of your sessions. diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 03124a34f481..9ad7e6b53dfe 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -161,7 +161,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | 你可能会在使用记录中看到 _Claude Haiku 3.5_。这是一个[低成本模型](/docs/config/#models),用于生成会话标题。 diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index ebd48dea8f34..9511bd9e24f5 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -166,7 +166,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | 你可能會在使用紀錄中看到 _Claude Haiku 3.5_。這是一個[低成本模型](/docs/config/#models), 會用來產生工作階段的標題。 From 72ec05d0be201514ca506741567e57ecec0e72ee Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 7 May 2026 00:32:33 -0400 Subject: [PATCH 0006/1034] go: rate limit metadata --- .../console/app/src/routes/zen/util/error.ts | 11 ++++++- .../app/src/routes/zen/util/handler.ts | 30 ++++++++++++++----- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts index b2a1d30d033a..216b6564e7ed 100644 --- a/packages/console/app/src/routes/zen/util/error.ts +++ b/packages/console/app/src/routes/zen/util/error.ts @@ -13,4 +13,13 @@ class LimitError extends Error { } export class RateLimitError extends LimitError {} export class FreeUsageLimitError extends LimitError {} -export class SubscriptionUsageLimitError extends LimitError {} + +class SubscriptionUsageLimitError extends LimitError { + workspace: string + constructor(message: string, workspace: string, retryAfter?: number) { + super(message, retryAfter) + this.workspace = workspace + } +} +export class GoUsageLimitError extends SubscriptionUsageLimitError {} +export class BlackUsageLimitError extends SubscriptionUsageLimitError {} diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 16f9174325cf..c12129ff1db3 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -23,7 +23,8 @@ import { ModelError, RateLimitError, FreeUsageLimitError, - SubscriptionUsageLimitError, + GoUsageLimitError, + BlackUsageLimitError, } from "./error" import { buildCostChunk, @@ -395,7 +396,8 @@ export async function handler( if ( error instanceof RateLimitError || error instanceof FreeUsageLimitError || - error instanceof SubscriptionUsageLimitError + error instanceof GoUsageLimitError || + error instanceof BlackUsageLimitError ) { const headers = new Headers() if (error.retryAfter) { @@ -404,7 +406,14 @@ export async function handler( return new Response( JSON.stringify({ type: "error", - error: { type: error.constructor.name, message: error.message }, + error: { + type: error.constructor.name, + message: error.message, + }, + metadata: + error instanceof GoUsageLimitError || error instanceof BlackUsageLimitError + ? { workspace: error.workspace } + : {}, }), { status: 429, headers }, ) @@ -693,10 +702,11 @@ export async function handler( timeUpdated: sub.timeFixedUpdated, }) if (result.status === "rate-limited") - throw new SubscriptionUsageLimitError( + throw new BlackUsageLimitError( t("zen.api.error.subscriptionQuotaExceeded", { retryIn: formatRetryTime(result.resetInSec), }), + authInfo.workspaceID, result.resetInSec, ) } @@ -711,10 +721,11 @@ export async function handler( timeUpdated: sub.timeRollingUpdated, }) if (result.status === "rate-limited") - throw new SubscriptionUsageLimitError( + throw new BlackUsageLimitError( t("zen.api.error.subscriptionQuotaExceeded", { retryIn: formatRetryTime(result.resetInSec), }), + authInfo.workspaceID, result.resetInSec, ) } @@ -739,8 +750,9 @@ export async function handler( timeUpdated: sub.timeWeeklyUpdated, }) if (result.status === "rate-limited") - throw new SubscriptionUsageLimitError( + throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + authInfo.workspaceID, result.resetInSec, ) } @@ -754,8 +766,9 @@ export async function handler( timeSubscribed: sub.timeCreated, }) if (result.status === "rate-limited") - throw new SubscriptionUsageLimitError( + throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + authInfo.workspaceID, result.resetInSec, ) } @@ -769,8 +782,9 @@ export async function handler( timeUpdated: sub.timeRollingUpdated, }) if (result.status === "rate-limited") - throw new SubscriptionUsageLimitError( + throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + authInfo.workspaceID, result.resetInSec, ) } From ba1ec62caf7c114ffe3d422a51c90c1e572f15e4 Mon Sep 17 00:00:00 2001 From: carmit hershman <78722358+carmithersh@users.noreply.github.com> Date: Thu, 7 May 2026 08:37:14 +0300 Subject: [PATCH 0007/1034] docs: add opencode-jfrog-plugin to ecosystem list for JFrog integration (#26019) --- packages/web/src/content/docs/ecosystem.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 055daf14193d..55f0bcdaac79 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -52,6 +52,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode | | [opencode-sentry-monitor](https://github.com/stolinski/opencode-sentry-monitor) | Trace and debug your AI agents with Sentry AI Monitoring | | [opencode-firecrawl](https://github.com/firecrawl/opencode-firecrawl) | Web scraping, crawling, and search via the Firecrawl CLI | +| [opencode-jfrog-plugin](https://github.com/jfrog/opencode-jfrog-plugin) | JFrog Plugin for seamless integration of Opencode users to JFrog platform | --- From 9b30ee2db217925b31064e71a65a2ee57c130611 Mon Sep 17 00:00:00 2001 From: Jesse <82005785+jessedi0n@users.noreply.github.com> Date: Thu, 7 May 2026 07:39:14 +0200 Subject: [PATCH 0008/1034] fix(desktop): add macOS settings menu entry (#26081) Co-authored-by: jesse.mahnken --- packages/desktop/src/main/menu.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts index 0d9a697fa900..2d5a900f3922 100644 --- a/packages/desktop/src/main/menu.ts +++ b/packages/desktop/src/main/menu.ts @@ -23,6 +23,11 @@ export function createMenu(deps: Deps) { enabled: UPDATER_ENABLED, click: () => deps.checkForUpdates(), }, + { + label: "Settings", + accelerator: "Cmd+,", + click: () => deps.trigger("settings.open"), + }, { label: "Reload Webview", click: () => deps.reload(), From 54a78c92246de620234200af3649f8392b3f6761 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 7 May 2026 13:48:56 +0800 Subject: [PATCH 0009/1034] feat(desktop): move server to utilityProcess (#25962) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> --- packages/desktop/electron.vite.config.ts | 2 +- packages/desktop/src/main/apps.ts | 55 +++--- packages/desktop/src/main/env.d.ts | 1 + packages/desktop/src/main/index.ts | 79 +++++---- packages/desktop/src/main/ipc.ts | 2 +- packages/desktop/src/main/server.ts | 217 ++++++++++++++++++++--- packages/desktop/src/main/sidecar.ts | 178 +++++++++++++++++++ 7 files changed, 441 insertions(+), 93 deletions(-) create mode 100644 packages/desktop/src/main/sidecar.ts diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index a352e03fdd4a..52aa699ff60e 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -37,7 +37,7 @@ export default defineConfig({ }, build: { rollupOptions: { - input: { index: "src/main/index.ts" }, + input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" }, }, externalizeDeps: { include: [nodePtyPkg] }, }, diff --git a/packages/desktop/src/main/apps.ts b/packages/desktop/src/main/apps.ts index 174da94a5d9b..bf25417b8347 100644 --- a/packages/desktop/src/main/apps.ts +++ b/packages/desktop/src/main/apps.ts @@ -1,14 +1,22 @@ -import { execFileSync } from "node:child_process" -import { existsSync, readFileSync, readdirSync } from "node:fs" +import { execFile, execFileSync } from "node:child_process" +import { access, readFile, readdir } from "node:fs/promises" import { dirname, extname, join } from "node:path" +import util from "node:util" -export function checkAppExists(appName: string): boolean { +const execFilePromise = util.promisify(execFile) + +const exists = (path: string) => + access(path) + .then(() => true) + .catch(() => false) + +export function checkAppExists(appName: string) { if (process.platform === "win32") return true if (process.platform === "linux") return true return checkMacosApp(appName) } -export function resolveAppPath(appName: string): string | null { +export function resolveAppPath(appName: string) { if (process.platform !== "win32") return appName return resolveWindowsAppPath(appName) } @@ -32,26 +40,25 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string } } -function checkMacosApp(appName: string) { +async function checkMacosApp(appName: string) { const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`] const home = process.env.HOME if (home) locations.push(`${home}/Applications/${appName}.app`) - if (locations.some((location) => existsSync(location))) return true - - try { - execFileSync("which", [appName]) - return true - } catch { - return false + for (const location of locations) { + if (await exists(location)) return true } + + return execFilePromise("which", [appName]) + .then(() => true) + .catch(() => false) } -function resolveWindowsAppPath(appName: string): string | null { +async function resolveWindowsAppPath(appName: string): Promise { let output: string try { - output = execFileSync("where", [appName]).toString() + output = execFilePromise("where", [appName]).toString() } catch { return null } @@ -66,8 +73,8 @@ function resolveWindowsAppPath(appName: string): string | null { const exe = paths.find((path) => hasExt(path, "exe")) if (exe) return exe - const resolveCmd = (path: string) => { - const content = readFileSync(path, "utf8") + const resolveCmd = async (path: string) => { + const content = await readFile(path, "utf8") for (const token of content.split('"').map((value: string) => value.trim())) { const lower = token.toLowerCase() if (!lower.includes(".exe")) continue @@ -85,10 +92,10 @@ function resolveWindowsAppPath(appName: string): string | null { return join(current, part) }, base) - if (existsSync(resolved)) return resolved + if (await exists(resolved)) return resolved } - if (existsSync(token)) return token + if (await exists(token)) return token } return null @@ -96,20 +103,20 @@ function resolveWindowsAppPath(appName: string): string | null { for (const path of paths) { if (hasExt(path, "cmd") || hasExt(path, "bat")) { - const resolved = resolveCmd(path) + const resolved = await resolveCmd(path) if (resolved) return resolved } if (!extname(path)) { const cmd = `${path}.cmd` - if (existsSync(cmd)) { - const resolved = resolveCmd(cmd) + if (await exists(cmd)) { + const resolved = await resolveCmd(cmd) if (resolved) return resolved } const bat = `${path}.bat` - if (existsSync(bat)) { - const resolved = resolveCmd(bat) + if (await exists(bat)) { + const resolved = await resolveCmd(bat) if (resolved) return resolved } } @@ -126,7 +133,7 @@ function resolveWindowsAppPath(appName: string): string | null { const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))] for (const dir of dirs) { try { - for (const entry of readdirSync(dir)) { + for (const entry of await readdir(dir)) { const candidate = join(dir, entry) if (!hasExt(candidate, "exe")) continue const stem = entry.replace(/\.exe$/i, "") diff --git a/packages/desktop/src/main/env.d.ts b/packages/desktop/src/main/env.d.ts index 1de56e1c9048..eee21e48cb19 100644 --- a/packages/desktop/src/main/env.d.ts +++ b/packages/desktop/src/main/env.d.ts @@ -5,6 +5,7 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv } + declare module "virtual:opencode-server" { export namespace Server { export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index d3c8fcc04e7d..f75cd719a292 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -47,7 +47,15 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" +import { + getDefaultServerUrl, + getWslConfig, + preferAppEnv, + setDefaultServerUrl, + setWslConfig, + spawnLocalServer, + type SidecarListener, +} from "./server" import { createLoadingWindow, createMainWindow, @@ -55,15 +63,13 @@ import { setBackgroundColor, setDockIcon, } from "./windows" -import { drizzle } from "drizzle-orm/node-sqlite/driver" -import type { Server } from "virtual:opencode-server" import { migrate } from "./migrate" const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } let mainWindow: BrowserWindow | null = null -let server: Server.Listener | null = null +let server: SidecarListener | null = null const loadingComplete = defer() const pendingDeepLinks: string[] = [] @@ -107,6 +113,8 @@ function setupApp() { return } + preferAppEnv(app.getPath("userData")) + app.on("second-instance", (_event: Event, argv: string[]) => { const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) if (urls.length) { @@ -123,17 +131,16 @@ function setupApp() { }) app.on("before-quit", () => { - killSidecar() + void killSidecar() }) app.on("will-quit", () => { - killSidecar() + void killSidecar() }) for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { - killSidecar() - app.exit(0) + void killSidecar().finally(() => app.exit(0)) }) } @@ -184,7 +191,6 @@ function setInitStep(step: InitStep) { async function initialize() { const needsMigration = !sqliteFileExists() - const sqliteDone = needsMigration ? defer() : undefined let overlay: BrowserWindow | null = null const port = await getSidecarPort() @@ -199,31 +205,26 @@ async function initialize() { setInitStep({ phase: "sqlite_waiting" }) if (overlay) sendSqliteMigrationProgress(overlay, progress) if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) - if (progress.type === "Done") sqliteDone?.resolve() }) - if (needsMigration) { - const { Database, JsonMigration } = await import("virtual:opencode-server") - await JsonMigration.run(drizzle({ client: Database.Client().$client }), { - progress: (event: { current: number; total: number }) => { - const percent = Math.round(event.current / event.total) * 100 - initEmitter.emit("sqlite", { type: "InProgress", value: percent }) - }, - }) - initEmitter.emit("sqlite", { type: "Done" }) - - sqliteDone?.resolve() - } - - if (needsMigration) { - await sqliteDone?.promise - } - logger.log("spawning sidecar", { url }) - const { listener, health } = await spawnLocalServer(hostname, port, password, () => { - ensureLoopbackNoProxy() - useEnvProxy() - }) + const { listener, health } = await spawnLocalServer( + hostname, + port, + password, + () => { + ensureLoopbackNoProxy() + useEnvProxy() + }, + { + needsMigration, + userDataPath: app.getPath("userData"), + onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), + onStdout: (message) => logger.log("sidecar stdout", { message }), + onStderr: (message) => logger.warn("sidecar stderr", { message }), + onExit: (code) => logger.warn("sidecar exited", { code }), + }, + ) server = listener serverReady.resolve({ url, @@ -273,9 +274,10 @@ function wireMenu() { }, reload: () => mainWindow?.reload(), relaunch: () => { - killSidecar() - app.relaunch() - app.exit(0) + void killSidecar().finally(() => { + app.relaunch() + app.exit(0) + }) }, }) } @@ -304,7 +306,7 @@ registerIpcHandlers({ getDisplayBackend: async () => null, setDisplayBackend: async () => undefined, parseMarkdown: async (markdown) => parseMarkdown(markdown), - checkAppExists: async (appName) => checkAppExists(appName), + checkAppExists: (appName) => checkAppExists(appName), wslPath: async (path, mode) => wslPath(path, mode), resolveAppPath: async (appName) => resolveAppPath(appName), loadingWindowComplete: () => loadingComplete.resolve(), @@ -314,10 +316,11 @@ registerIpcHandlers({ setBackgroundColor: (color) => setBackgroundColor(color), }) -function killSidecar() { +async function killSidecar() { if (!server) return - server.stop() + const current = server server = null + await current.stop() } function ensureLoopbackNoProxy() { @@ -440,7 +443,7 @@ async function installUpdate() { logger.log("installing downloaded update", { version: downloadedUpdateVersion, }) - killSidecar() + await killSidecar() autoUpdater.quitAndInstall() } diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 1c4af0eb60a5..dbcd4239dc2d 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -19,7 +19,7 @@ const pickerFilters = (ext?: string[]) => { } type Deps = { - killSidecar: () => void + killSidecar: () => Promise | void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise getWindowConfig: () => Promise | WindowConfig consumeInitialDeepLinks: () => Promise | string[] diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 4b8cb04943bc..635a93578afc 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -1,12 +1,37 @@ -import { app } from "electron" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { app, utilityProcess } from "electron" +import type { Details } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" -import { getUserShell, loadShellEnv } from "./shell-env" +import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env" import { getStore } from "./store" +import type { SqliteMigrationProgress } from "../preload/types" export type WslConfig = { enabled: boolean } export type HealthCheck = { wait: Promise } +type SidecarMessage = + | { type: "sqlite"; progress: SqliteMigrationProgress } + | { type: "ready" } + | { type: "stopped" } + | { type: "error"; error: { message: string; stack?: string } } + +export type SidecarListener = { stop: () => Promise } + +const SIDECAR_SERVICE_NAME = "opencode server" +const SIDECAR_START_STALL_TIMEOUT = 60_000 +const SIDECAR_STOP_TIMEOUT = 6_000 + +type SpawnLocalServerOptions = { + needsMigration: boolean + userDataPath: string + onSqliteProgress?: (progress: SqliteMigrationProgress) => void + onStdout?: (message: string) => void + onStderr?: (message: string) => void + onExit?: (code: number) => void +} + export function getDefaultServerUrl(): string | null { const value = getStore().get(DEFAULT_SERVER_URL_KEY) return typeof value === "string" ? value : null @@ -30,49 +55,155 @@ export function setWslConfig(config: WslConfig) { getStore().set(WSL_ENABLED_KEY, config.enabled) } -export async function spawnLocalServer(hostname: string, port: number, password: string, configureEnv?: () => void) { - prepareServerEnv(password) +export function preferAppEnv(userDataPath: string) { + const shell = process.platform === "win32" ? null : getUserShell() + Object.assign( + process.env, + mergeShellEnv(shell ? loadShellEnv(shell) : null, { + ...process.env, + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_CLIENT: "desktop", + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + }), + ) +} + +export async function spawnLocalServer( + hostname: string, + port: number, + password: string, + configureEnv: () => void, + options: SpawnLocalServerOptions, +) { configureEnv?.() - const { Log, Server } = await import("virtual:opencode-server") - await Log.init({ level: "WARN" }) - const listener = await Server.listen({ - port, - hostname, - username: "opencode", - password, - cors: ["oc://renderer"], + const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js") + const child = utilityProcess.fork(sidecar, [], { + cwd: process.cwd(), + env: createSidecarEnv(), + serviceName: SIDECAR_SERVICE_NAME, + stdio: "pipe", + }) + let exited = false + const exit = defer() + + const onProcessGone = (_event: unknown, details: Details) => { + if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return + options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`) + } + + app.on("child-process-gone", onProcessGone) + child.once("exit", (code) => { + exited = true + app.off("child-process-gone", onProcessGone) + options.onExit?.(code) + exit.resolve(code) + }) + child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`)) + + child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd())) + child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd())) + + await new Promise((resolve, reject) => { + let done = false + let timeout: NodeJS.Timeout + + const fail = (error: Error) => { + if (done) return + done = true + cleanup() + reject(error) + } + + const refreshTimeout = () => { + clearTimeout(timeout) + timeout = setTimeout(() => { + fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`)) + }, SIDECAR_START_STALL_TIMEOUT) + } + + const onMessage = (message: SidecarMessage) => { + if (message.type === "sqlite") { + refreshTimeout() + options.onSqliteProgress?.(message.progress) + return + } + if (message.type === "ready") { + if (done) return + done = true + cleanup() + resolve() + return + } + if (message.type === "error") { + fail(Object.assign(new Error(message.error.message), { stack: message.error.stack })) + } + } + const onExit = (code: number) => { + fail(new Error(`Sidecar exited before ready with code ${code}`)) + } + const cleanup = () => { + clearTimeout(timeout) + child.off("message", onMessage) + child.off("exit", onExit) + } + + child.on("message", onMessage) + child.on("exit", onExit) + refreshTimeout() + child.postMessage({ + type: "start", + hostname, + port, + password, + userDataPath: options.userDataPath, + needsMigration: options.needsMigration, + }) + }).catch((error) => { + if (!exited) child.kill() + throw error }) const wait = (async () => { const url = `http://${hostname}:${port}` + let healthy = false + const gone = exit.promise.then((code) => { + if (healthy) return + throw new Error(`Sidecar exited before health check passed with code ${code}`) + }) const ready = async () => { while (true) { await new Promise((resolve) => setTimeout(resolve, 100)) - if (await checkHealth(url, password)) return + if (await checkHealth(url, password)) { + healthy = true + return + } } } - await ready() + await Promise.race([ready(), gone]) })() - return { listener, health: { wait } } -} + let stopping: Promise | undefined -function prepareServerEnv(password: string) { - const shell = process.platform === "win32" ? null : getUserShell() - const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} - const env = { - ...process.env, - ...shellEnv, - OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", - OPENCODE_SERVER_USERNAME: "opencode", - OPENCODE_SERVER_PASSWORD: password, - XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? app.getPath("userData"), + return { + listener: { + stop: () => { + if (stopping) return stopping + if (exited) return Promise.resolve() + child.postMessage({ type: "stop" }) + stopping = Promise.race([ + exit.promise.then(() => undefined), + delay(SIDECAR_STOP_TIMEOUT).then(() => { + if (!exited) child.kill() + }), + ]) + return stopping + }, + }, + health: { wait }, } - Object.assign(process.env, env) } export async function checkHealth(url: string, password?: string | null): Promise { @@ -100,3 +231,31 @@ export async function checkHealth(url: string, password?: string | null): Promis return false } } + +function createSidecarEnv(): Record { + const env = Object.fromEntries( + Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])), + ) + delete env.DEBUG + if (process.platform === "linux") delete env.LD_PRELOAD + return env +} + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function serializeError(error: unknown) { + if (error instanceof Error) return { message: error.message, stack: error.stack } + return { message: String(error) } +} + +function defer() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} diff --git a/packages/desktop/src/main/sidecar.ts b/packages/desktop/src/main/sidecar.ts new file mode 100644 index 000000000000..e7d652b6e1aa --- /dev/null +++ b/packages/desktop/src/main/sidecar.ts @@ -0,0 +1,178 @@ +import { drizzle } from "drizzle-orm/node-sqlite/driver" +import * as http from "node:http" +import * as tls from "node:tls" + +type NodeHttpWithEnvProxy = typeof http & { + setGlobalProxyFromEnv: () => void +} + +type NodeTlsWithSystemCertificates = typeof tls & { + getCACertificates: (type: "default" | "system") => string[] + setDefaultCACertificates: (certificates: string[]) => void +} + +type StartCommand = { + type: "start" + hostname: string + port: number + password: string + userDataPath: string + needsMigration: boolean +} + +type StopCommand = { type: "stop" } +type SidecarCommand = StartCommand | StopCommand + +type SidecarMessage = + | { type: "sqlite"; progress: { type: "InProgress"; value: number } | { type: "Done" } } + | { type: "ready" } + | { type: "stopped" } + | { type: "error"; error: { message: string; stack?: string } } + +type ParentPort = { + postMessage(message: SidecarMessage): void + on(event: "message", listener: (event: { data: unknown }) => void): void +} + +type Listener = { + stop(close?: boolean): void | Promise +} + +const parentPort = getParentPort() +let listener: Listener | undefined + +parentPort.on("message", (event) => { + const command = parseCommand(event.data) + if (!command) return + if (command.type === "stop") { + void stop() + return + } + void start(command) +}) + +async function start(command: StartCommand) { + try { + prepareSidecarEnv(command.password, command.userDataPath) + ensureLoopbackNoProxy() + useSystemCertificates() + useEnvProxy() + const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server") + await Log.init({ level: "WARN" }) + + if (command.needsMigration) { + await JsonMigration.run(drizzle({ client: Database.Client().$client }), { + progress: (event: { current: number; total: number }) => { + parentPort.postMessage({ + type: "sqlite", + progress: { + type: "InProgress", + value: event.total === 0 ? 100 : Math.round((event.current / event.total) * 100), + }, + }) + }, + }) + parentPort.postMessage({ type: "sqlite", progress: { type: "Done" } }) + } + + listener = await Server.listen({ + port: command.port, + hostname: command.hostname, + username: "opencode", + password: command.password, + cors: ["oc://renderer"], + }) + parentPort.postMessage({ type: "ready" }) + } catch (error) { + parentPort.postMessage({ type: "error", error: serializeError(error) }) + setImmediate(() => process.exit(1)) + } +} + +async function stop() { + try { + await listener?.stop() + } finally { + listener = undefined + parentPort.postMessage({ type: "stopped" }) + setImmediate(() => process.exit(0)) + } +} + +function prepareSidecarEnv(password: string, userDataPath: string) { + Object.assign(process.env, { + OPENCODE_SERVER_USERNAME: "opencode", + OPENCODE_SERVER_PASSWORD: password, + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + }) +} + +function ensureLoopbackNoProxy() { + const loopback = ["127.0.0.1", "localhost", "::1"] + const upsert = (key: string) => { + const items = (process.env[key] ?? "") + .split(",") + .map((value: string) => value.trim()) + .filter((value: string) => Boolean(value)) + + for (const host of loopback) { + if (items.some((value: string) => value.toLowerCase() === host)) continue + items.push(host) + } + + process.env[key] = items.join(",") + } + + upsert("NO_PROXY") + upsert("no_proxy") +} + +function useSystemCertificates() { + try { + const nodeTls = tls as NodeTlsWithSystemCertificates + nodeTls.setDefaultCACertificates([ + ...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]), + ]) + } catch (error) { + console.warn("failed to load system certificates", error) + } +} + +function useEnvProxy() { + try { + ;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv() + } catch (error) { + console.warn("failed to load proxy environment", error) + } +} + +function parseCommand(value: unknown): SidecarCommand | undefined { + if (!value || typeof value !== "object") return + const command = value as Partial + if (command.type === "stop") return { type: "stop" } + if (command.type !== "start") return + if (typeof command.hostname !== "string") return + if (typeof command.port !== "number") return + if (typeof command.password !== "string") return + if (typeof command.userDataPath !== "string") return + if (typeof command.needsMigration !== "boolean") return + return { + type: "start", + hostname: command.hostname, + port: command.port, + password: command.password, + userDataPath: command.userDataPath, + needsMigration: command.needsMigration, + } +} + +function serializeError(error: unknown) { + if (error instanceof Error) return { message: error.message, stack: error.stack } + return { message: String(error) } +} + +function getParentPort() { + const port = process.parentPort as ParentPort | undefined + if (!port) throw new Error("Sidecar parent port unavailable") + return port +} From 293bb422fa920a426a0bf98ef95a3f6d77f9c504 Mon Sep 17 00:00:00 2001 From: Bence Ferdinandy Date: Thu, 7 May 2026 07:52:07 +0200 Subject: [PATCH 0010/1034] fix(format): restore stdout/stderr ignore for formatter processes (#26037) Co-authored-by: Aiden Cline --- packages/opencode/src/format/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 7c122e3501cb..a61eb7be29ac 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -91,6 +91,9 @@ export const layer = Layer.effect( cwd: dir, env: item.environment, extendEnv: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", }), ) .pipe( From f8aa4a3be0cf3fae670b69c8940f42a33963e7b5 Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Thu, 7 May 2026 09:56:10 +0200 Subject: [PATCH 0011/1034] chore: simplify honeycomb alerts (#26142) --- bun.lock | 9 - infra/console.ts | 6 +- infra/monitoring.ts | 367 ++++-------------- infra/secret.ts | 7 + packages/console/app/package.json | 1 - .../app/src/routes/honeycomb/webhook.ts | 81 ++++ .../app/src/routes/incident/webhook.ts | 77 ---- packages/console/core/src/util/crypto.ts | 8 + packages/console/core/sst-env.d.ts | 4 +- packages/console/function/sst-env.d.ts | 4 +- packages/console/resource/sst-env.d.ts | 4 +- packages/enterprise/sst-env.d.ts | 4 +- packages/function/sst-env.d.ts | 4 +- sst-env.d.ts | 4 +- sst.config.ts | 13 +- 15 files changed, 185 insertions(+), 408 deletions(-) create mode 100644 packages/console/app/src/routes/honeycomb/webhook.ts delete mode 100644 packages/console/app/src/routes/incident/webhook.ts create mode 100644 packages/console/core/src/util/crypto.ts diff --git a/bun.lock b/bun.lock index d481de8e8346..8e3c9b7452de 100644 --- a/bun.lock +++ b/bun.lock @@ -107,7 +107,6 @@ "solid-js": "catalog:", "solid-list": "0.3.0", "solid-stripe": "0.8.1", - "svix": "1.92.2", "vite": "catalog:", "zod": "catalog:", }, @@ -2168,8 +2167,6 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], - "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="], "@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="], @@ -3180,8 +3177,6 @@ "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], - "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], @@ -4656,8 +4651,6 @@ "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], - "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], - "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -4726,8 +4719,6 @@ "sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="], - "svix": ["svix@1.92.2", "", { "dependencies": { "standardwebhooks": "1.0.0" } }, "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ=="], - "system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="], "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], diff --git a/infra/console.ts b/infra/console.ts index d92fcaa8e2e3..ab6502a8f875 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -1,5 +1,6 @@ import { domain } from "./stage" import { EMAILOCTOPUS_API_KEY } from "./app" +import { SECRET } from "./secret" //////////////// // DATABASE @@ -221,8 +222,6 @@ const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", { properties: { value: stripeWebhook.secret }, }) -const INCIDENT_WEBHOOK_SIGNING_SECRET = new sst.Secret("INCIDENT_WEBHOOK_SIGNING_SECRET") -const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL") const gatewayKv = new sst.cloudflare.Kv("GatewayKv") @@ -233,6 +232,7 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv") const bucket = new sst.cloudflare.Bucket("ZenData") const bucketNew = new sst.cloudflare.Bucket("ZenDataNew") +const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL") const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID") const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY") @@ -254,8 +254,8 @@ new sst.cloudflare.x.SolidStart("Console", { database, AUTH_API_URL, STRIPE_WEBHOOK_SECRET, - INCIDENT_WEBHOOK_SIGNING_SECRET, DISCORD_INCIDENT_WEBHOOK_URL, + SECRET.HoneycombWebhookSecret, STRIPE_SECRET_KEY, EMAILOCTOPUS_API_KEY, AWS_SES_ACCESS_KEY_ID, diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 4fb7183a2fcd..4e22e3d812c4 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -1,318 +1,91 @@ -const displayName = (s: string) => - s - .split("-") - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" ") - .replace(/(?<=\d) (?=\d)/g, ".") +import { SECRET } from "./secret" +import { domain } from "./stage" -const resourceName = (s: string) => displayName(s).replace(/[^a-zA-Z0-9]/g, "") - -const varSpec = (label: string, name: string) => - $jsonStringify({ - content: [ - { - content: [ - { - attrs: { - name, - label, - missing: false, - }, - type: "varSpec", - }, - ], - type: "paragraph", - }, - ], - type: "doc", - }) - -const fields = { - model: incident.getAlertAttributeOutput({ name: "Model" }), - product: incident.getAlertAttributeOutput({ name: "Product" }), -} - -const alertSource = new incident.AlertSource("HoneycombAlertSource", { - name: $app.stage === "production" ? "Honeycomb" : `Honeycomb (${$app.stage})`, - sourceType: "honeycomb", - template: { - title: { - literal: varSpec("Payload -> Title", "title"), - }, - description: { - literal: varSpec("Payload -> Description", "description"), - }, - attributes: [ - { - alertAttributeId: fields.model.id, - binding: { - value: { - reference: 'expressions["model"]', - }, - mergeStrategy: "first_wins", - }, - }, - { - alertAttributeId: fields.product.id, - binding: { - value: { - reference: 'expressions["product"]', - }, - mergeStrategy: "first_wins", - }, - }, - ], - expressions: [ - { - label: "Model", - operations: [ - { - operationType: "parse", - parse: { - returns: { - array: false, - type: fields.model.type, - }, - source: "$['model']", - }, - }, - ], - reference: "model", - rootReference: "payload", - }, - { - label: "Product", - operations: [ - { - operationType: "parse", - parse: { - returns: { - array: false, - type: fields.product.type, - }, - source: "$['product']", - }, - }, - ], - reference: "product", - rootReference: "payload", - }, - ], - }, -}) - -const webhookRecipient = new honeycomb.WebhookRecipient(`IncidentWebhook`, { - name: $app.stage === "production" ? "Incident.io" : `Incident.io (${$app.stage})`, - url: alertSource.alertEventsUrl, - secret: alertSource.secretToken, +const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", { + name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`, + url: `https://${domain}/honeycomb/webhook`, + secret: SECRET.HoneycombWebhookSecret.result, templates: [ { type: "trigger", - body: $jsonStringify({ - title: "{{ .Name }}", - description: "{{ .Description }}", - status: "{{ .Alert.Status }}", - deduplication_key: "{{ .Alert.InstanceID }}", - source_url: "{{ .Result.URL }}", - model: "{{ .Vars.model }}", - product: "{{ .Vars.product }}", - }), + body: `{ + "url": {{ .Result.URL | quote }}, + "type": {{ .Vars.type | quote }}, + "name": {{ .Name | quote }}, + "status": {{ .Alert.Status | quote }}, + "isTest": {{ .Alert.IsTest }}, + "groups": {{ .Result.GroupsTriggered | toJson }} + }`, }, ], variables: [ { - name: "model", - }, - { - name: "product", + name: "type", }, ], }) -new incident.AlertRoute("HoneycombAlertRoute", { - name: $app.stage === "production" ? "Honeycomb" : `Honeycomb (${$app.stage})`, - enabled: true, - isPrivate: false, - alertSources: [ - { - alertSourceId: alertSource.id, - conditionGroups: [ - { - conditions: [ - { - subject: "alert.title", - operation: "is_set", - paramBindings: [], - }, - ], - }, - ], - }, - ], - conditionGroups: [ - { - conditions: [ - { - subject: "alert.title", - operation: "is_set", - paramBindings: [], - }, - ], - }, - ], - expressions: [], - escalationConfig: { - autoCancelEscalations: true, - escalationTargets: [], - }, - incidentConfig: { - autoDeclineEnabled: true, - enabled: true, - conditionGroups: [], - deferTimeSeconds: 0, - groupingKeys: [ - { - reference: $interpolate`alert.attributes.${fields.model.id}`, - }, +const modelHttpErrorsQuery = (product: "go" | "zen") => { + const filters = [ + { column: "model", op: "exists" }, + { column: "event_type", op: "=", value: "completions" }, + { column: "user_agent", op: "contains", value: "opencode" }, + { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, + ] + + return honeycomb.getQuerySpecificationOutput({ + breakdowns: ["model"], + calculatedFields: [ { - reference: $interpolate`alert.attributes.${fields.product.id}`, + name: "is_failed_http_status", + expression: `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`, }, ], - groupingWindowSeconds: 3600, - }, - incidentTemplate: { - name: { - value: { - literal: varSpec("Alert -> Title", "alert.title"), - }, - }, - summary: { - value: { - literal: varSpec("Alert -> Description", "alert.description"), - }, - }, - startInTriage: { - value: { - literal: "true", - }, - }, - severity: { - mergeStrategy: "first-wins", - }, - incidentMode: { - value: { - literal: $app.stage === "production" ? "standard" : "test", - }, - }, - }, -}) - -type Product = "go" | "zen" - -type Trigger = (opts: { model: string; product: Product }) => { - id: string - title: string - description: string - json: honeycomb.GetQuerySpecificationOutputArgs - threshold: { op: ">=" | "<="; value: number } -} - -type Model = { id: string; products: Product[]; triggers: Trigger[] } - -const httpErrors: Trigger = ({ model, product }) => ({ - id: "increased-http-errors", - title: `Increased HTTP Errors for ${displayName(model)} on ${displayName(product)}`, - description: `Detected increased rate of HTTP errors for ${displayName(model)} on OpenCode ${displayName(product)}`, - json: { calculations: [ - { - op: "COUNT", - name: "TOTAL", - filterCombination: "AND", - filters: [ - { column: "model", op: "=", value: model }, - { column: "event_type", op: "=", value: "completions" }, - { column: "user_agent", op: "contains", value: "opencode" }, - { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, - ], - }, - { - op: "COUNT", - name: "FAILED", - filterCombination: "AND", - filters: [ - { column: "model", op: "=", value: model }, - { column: "event_type", op: "=", value: "completions" }, - { column: "user_agent", op: "contains", value: "opencode" }, - { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, - { column: "status", op: ">=", value: "400" }, - { column: "status", op: "!=", value: "401" }, - ], - }, + { op: "COUNT", name: "TOTAL", filterCombination: "AND", filters }, + { op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters }, ], - formulas: [{ name: "ERROR", expression: "$FAILED / $TOTAL" }], + formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 2500), DIV($FAILED, $TOTAL), 0)" }], timeRange: 900, - }, - threshold: { op: ">=", value: 0.8 }, -}) - -const models: Model[] = [ - { id: "kimi-k2.6", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "kimi-k2.5", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "deepseek-v4-flash", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "deepseek-v4-pro", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "glm-5.1", products: ["go", "zen"], triggers: [httpErrors] }, - // { id: "glm-5", products: ["go"], triggers: [httpErrors] }, - { id: "qwen3.6-plus", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "qwen3.5-plus", products: ["go"], triggers: [httpErrors] }, - { id: "minimax-m2.7", products: ["go", "zen"], triggers: [httpErrors] }, - // { id: "minimax-m2.5", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "mimo-v2.5-pro", products: ["go"], triggers: [httpErrors] }, - // { id: "mimo-v2.5", products: ["go"], triggers: [httpErrors] }, - // { id: "mimo-v2-omni", products: ["go"], triggers: [httpErrors] }, - // { id: "mimo-v2-pro", products: ["go"], triggers: [httpErrors] }, - { id: "claude-opus-4-7", products: ["zen"], triggers: [httpErrors] }, - // { id: "claude-opus-4-6", products: ["zen"], triggers: [httpErrors] }, - // { id: "claude-sonnet-4-6", products: ["zen"], triggers: [httpErrors] }, - { id: "gpt-5.5", products: ["zen"], triggers: [httpErrors] }, - { id: "big-pickle", products: ["zen"], triggers: [httpErrors] }, - // { id: "minimax-m2.5-free", products: ["zen"], triggers: [httpErrors] }, - // { id: "hy3-preview-free", products: ["zen"], triggers: [httpErrors] }, - // { id: "nemotron-3-super-free", products: ["zen"], triggers: [httpErrors] }, - // { id: "trinity-large-preview-free", products: ["zen"], triggers: [httpErrors] }, - // { id: "ling-2.6-flash-free", products: ["zen"], triggers: [httpErrors] }, -] - -if ($app.stage !== "production") { - models.splice(1) + }).json } -for (const model of models) { - for (const product of model.products) { - for (const trigger of model.triggers) { - const spec = trigger({ model: model.id, product }) +const description = "Managed by SST (Don't edit in Honeycomb UI)" - new honeycomb.Trigger(resourceName(`${spec.id}-${product}-${model.id}`), { - name: spec.title, - description: spec.description, - queryJson: honeycomb.getQuerySpecificationOutput(spec.json).json, - alertType: "on_change", - frequency: 300, - thresholds: [{ ...spec.threshold, exceededLimit: 1 }], - recipients: [ - { - id: webhookRecipient.id, - notificationDetails: [ - { - variables: [ - { name: "model", value: model.id }, - { name: "product", value: product }, - ], - }, - ], - }, - ], - }) - } - } -} +new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { + name: "Increased Model HTTP Errors [Go]", + description, + queryJson: modelHttpErrorsQuery("go"), + alertType: "on_change", + frequency: 300, + thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + recipients: [ + // { + // id: webhookRecipient.id, + // notificationDetails: [ + // { + // variables: [{ name: "type", value: "model_http_errors" }], + // }, + // ], + // }, + ], +}) + +new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { + name: "Increased Model HTTP Errors [Zen]", + description, + queryJson: modelHttpErrorsQuery("zen"), + alertType: "on_change", + frequency: 300, + thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + recipients: [ + // { + // id: webhookRecipient.id, + // notificationDetails: [ + // { + // variables: [{ name: "type", value: "model_http_errors" }], + // }, + // ], + // }, + ], +}) diff --git a/infra/secret.ts b/infra/secret.ts index 0b1870fa1552..d4e8b148fc3a 100644 --- a/infra/secret.ts +++ b/infra/secret.ts @@ -1,4 +1,11 @@ +sst.Linkable.wrap(random.RandomPassword, (resource) => ({ + properties: { + value: resource.result, + }, +})) + export const SECRET = { R2AccessKey: new sst.Secret("R2AccessKey", "unknown"), R2SecretKey: new sst.Secret("R2SecretKey", "unknown"), + HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }), } diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 71d37d15538a..298ae4a8cfba 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -31,7 +31,6 @@ "solid-js": "catalog:", "solid-list": "0.3.0", "solid-stripe": "0.8.1", - "svix": "1.92.2", "vite": "catalog:", "zod": "catalog:" }, diff --git a/packages/console/app/src/routes/honeycomb/webhook.ts b/packages/console/app/src/routes/honeycomb/webhook.ts new file mode 100644 index 000000000000..b4d5e4bf7e21 --- /dev/null +++ b/packages/console/app/src/routes/honeycomb/webhook.ts @@ -0,0 +1,81 @@ +import type { APIEvent } from "@solidjs/start/server" +import { z } from "zod" +import { Resource } from "@opencode-ai/console-resource" +import { safeEqual } from "@opencode-ai/console-core/util/crypto.js" + +const DISCORD_ALERT_ROLE_ID = "1501447160175136838" + +const basePayload = z.object({ + name: z.string().optional(), + status: z.string().optional(), + isTest: z.boolean().optional(), + url: z.string(), +}) + +const groups = z.object({ group: z.object({ key: z.string(), value: z.string() }).array() }).array() + +const honeycombWebhookPayload = z.discriminatedUnion("type", [ + basePayload.extend({ + type: z.literal("model_http_errors"), + groups, + }), + basePayload.extend({ + type: z.literal("provider_http_errors"), + groups, + }), +]) + +const postDiscordMessage = async (payload: z.infer) => { + const group = payload.type === "model_http_errors" ? "model" : "provider" + const names = (payload.groups ?? []).flatMap((item) => item.group.map((g) => g.value)) + + const content = [ + `[**${payload.isTest ? "[TEST] " : ""}${payload.name ?? "Honeycomb alert"}**](${payload.url})`, + names.length > 0 ? `Affected ${group}s:` : undefined, + ...names.map((name) => `- ${name}`), + "", + `<@&${DISCORD_ALERT_ROLE_ID}>`, + ] + .filter((line) => line !== undefined) + .join("\n") + + return fetch(Resource.DISCORD_INCIDENT_WEBHOOK_URL.value, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content, + allowed_mentions: { roles: [DISCORD_ALERT_ROLE_ID] }, + flags: 4, + }), + }) +} + +export async function POST(input: APIEvent) { + const token = input.request.headers.get("X-Honeycomb-Webhook-Token") + if (!safeEqual(token ?? "", Resource.HoneycombWebhookSecret.value)) { + console.debug("Invalid Honeycomb webhook token") + return Response.json({ message: "invalid token" }, { status: 401 }) + } + + const body = await input.request.json() + console.log(body, JSON.stringify(body, null, 2)) + + const parsed = honeycombWebhookPayload.safeParse(body) + + if (!parsed.success) { + console.error(parsed.error) + return Response.json({ message: "invalid payload" }, { status: 400 }) + } + + if (parsed.data.status !== "TRIGGERED") { + console.debug("Skipping resolved alert Honeycomb webhook") + return Response.json({ message: "ignored" }, { status: 200 }) + } + + const response = await postDiscordMessage(parsed.data) + if (!response.ok) { + return Response.json({ message: "discord webhook failed" }, { status: 502 }) + } + + return Response.json({ message: "sent" }, { status: 200 }) +} diff --git a/packages/console/app/src/routes/incident/webhook.ts b/packages/console/app/src/routes/incident/webhook.ts deleted file mode 100644 index ce7b0a0d9fcb..000000000000 --- a/packages/console/app/src/routes/incident/webhook.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { APIEvent } from "@solidjs/start/server" -import { Resource } from "@opencode-ai/console-resource" -import { Webhook } from "svix" - -const DISCORD_INCIDENT_ROLE_ID = "1501447160175136838" - -type Incident = { - mode?: "test" | "standard" - name?: string - permalink?: string - summary?: string -} - -type IncidentWebhookPayload = { - event_type?: string - "public_incident.incident_created_v2"?: Incident -} - -const verifyWebhook = async (request: Request) => { - const body = await request.text() - try { - return new Webhook(Resource.INCIDENT_WEBHOOK_SIGNING_SECRET.value).verify( - body, - Object.fromEntries(request.headers.entries()), - ) as IncidentWebhookPayload - } catch { - return undefined - } -} - -const postDiscordMessage = async (incident: Incident) => { - return fetch(Resource.DISCORD_INCIDENT_WEBHOOK_URL.value, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: [ - `**${incident.mode === "test" ? "[TEST] " : ""}${incident.name ?? "Incident has been created"}**`, - incident.summary, - "", - `<@&${DISCORD_INCIDENT_ROLE_ID}>`, - "", - incident.permalink, - ] - .filter((line) => line !== undefined) - .join("\n"), - allowed_mentions: { - roles: [DISCORD_INCIDENT_ROLE_ID], - }, - flags: 4, - }), - }) -} - -export async function POST(input: APIEvent) { - const payload = await verifyWebhook(input.request) - if (!payload) { - return Response.json({ message: "invalid signature" }, { status: 401 }) - } - - if (payload.event_type !== "public_incident.incident_created_v2") { - return Response.json({ message: "ignored event" }, { status: 200 }) - } - - const incident = payload["public_incident.incident_created_v2"] - if (!incident) { - return Response.json({ message: "missing incident" }, { status: 400 }) - } - - const response = await postDiscordMessage(incident) - if (!response.ok) { - return Response.json({ message: "discord webhook failed" }, { status: 502 }) - } - - return Response.json({ message: "sent" }, { status: 200 }) -} diff --git a/packages/console/core/src/util/crypto.ts b/packages/console/core/src/util/crypto.ts new file mode 100644 index 000000000000..46f53ae3913d --- /dev/null +++ b/packages/console/core/src/util/crypto.ts @@ -0,0 +1,8 @@ +import { timingSafeEqual } from "node:crypto" + +export function safeEqual(a: string, b: string): boolean { + const encoder = new TextEncoder() + const aBytes = encoder.encode(a) + const bBytes = encoder.encode(b) + return aBytes.length === bBytes.length && timingSafeEqual(aBytes, bBytes) +} diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index bc56bd789dbc..9680a53aab1e 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index bc56bd789dbc..9680a53aab1e 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index bc56bd789dbc..9680a53aab1e 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index bc56bd789dbc..9680a53aab1e 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index bc56bd789dbc..9680a53aab1e 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/sst-env.d.ts b/sst-env.d.ts index 52702acd7cf4..e75c54d05658 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -114,8 +114,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "LogProcessor": { diff --git a/sst.config.ts b/sst.config.ts index a7e513ca0a4e..d82c7d18d998 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -11,15 +11,10 @@ export default $config({ stripe: { apiKey: process.env.STRIPE_SECRET_KEY!, }, + random: "4.19.2", planetscale: "0.4.1", - honeycomb: { - version: "0.49.0", - apiKey: process.env.HONEYCOMB_API_KEY!, - }, - incident: { - version: "5.35.0", - apiKey: process.env.INCIDENT_API_KEY!, - }, + honeycomb: "0.49.0", + incident: "5.35.0", }, } }, @@ -27,7 +22,7 @@ export default $config({ await import("./infra/app.js") await import("./infra/console.js") await import("./infra/enterprise.js") - if ($app.stage === "production") { + if ($app.stage === "production" || $app.stage === "vimtor") { await import("./infra/monitoring.js") } }, From 1ea01fdad07a717165e91e1f92eda2197f17e9ce Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 08:08:37 +0000 Subject: [PATCH 0012/1034] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index a765e803d2e8..078b600d052e 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-cgqwEUyOYcOnh07Wz20qkPIrDeBaCBmKiis6HO1EAIU=", - "aarch64-linux": "sha256-AVy0RQuuXiseIwJV9f9API8OEo1jcy84dVidkEXgnX8=", - "aarch64-darwin": "sha256-RIS3/SuSXaMV9WcTXxOJWGDw96LcCFT6E8Ktc28/544=", - "x86_64-darwin": "sha256-GBwXIZQxy5F7tH9TJyWAonX5aETbZ/veAjeznDtsYmk=" + "x86_64-linux": "sha256-MHeO1KTmjYa+V4ZBYrQq93cYpjnkGfO9e3MOWwkzjVY=", + "aarch64-linux": "sha256-EqTRG7DrdKKT7CEvnaNk5VhjTRhlZ9juP9/Nnr3dJ+g=", + "aarch64-darwin": "sha256-c8dWd8Pgp5uIAOdYbHIeGKqWfkF/l4Ze7ArYUMvTNkE=", + "x86_64-darwin": "sha256-61NpSO0AZ4iZG19RQ6zg0SJec+VQE46WJKOdRrNofT0=" } } From 0b2e65f16d35b0c21c4206c9249b6550919ec7e5 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 10:15:40 +0200 Subject: [PATCH 0013/1034] chore: reactivate alerts --- .github/workflows/deploy.yml | 1 - infra/monitoring.ts | 34 +++++++++++++++++----------------- sst.config.ts | 1 - 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 10b8dc180b0c..abd8bafdd675 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,7 +37,6 @@ jobs: PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }} - INCIDENT_API_KEY: ${{ secrets.INCIDENT_API_KEY }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 4e22e3d812c4..84add2f8e6e5 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -45,7 +45,7 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { { op: "COUNT", name: "TOTAL", filterCombination: "AND", filters }, { op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters }, ], - formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 2500), DIV($FAILED, $TOTAL), 0)" }], + formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 500), DIV($FAILED, $TOTAL), 0)" }], timeRange: 900, }).json } @@ -60,14 +60,14 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { frequency: 300, thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], recipients: [ - // { - // id: webhookRecipient.id, - // notificationDetails: [ - // { - // variables: [{ name: "type", value: "model_http_errors" }], - // }, - // ], - // }, + { + id: webhookRecipient.id, + notificationDetails: [ + { + variables: [{ name: "type", value: "model_http_errors" }], + }, + ], + }, ], }) @@ -79,13 +79,13 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { frequency: 300, thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], recipients: [ - // { - // id: webhookRecipient.id, - // notificationDetails: [ - // { - // variables: [{ name: "type", value: "model_http_errors" }], - // }, - // ], - // }, + { + id: webhookRecipient.id, + notificationDetails: [ + { + variables: [{ name: "type", value: "model_http_errors" }], + }, + ], + }, ], }) diff --git a/sst.config.ts b/sst.config.ts index d82c7d18d998..696a6fa7689d 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -14,7 +14,6 @@ export default $config({ random: "4.19.2", planetscale: "0.4.1", honeycomb: "0.49.0", - incident: "5.35.0", }, } }, From b2cc40f09c0f558ed698ae450abea6f3f8a9c233 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 10:30:29 +0200 Subject: [PATCH 0014/1034] chore: first provider alert version --- infra/monitoring.ts | 80 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 84add2f8e6e5..1da54fe63d16 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -50,6 +50,48 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { }).json } +const providerHttpErrorsQuery = (product: "go" | "zen") => { + const filters = [ + { column: "provider", op: "exists" }, + { column: "user_agent", op: "contains", value: "opencode" }, + { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, + ] + + return honeycomb.getQuerySpecificationOutput({ + breakdowns: ["provider"], + calculatedFields: [ + { + name: "is_success_http_status", + expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`, + }, + { + name: "is_failed_provider_http_status", + expression: `IF(AND(GTE($llm.error.code, "400"), NOT(EQUALS($llm.error.code, "401"))), 1, 0)`, + }, + ], + calculations: [ + { + op: "SUM", + name: "SUCCESS", + column: "is_success_http_status", + filterCombination: "AND", + filters: [...filters, { column: "event_type", op: "=", value: "completions" }], + }, + { + op: "SUM", + name: "FAILED", + column: "is_failed_provider_http_status", + filterCombination: "AND", + filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }], + }, + ], + formulas: [ + { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 500), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, + ], + timeRange: 900, + }).json +} + const description = "Managed by SST (Don't edit in Honeycomb UI)" new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { @@ -89,3 +131,41 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { }, ], }) + +new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", { + name: "Increased Provider HTTP Errors [Go]", + description, + queryJson: providerHttpErrorsQuery("go"), + alertType: "on_change", + frequency: 300, + thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + recipients: [ + // { + // id: webhookRecipient.id, + // notificationDetails: [ + // { + // variables: [{ name: "type", value: "provider_http_errors" }], + // }, + // ], + // }, + ], +}) + +new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { + name: "Increased Provider HTTP Errors [Zen]", + description, + queryJson: providerHttpErrorsQuery("zen"), + alertType: "on_change", + frequency: 300, + thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + recipients: [ + // { + // id: webhookRecipient.id, + // notificationDetails: [ + // { + // variables: [{ name: "type", value: "provider_http_errors" }], + // }, + // ], + // }, + ], +}) From 1219691c114c9aec251bc855b3a4b53f7d12ff14 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 7 May 2026 16:31:37 +0800 Subject: [PATCH 0015/1034] docs(desktop): update README from Tauri to Electron (#26146) --- packages/desktop/README.md | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index ebaf48822313..6dd9a202ada1 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -1,32 +1,19 @@ # OpenCode Desktop -Native OpenCode desktop app, built with Tauri v2. +The OpenCode Desktop app, built with Electron. ## Development -From the repo root: - ```bash bun install -bun run --cwd packages/desktop tauri dev -``` - -This starts the Vite dev server on http://localhost:1420 and opens the native window. - -If you only want the web dev server (no native shell): - -```bash -bun run --cwd packages/desktop dev +bun dev ``` ## Build -To create a production `dist/` and build the native app bundle: +Run the `build` script to build the app's JS assets, then `package` to +bundle the assets as an application. The resulting app will be in `dist/`. ```bash -bun run --cwd packages/desktop tauri build +bun run build && bun run package ``` - -## Prerequisites - -Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions. From cee04f2924b16718bd7f60b05a1e946c17f8ea7e Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 10:56:37 +0200 Subject: [PATCH 0016/1034] chore: make provider down queries live --- infra/app.ts | 1 + infra/monitoring.ts | 34 +++++++++++++++++----------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/infra/app.ts b/infra/app.ts index bb627f51ec51..2ede5a1f4a29 100644 --- a/infra/app.ts +++ b/infra/app.ts @@ -30,6 +30,7 @@ export const api = new sst.cloudflare.Worker("Api", { transform: { worker: (args) => { args.logpush = true + if ($app.stage === "vimtor") return args.bindings = $resolve(args.bindings).apply((bindings) => [ ...bindings, { diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 1da54fe63d16..9956e2ed702f 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -88,7 +88,7 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { formulas: [ { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 500), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, ], - timeRange: 900, + timeRange: 1800, }).json } @@ -140,14 +140,14 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", { frequency: 300, thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], recipients: [ - // { - // id: webhookRecipient.id, - // notificationDetails: [ - // { - // variables: [{ name: "type", value: "provider_http_errors" }], - // }, - // ], - // }, + { + id: webhookRecipient.id, + notificationDetails: [ + { + variables: [{ name: "type", value: "provider_http_errors" }], + }, + ], + }, ], }) @@ -159,13 +159,13 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { frequency: 300, thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], recipients: [ - // { - // id: webhookRecipient.id, - // notificationDetails: [ - // { - // variables: [{ name: "type", value: "provider_http_errors" }], - // }, - // ], - // }, + { + id: webhookRecipient.id, + notificationDetails: [ + { + variables: [{ name: "type", value: "provider_http_errors" }], + }, + ], + }, ], }) From 193c169ca51103db79331d53bf1884262beffe7a Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 11:00:43 +0200 Subject: [PATCH 0017/1034] chore: improve provider down query --- infra/monitoring.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 9956e2ed702f..b2716bcabbc8 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -66,7 +66,7 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { }, { name: "is_failed_provider_http_status", - expression: `IF(AND(GTE($llm.error.code, "400"), NOT(EQUALS($llm.error.code, "401"))), 1, 0)`, + expression: `IF(GTE($llm.error.code, "400"), 1, 0)`, }, ], calculations: [ @@ -86,7 +86,7 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { }, ], formulas: [ - { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 500), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, + { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 250), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, ], timeRange: 1800, }).json From 30c4fcb1a596335057888bb76fa168f5039426c1 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 11:10:36 +0200 Subject: [PATCH 0018/1034] chore: fix honeycomb query frequency --- infra/monitoring.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index b2716bcabbc8..26ba573a0768 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -137,7 +137,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", { description, queryJson: providerHttpErrorsQuery("go"), alertType: "on_change", - frequency: 300, + frequency: 600, thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], recipients: [ { @@ -156,7 +156,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { description, queryJson: providerHttpErrorsQuery("zen"), alertType: "on_change", - frequency: 300, + frequency: 600, thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], recipients: [ { From fea9a0bd4c1c9d9b66af84a43936a84626d723d8 Mon Sep 17 00:00:00 2001 From: YGoetschel <54545214+YGoetschel@users.noreply.github.com> Date: Thu, 7 May 2026 12:55:40 +0200 Subject: [PATCH 0019/1034] fix: guard undefined contents in diff renderer to fix share viewer SSR crash (#21763) --- packages/ui/src/components/file-ssr.tsx | 8 ++++++-- packages/ui/src/components/message-part.tsx | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/file-ssr.tsx b/packages/ui/src/components/file-ssr.tsx index ad05555bdf6b..6f11ca2433d3 100644 --- a/packages/ui/src/components/file-ssr.tsx +++ b/packages/ui/src/components/file-ssr.tsx @@ -128,8 +128,12 @@ function DiffSSRViewer(props: SSRDiffFileProps) { prerenderedHTML: local.preloadedDiff.prerenderedHTML, } : { - oldFile: local.before, - newFile: local.after, + oldFile: local.before + ? { ...local.before, contents: typeof local.before.contents === "string" ? local.before.contents : "" } + : local.before, + newFile: local.after + ? { ...local.after, contents: typeof local.after.contents === "string" ? local.after.contents : "" } + : local.after, lineAnnotations: annotations, fileContainer: fileDiffRef, containerWrapper: container, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index cc046fdfc577..c36a52f81eee 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1906,11 +1906,11 @@ ToolRegistry.register({ mode="diff" before={{ name: props.metadata?.filediff?.file || props.input.filePath, - contents: props.metadata?.filediff?.before || props.input.oldString, + contents: props.metadata?.filediff?.before || props.input.oldString || "", }} after={{ name: props.metadata?.filediff?.file || props.input.filePath, - contents: props.metadata?.filediff?.after || props.input.newString, + contents: props.metadata?.filediff?.after || props.input.newString || "", }} /> From 95280ebec9a8aa851f862fbdb4a48ec1243d93d9 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 7 May 2026 17:05:35 +0530 Subject: [PATCH 0020/1034] fix(tui): restore custom provider in /connect (#26168) --- .../cli/cmd/tui/component/dialog-provider.tsx | 131 +++++++++++++++--- .../test/cli/cmd/tui/provider-options.test.ts | 29 ++++ 2 files changed, 142 insertions(+), 18 deletions(-) create mode 100644 packages/opencode/test/cli/cmd/tui/provider-options.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index d6cbda413317..16812fa8abdd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -25,6 +25,60 @@ const PROVIDER_PRIORITY: Record = { google: 5, } +const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__" +const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ + +type ProviderOptionBase = { + title: string + value: string + description?: string + category: string +} + +type ProviderOption = + | (ProviderOptionBase & { + type: "provider" + providerID: string + }) + | (ProviderOptionBase & { + type: "custom" + }) + +export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] { + return [ + ...pipe( + list, + sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), + map((provider) => ({ + type: "provider" as const, + title: provider.name, + value: provider.id, + providerID: provider.id, + description: { + opencode: "(Recommended)", + anthropic: "(API key)", + openai: "(ChatGPT Plus/Pro or API key)", + "opencode-go": "Low cost subscription for everyone", + }[provider.id], + category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers", + })), + ), + { + type: "custom", + title: "Other", + value: CUSTOM_PROVIDER_OPTION_VALUE, + description: "Custom provider", + category: "Providers", + }, + ] +} + +export function normalizeCustomProviderID(value: string) { + const providerID = value.trim().replace(/^@ai-sdk\//, "") + if (!CUSTOM_PROVIDER_ID.test(providerID)) return + return providerID +} + export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() @@ -32,30 +86,61 @@ export function createDialogProviderOptions() { const toast = useToast() const { theme } = useTheme() const onboarded = useConnected() + + async function promptCustomProviderID(): Promise { + const value = await DialogPrompt.show(dialog, "Other", { + placeholder: "Provider id", + description: () => ( + + This only stores a credential. Configure the provider in opencode.json to use it. + + ), + }) + if (value === null) return + + const providerID = normalizeCustomProviderID(value) + if (providerID) return providerID + + toast.show({ + variant: "error", + message: "Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores", + }) + return promptCustomProviderID() + } + const options = createMemo(() => { return pipe( - sync.data.provider_next.all, - sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), + providerOptions(sync.data.provider_next.all), map((provider) => { - const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id) - const connected = sync.data.provider_next.connected.includes(provider.id) + if (provider.type === "custom") { + return { + title: provider.title, + value: provider.value, + description: provider.description, + category: provider.category, + async onSelect() { + const providerID = await promptCustomProviderID() + if (!providerID) return + return dialog.replace(() => ) + }, + } + } + + const providerID = provider.providerID + const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID) + const connected = sync.data.provider_next.connected.includes(providerID) return { - title: provider.name, - value: provider.id, - description: { - opencode: "(Recommended)", - anthropic: "(API key)", - openai: "(ChatGPT Plus/Pro or API key)", - "opencode-go": "Low cost subscription for everyone", - }[provider.id], + title: provider.title, + value: provider.value, + description: provider.description, footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, - category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", + category: provider.category, gutter: connected && onboarded() ? () => : undefined, async onSelect() { if (consoleManaged) return - const methods = sync.data.provider_auth[provider.id] ?? [ + const methods = sync.data.provider_auth[providerID] ?? [ { type: "api", label: "API key", @@ -93,7 +178,7 @@ export function createDialogProviderOptions() { } const result = await sdk.client.provider.oauth.authorize({ - providerID: provider.id, + providerID, method: index, inputs, }) @@ -108,7 +193,7 @@ export function createDialogProviderOptions() { if (result.data?.method === "code") { dialog.replace(() => ( ( ( - + )) } }, @@ -256,11 +341,13 @@ interface ApiMethodProps { providerID: string title: string metadata?: Record + custom?: boolean } function ApiMethod(props: ApiMethodProps) { const dialog = useDialog() const sdk = useSDK() const sync = useSync() + const toast = useToast() const { theme } = useTheme() return ( @@ -305,6 +392,14 @@ function ApiMethod(props: ApiMethodProps) { }) await sdk.client.instance.dispose() await sync.bootstrap() + if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) { + toast.show({ + variant: "info", + message: `Saved credential for ${props.providerID}. Configure it in opencode.json to use it.`, + }) + dialog.clear() + return + } dialog.replace(() => ) }} /> diff --git a/packages/opencode/test/cli/cmd/tui/provider-options.test.ts b/packages/opencode/test/cli/cmd/tui/provider-options.test.ts new file mode 100644 index 000000000000..39d639837933 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/provider-options.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { normalizeCustomProviderID, providerOptions } from "../../../../src/cli/cmd/tui/component/dialog-provider" + +describe("providerOptions", () => { + test("includes a synthetic Other option for custom providers", () => { + expect(providerOptions([{ id: "openai", name: "OpenAI" }]).at(-1)).toMatchObject({ + title: "Other", + description: "Custom provider", + category: "Providers", + }) + }) + + test("does not use Other as the generic provider category", () => { + expect(providerOptions([{ id: "mistral", name: "Mistral" }])[0]?.category).toBe("Providers") + }) + + test("does not collide with a configured provider named other", () => { + const values = providerOptions([{ id: "other", name: "Other Provider" }]).map((option) => option.value) + expect(new Set(values).size).toBe(values.length) + }) + + test("normalizes and validates custom provider ids", () => { + expect(normalizeCustomProviderID(" custom-provider ")).toBe("custom-provider") + expect(normalizeCustomProviderID("custom_provider")).toBe("custom_provider") + expect(normalizeCustomProviderID("@ai-sdk/custom-provider")).toBe("custom-provider") + expect(normalizeCustomProviderID("-custom-provider")).toBeUndefined() + expect(normalizeCustomProviderID("Custom Provider")).toBeUndefined() + }) +}) From fbb7b5b1bf031e16f4bbe6db34038ccf501a7f3e Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 13:32:32 +0200 Subject: [PATCH 0021/1034] chore: add free tier usage alert --- infra/monitoring.ts | 35 +++++++++++++++++++ .../app/src/routes/honeycomb/webhook.ts | 10 ++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 26ba573a0768..908078ba1991 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -169,3 +169,38 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { }, ], }) + +new honeycomb.Trigger("IncreasedFreeTierRequests", { + disabled: true, + name: "Increased Free Tier Requests", + description, + queryJson: honeycomb.getQuerySpecificationOutput({ + calculations: [ + { + op: "COUNT", + name: "REQUESTS", + filterCombination: "AND", + filters: [ + { column: "event_type", op: "=", value: "completions" }, + { column: "user_agent", op: "contains", value: "opencode" }, + { column: "isFreeTier", op: "=", value: "true" }, + ], + }, + ], + timeRange: 14400, + }).json, + alertType: "on_change", + frequency: 3600, + thresholds: [{ op: ">=", value: 50, exceededLimit: 2 }], + baselineDetails: [{ type: "percentage", offsetMinutes: 1440 }], + recipients: [ + { + id: webhookRecipient.id, + notificationDetails: [ + { + variables: [{ name: "type", value: "custom" }], + }, + ], + }, + ], +}) diff --git a/packages/console/app/src/routes/honeycomb/webhook.ts b/packages/console/app/src/routes/honeycomb/webhook.ts index b4d5e4bf7e21..367a93aeb023 100644 --- a/packages/console/app/src/routes/honeycomb/webhook.ts +++ b/packages/console/app/src/routes/honeycomb/webhook.ts @@ -23,15 +23,19 @@ const honeycombWebhookPayload = z.discriminatedUnion("type", [ type: z.literal("provider_http_errors"), groups, }), + basePayload.extend({ + type: z.literal("custom"), + }), ]) const postDiscordMessage = async (payload: z.infer) => { - const group = payload.type === "model_http_errors" ? "model" : "provider" - const names = (payload.groups ?? []).flatMap((item) => item.group.map((g) => g.value)) + const group = + payload.type === "model_http_errors" ? "model" : payload.type === "provider_http_errors" ? "provider" : undefined + const names = payload.type === "custom" ? [] : payload.groups.flatMap((item) => item.group.map((g) => g.value)) const content = [ `[**${payload.isTest ? "[TEST] " : ""}${payload.name ?? "Honeycomb alert"}**](${payload.url})`, - names.length > 0 ? `Affected ${group}s:` : undefined, + group && names.length > 0 ? `Affected ${group}s:` : undefined, ...names.map((name) => `- ${name}`), "", `<@&${DISCORD_ALERT_ROLE_ID}>`, From 844fb719382decad09ba55d1f8e49d811ce550be Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 11:37:34 +0000 Subject: [PATCH 0022/1034] chore: generate --- .../cli/cmd/tui/component/dialog-provider.tsx | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 16812fa8abdd..e12492a2d035 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -103,7 +103,8 @@ export function createDialogProviderOptions() { toast.show({ variant: "error", - message: "Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores", + message: + "Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores", }) return promptCustomProviderID() } @@ -192,22 +193,12 @@ export function createDialogProviderOptions() { } if (result.data?.method === "code") { dialog.replace(() => ( - + )) } if (result.data?.method === "auto") { dialog.replace(() => ( - + )) } } From d6e06c8950cc58a376c70b27667a1746fd282539 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 13:40:29 +0200 Subject: [PATCH 0023/1034] chore: fix free tier query --- infra/monitoring.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 908078ba1991..e976044707df 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -178,7 +178,6 @@ new honeycomb.Trigger("IncreasedFreeTierRequests", { calculations: [ { op: "COUNT", - name: "REQUESTS", filterCombination: "AND", filters: [ { column: "event_type", op: "=", value: "completions" }, From 9c9bc09f526d46c095c9d82b6ae7761c64281d4c Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 13:58:41 +0200 Subject: [PATCH 0024/1034] chore: fix free tier query --- infra/monitoring.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index e976044707df..baf1f5d68b1d 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -175,22 +175,17 @@ new honeycomb.Trigger("IncreasedFreeTierRequests", { name: "Increased Free Tier Requests", description, queryJson: honeycomb.getQuerySpecificationOutput({ - calculations: [ - { - op: "COUNT", - filterCombination: "AND", - filters: [ - { column: "event_type", op: "=", value: "completions" }, - { column: "user_agent", op: "contains", value: "opencode" }, - { column: "isFreeTier", op: "=", value: "true" }, - ], - }, + calculations: [{ op: "COUNT" }], + filters: [ + { column: "event_type", op: "=", value: "completions" }, + { column: "user_agent", op: "contains", value: "opencode" }, + { column: "isFreeTier", op: "=", value: "true" }, ], - timeRange: 14400, + timeRange: 3600, }).json, alertType: "on_change", - frequency: 3600, - thresholds: [{ op: ">=", value: 50, exceededLimit: 2 }], + frequency: 900, + thresholds: [{ op: ">=", value: 50, exceededLimit: 1 }], baselineDetails: [{ type: "percentage", offsetMinutes: 1440 }], recipients: [ { From b6ff1b18c739c307c8e55aa9ab64e5ef2040f919 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 14:10:47 +0200 Subject: [PATCH 0025/1034] chore: activate free tier requests query --- infra/monitoring.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index baf1f5d68b1d..aad090aa8029 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -171,7 +171,6 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { }) new honeycomb.Trigger("IncreasedFreeTierRequests", { - disabled: true, name: "Increased Free Tier Requests", description, queryJson: honeycomb.getQuerySpecificationOutput({ @@ -185,7 +184,7 @@ new honeycomb.Trigger("IncreasedFreeTierRequests", { }).json, alertType: "on_change", frequency: 900, - thresholds: [{ op: ">=", value: 50, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 60, exceededLimit: 1 }], baselineDetails: [{ type: "percentage", offsetMinutes: 1440 }], recipients: [ { From 3c4b4d5faf226b22fbb277bd7699b81484d49684 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 7 May 2026 10:24:17 -0400 Subject: [PATCH 0026/1034] feat(core): copy file changes when warping (#26190) --- .../cmd/tui/component/dialog-session-list.tsx | 8 +- .../tui/component/dialog-workspace-create.tsx | 39 ++++- .../dialog-workspace-file-changes.tsx | 138 ++++++++++++++++++ .../cli/cmd/tui/component/prompt/index.tsx | 12 +- .../opencode/src/control-plane/workspace.ts | 100 ++++++++++++- packages/opencode/src/git/index.ts | 17 ++- packages/opencode/src/project/vcs.ts | 85 ++++++++++- .../src/server/routes/control/workspace.ts | 33 ++++- .../instance/httpapi/groups/instance.ts | 48 +++++- .../instance/httpapi/groups/workspace.ts | 14 +- .../instance/httpapi/handlers/instance.ts | 27 ++++ .../instance/httpapi/handlers/workspace.ts | 25 +++- .../src/server/routes/instance/index.ts | 98 ++++++++++++- packages/opencode/src/util/locale.ts | 5 + .../cmd/tui/dialog-workspace-create.test.ts | 25 ++++ .../test/control-plane/workspace.test.ts | 58 +++++++- .../test/plugin/workspace-adapter.test.ts | 8 +- .../server/httpapi-instance-context.test.ts | 9 +- .../test/server/httpapi-session.test.ts | 10 +- .../server/httpapi-workspace-routing.test.ts | 9 +- .../test/server/httpapi-workspace.test.ts | 8 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 110 ++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 97 +++++++++++- 23 files changed, 955 insertions(+), 28 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 09d952ef8192..a521e07b1dcf 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -12,7 +12,11 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create" +import { + openWorkspaceSelect, + type WorkspaceSelection, + warpWorkspaceSession, +} from "./dialog-workspace-create" import { Spinner } from "./spinner" import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" @@ -70,8 +74,10 @@ export function DialogSessionList() { sync, project, toast, + sourceWorkspaceID: session.workspaceID, workspaceID, sessionID: session.id, + copyChanges: false, done: list, }) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 157ca2058231..31955dcf31df 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -3,10 +3,13 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { useSync } from "@tui/context/sync" import { useProject } from "@tui/context/project" +import { useRoute } from "@tui/context/route" import { createMemo, createSignal, onMount } from "solid-js" import { errorMessage } from "@/util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" +import { DialogAlert } from "../ui/dialog-alert" +import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" type Adapter = { type: string @@ -38,6 +41,7 @@ export function recentConnectedWorkspaces( get: (workspaceID: string) => WorkspaceInfo | undefined status: (workspaceID: string) => string | undefined limit?: number + omitWorkspaceID?: string }) { const workspaces = input.sessions .toSorted((a, b) => b.time.updated - a.time.updated) @@ -45,6 +49,7 @@ export function recentConnectedWorkspaces( const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined return workspace && input.status(workspace.id) === "connected" ? [workspace] : [] }) + .filter((workspace) => workspace.id !== input.omitWorkspaceID) .filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index) const recent = workspaces.slice(0, input.limit ?? 3) @@ -93,17 +98,29 @@ export async function warpWorkspaceSession(input: { sync: ReturnType project: ReturnType toast: ReturnType + sourceWorkspaceID?: string workspaceID: string | null sessionID: string + copyChanges: boolean done?: () => void }): Promise { const result = await input.sdk.client.experimental.workspace .warp({ id: input.workspaceID, sessionID: input.sessionID, + copyChanges: input.copyChanges, }) .catch(() => undefined) if (!result?.data) { + if (result?.error?.name === "VcsApplyError") { + await DialogAlert.show( + input.dialog, + "Unable to Warp Session", + "Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.", + ) + return false + } + input.toast.show({ message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`, variant: "error", @@ -143,16 +160,29 @@ export async function warpWorkspaceSession(input: { return true } +export async function confirmWorkspaceFileChanges(input: { + dialog: ReturnType + sdk: ReturnType + sourceWorkspaceID?: string +}) { + const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined) + const fileChangeChoice = status?.data?.length ? await DialogWorkspaceFileChanges.show(input.dialog, status.data) : "no" + if (!fileChangeChoice) return + return fileChangeChoice === "yes" +} + export function DialogWorkspaceSelect(props: { adapters?: Adapter[] onSelect: (selection: WorkspaceSelection) => Promise | void }) { const dialog = useDialog() const project = useProject() + const route = useRoute() const sync = useSync() const sdk = useSDK() const toast = useToast() const [adapters, setAdapters] = createSignal(props.adapters) + const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined)) onMount(() => { dialog.setSize("medium") @@ -171,6 +201,7 @@ export function DialogWorkspaceSelect(props: { sessions: sync.data.session, get: project.workspace.get, status: project.workspace.status, + omitWorkspaceID: omittedWorkspaceID(), }) return [ ...list.map((adapter) => ({ @@ -231,19 +262,23 @@ export function DialogWorkspaceSelect(props: { return } - dialog.replace(() => ) + dialog.replace(() => ) }} /> ) } -function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise | void }) { +function DialogExistingWorkspaceSelect(props: { + omitWorkspaceID?: string + onSelect: (selection: WorkspaceSelection) => Promise | void +}) { const project = useProject() const options = createMemo[]>(() => project.workspace .list() .filter((workspace) => project.workspace.status(workspace.id) === "connected") + .filter((workspace) => workspace.id !== props.omitWorkspaceID) .map((workspace: Workspace) => ({ title: workspace.name, description: `(${workspace.type})`, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx new file mode 100644 index 000000000000..b2cb20630c38 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx @@ -0,0 +1,138 @@ +import { TextAttributes } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import type { VcsFileStatus } from "@opencode-ai/sdk/v2" +import { createMemo, For } from "solid-js" +import { createStore } from "solid-js/store" +import { Locale } from "@/util/locale" +import { useTheme } from "../context/theme" +import { useTuiConfig } from "../context/tui-config" +import { useDialog, type DialogContext } from "../ui/dialog" +import { getScrollAcceleration } from "../util/scroll" + +const options = ["no", "yes"] as const + +export type WorkspaceFileChangesChoice = (typeof options)[number] + +function statusLabel(status: VcsFileStatus["status"]) { + if (status === "added") return "A" + if (status === "deleted") return "D" + return "M" +} + +function changeCountWidth(file: VcsFileStatus) { + // The "plus 2" is for spaces + return `${file.additions ? `+${file.additions}` : ""}${file.deletions ? ` -${file.deletions}` : ""}`.length + 2 +} + +export function DialogWorkspaceFileChanges(props: { + files: VcsFileStatus[] + onSelect: (choice: WorkspaceFileChangesChoice) => void +}) { + const dialog = useDialog() + const { theme } = useTheme() + const tuiConfig = useTuiConfig() + const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) + const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice }) + const height = createMemo(() => Math.min(props.files.length, 8)) + const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0)) + + function confirm() { + props.onSelect(store.active) + dialog.clear() + } + + useKeyboard((evt) => { + if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() + confirm() + return + } + if (evt.name === "left") { + evt.preventDefault() + evt.stopPropagation() + const index = options.indexOf(store.active) + setStore("active", options[Math.max(index - 1, 0)]) + return + } + if (evt.name === "right") { + evt.preventDefault() + evt.stopPropagation() + const index = options.indexOf(store.active) + setStore("active", options[Math.min(index + 1, options.length - 1)]) + } + }) + + return ( + + + + File Changes Found + + dialog.clear()}> + esc + + + + + {(item) => ( + + + + {statusLabel(item.status)} + + + {Locale.truncateLeft(item.file, fileNameWidth())} + + + + + {" "} + {item.additions ? +{item.additions} : null} + {item.deletions ? -{item.deletions} : null} + + + + )} + + + + + Do you want to apply these changes after warping? + + + + + {(item) => ( + { + setStore("active", item) + props.onSelect(item) + dialog.clear() + }} + > + {item} + + )} + + + + ) +} + +DialogWorkspaceFileChanges.show = (dialog: DialogContext, files: VcsFileStatus[]) => { + return new Promise((resolve) => { + dialog.replace( + () => , + () => resolve(undefined), + ) + }) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 41e32539eef5..73ef5477e972 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -42,7 +42,12 @@ import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" -import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create" +import { + confirmWorkspaceFileChanges, + openWorkspaceSelect, + warpWorkspaceSession, + type WorkspaceSelection, +} from "../dialog-workspace-create" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" import { Flag } from "@opencode-ai/core/flag/flag" @@ -230,6 +235,9 @@ export function Prompt(props: PromptProps) { if (selection.type === "new") void createWorkspace(selection) return } + const sourceWorkspaceID = project.workspace.current() + const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID }) + if (copyChanges === undefined) return selectWorkspace(selection) dialog.clear() @@ -247,8 +255,10 @@ export function Prompt(props: PromptProps) { sync, project, toast, + sourceWorkspaceID, workspaceID: workspace.id, sessionID: props.sessionID, + copyChanges, }) if (warped) showWarpNotice(workspace.name) } diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 24ca0e61bfcc..f9bab469b75e 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -18,7 +18,7 @@ import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" import { WorkspaceTable } from "./workspace.sql" import { getAdapter } from "./adapters" -import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" +import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { Session } from "@/session/session" import { SessionPrompt } from "@/session/prompt" @@ -31,6 +31,9 @@ import { WorkspaceContext } from "./workspace-context" import { EffectBridge } from "@/effect/bridge" import { withStatics } from "@/util/schema" import { zod as effectZod, zodObject } from "@/util/effect-zod" +import { Vcs } from "@/project/vcs" +import { InstanceStore } from "@/project/instance-store" +import { InstanceBootstrap } from "@/project/bootstrap" export const Info = WorkspaceInfoSchema export type Info = WorkspaceInfo @@ -86,6 +89,7 @@ export type CreateInput = Schema.Schema.Type export const SessionWarpInput = Schema.Struct({ workspaceID: Schema.NullOr(WorkspaceID), sessionID: SessionID, + copyChanges: Schema.optional(Schema.Boolean), }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) export type SessionWarpInput = Schema.Schema.Type @@ -137,6 +141,7 @@ type SessionWarpError = | WorkspaceNotFoundError | SessionEventsNotFoundError | SessionWarpHttpError + | Vcs.PatchApplyError | HttpClientError.HttpClientError type WaitForSyncError = SyncTimeoutError | SyncAbortedError type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError @@ -167,6 +172,7 @@ export const layer = Layer.effect( const prompt = yield* SessionPrompt.Service const http = yield* HttpClient.HttpClient const sync = yield* SyncEvent.Service + const vcs = yield* Vcs.Service const connections = new Map() const syncFibers = yield* FiberMap.make() @@ -255,6 +261,66 @@ export const layer = Layer.effect( ) }) + const runInWorkspace = (input: { + workspaceID?: WorkspaceID + local: () => Effect.Effect + remote: (input: { + workspace: Info + target: Extract + }) => HttpClientRequest.HttpClientRequest + fallback: A + response?: "json" | "text" + }) => + Effect.gen(function* () { + if (!input.workspaceID) return yield* input.local() + + const workspace = yield* get(input.workspaceID) + if (!workspace) return input.fallback + + const adapter = getAdapter(workspace.projectID, workspace.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace)) + + if (target.type === "local") { + const store = yield* InstanceStore.Service + return yield* store.provide({ directory: target.directory }, input.local()) + } + + const response = yield* http.execute(input.remote({ workspace, target })).pipe( + Effect.catch((error) => + Effect.sync(() => { + log.warn("workspace target request failed", { + workspaceID: workspace.id, + error: errorData(error), + }) + }), + ), + ) + if (!response) return input.fallback + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))) + log.warn("workspace target request failed", { + workspaceID: workspace.id, + status: response.status, + body, + }) + return input.fallback + } + + const body = input.response === "text" ? response.text : response.json + return yield* body.pipe( + Effect.map((result) => result as A), + Effect.catch((error) => + Effect.sync(() => { + log.warn("workspace target response decode failed", { + workspaceID: workspace.id, + error: errorData(error), + }) + return input.fallback + }), + ), + ) + }) + const syncHistory = Effect.fn("Workspace.syncHistory")(function* ( space: Info, url: URL | string, @@ -557,6 +623,36 @@ export const layer = Layer.effect( } } + const sourcePatch = + input.copyChanges && current?.workspaceID + ? yield* runInWorkspace({ + workspaceID: current?.workspaceID ?? undefined, + local: () => vcs.diffRaw(), + remote: ({ target }) => + HttpClientRequest.get(route(target.url, "/vcs/diff/raw"), { + headers: new Headers(target.headers), + }), + fallback: "", + response: "text", + }).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)))) + : "" + + if (sourcePatch) { + // Attempt to apply the file changes to the new workspace. + // We intentionally do first so if it fails we don't warp + // the session. + yield* runInWorkspace({ + workspaceID: input.workspaceID ?? undefined, + local: () => vcs.apply({ patch: sourcePatch }), + remote: ({ target }) => + HttpClientRequest.post(route(target.url, "/vcs/apply"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ patch: sourcePatch }), + }), + fallback: { applied: false }, + }).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)))) + } + if (input.workspaceID === null) { yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { @@ -866,6 +962,8 @@ export const defaultLayer = layer.pipe( Layer.provide(Session.defaultLayer), Layer.provide(SyncEvent.defaultLayer), Layer.provide(SessionPrompt.defaultLayer), + Layer.provide(Project.defaultLayer), + Layer.provide(Vcs.defaultLayer), Layer.provide(FetchHttpClient.layer), ) diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index fff1d70b2a41..349bbad466ec 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -68,6 +68,7 @@ export interface Options { readonly cwd: string readonly env?: Record readonly maxOutputBytes?: number + readonly stdin?: ChildProcess.CommandInput } export interface Interface { @@ -85,6 +86,7 @@ export interface Interface { readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect readonly statUntracked: (cwd: string, file: string) => Effect.Effect + readonly applyPatch: (cwd: string, patch: string) => Effect.Effect } const kind = (code: string): Kind => { @@ -101,6 +103,8 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const encoder = new TextEncoder() + const stdin = (text: string) => Stream.make(encoder.encode(text)) const run = Effect.fn("Git.run")( function* (args: string[], opts: Options) { @@ -108,7 +112,7 @@ export const layer = Layer.effect( cwd: opts.cwd, env: opts.env, extendEnv: true, - stdin: "ignore", + stdin: opts.stdin ?? "ignore", stdout: "pipe", stderr: "pipe", }) @@ -316,9 +320,13 @@ export const layer = Layer.effect( cwd, maxOutputBytes: 4096, }) + if (result.truncated) return - const parts = result.text().split("\t") + const text = result.text() + + const parts = text.split("\t") if (parts.length < 2) return + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10) const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10) return { @@ -328,6 +336,10 @@ export const layer = Layer.effect( } satisfies Stat }) + const applyPatch = Effect.fn("Git.applyPatch")(function* (cwd: string, patch: string) { + return yield* run(["apply", "-"], { cwd, stdin: stdin(patch) }) + }) + return Service.of({ run, branch, @@ -343,6 +355,7 @@ export const layer = Layer.effect( patchAll, patchUntracked, statUntracked, + applyPatch, }) }), ) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 8b3bedbf5bf1..02173453dbfc 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -6,7 +6,7 @@ import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" -import { zod } from "@/util/effect-zod" +import { zod, zodObject } from "@/util/effect-zod" import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "vcs" }) @@ -239,11 +239,39 @@ export const FileDiff = Schema.Struct({ .pipe(withStatics((s) => ({ zod: zod(s) }))) export type FileDiff = Schema.Schema.Type +export const FileStatus = Schema.Struct({ + file: Schema.String, + additions: NonNegativeInt, + deletions: NonNegativeInt, + status: Schema.Literals(["added", "deleted", "modified"]), +}) + .annotate({ identifier: "VcsFileStatus" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type FileStatus = Schema.Schema.Type + +export const ApplyInput = Schema.Struct({ + patch: Schema.String, +}).pipe(withStatics((s) => ({ zod: zod(s), zodObject: zodObject(s) }))) +export type ApplyInput = Schema.Schema.Type + +export const ApplyResult = Schema.Struct({ + applied: Schema.Boolean, +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ApplyResult = Schema.Schema.Type + +export class PatchApplyError extends Schema.TaggedErrorClass()("VcsPatchApplyError", { + message: Schema.String, + reason: Schema.Literals(["non-git", "not-clean"]), +}) {} + export interface Interface { readonly init: () => Effect.Effect readonly branch: () => Effect.Effect readonly defaultBranch: () => Effect.Effect + readonly status: () => Effect.Effect readonly diff: (mode: Mode) => Effect.Effect + readonly diffRaw: () => Effect.Effect + readonly apply: (input: ApplyInput) => Effect.Effect } interface State { @@ -304,6 +332,31 @@ export const layer: Layer.Layer = Lay defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () { return yield* InstanceState.use(state, (x) => x.root?.name) }), + status: Effect.fn("Vcs.status")(function* () { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return [] + const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined + const [list, stats] = yield* Effect.all( + [git.status(ctx.directory), ref ? git.stats(ctx.directory, ref) : Effect.succeed([])], + { concurrency: 2 }, + ) + const map = nums(stats) + return yield* Effect.forEach( + list.toSorted((a, b) => a.file.localeCompare(b.file)), + (item) => + Effect.gen(function* () { + const stat = + map.get(item.file) ?? + (item.status === "added" ? yield* git.statUntracked(ctx.worktree, item.file) : undefined) + return { + file: item.file, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + status: item.status, + } satisfies FileStatus + }), + ) + }), diff: Effect.fn("Vcs.diff")(function* (mode: Mode) { const value = yield* InstanceState.get(state) const ctx = yield* InstanceState.context @@ -318,6 +371,36 @@ export const layer: Layer.Layer = Lay if (!ref) return [] return yield* diffAgainstRef(git, ctx.directory, ref) }), + diffRaw: Effect.fn("Vcs.diffRaw")(function* () { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return "" + const [hasHead, status] = yield* Effect.all([git.hasHead(ctx.directory), git.status(ctx.directory)], { + concurrency: 2, + }) + const tracked = hasHead ? (yield* git.patchAll(ctx.directory, "HEAD")).text : "" + const untracked = yield* Effect.forEach( + status.filter((item) => item.code === "??"), + (item) => git.patchUntracked(ctx.directory, item.file).pipe(Effect.map((patch) => patch.text)), + ) + return [tracked, ...untracked].filter(Boolean).join("\n") + }), + apply: Effect.fn("Vcs.apply")(function* (input: ApplyInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { + return yield* new PatchApplyError({ + message: "Patch can't be applied because the project is not git-based", + reason: "non-git", + }) + } + const applied = yield* git.applyPatch(ctx.directory, input.patch) + if (applied.exitCode !== 0) { + return yield* new PatchApplyError({ + message: "Patch can't be applied", + reason: "not-clean", + }) + } + return { applied: true } + }), }) }), ) diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 788aef3176b9..0c1bf252edae 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -8,6 +8,7 @@ import { AppRuntime } from "@/effect/app-runtime" import { WorkspaceAdapterEntry } from "@/control-plane/types" import { zodObject } from "@/util/effect-zod" import { Instance } from "@/project/instance" +import { Vcs } from "@/project/vcs" import { errors } from "../../error" import { lazy } from "@/util/lazy" @@ -164,19 +165,47 @@ export const WorkspaceRoutes = lazy(() => z.object({ id: zodObject(Workspace.Info).shape.id.nullable(), sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID, + copyChanges: z.boolean().optional(), }), ), async (c) => { const body = c.req.valid("json") - await AppRuntime.runPromise( + return AppRuntime.runPromise( Workspace.Service.use((workspace) => workspace.sessionWarp({ workspaceID: body.id, sessionID: body.sessionID, + copyChanges: body.copyChanges, + }), + ).pipe( + Effect.match({ + onFailure: (error) => { + if (error instanceof Vcs.PatchApplyError) { + return c.json( + { + name: "VcsApplyError", + data: { + message: error.message, + reason: error.reason, + }, + }, + 400, + ) + } + return c.json( + { + name: "WorkspaceWarpError", + data: { + message: error.message, + }, + }, + 400, + ) + }, + onSuccess: () => c.body(null, 204), }), ), ) - return c.body(null, 204) }, ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index 463ea1ae4c83..f2b0504a05f7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -5,7 +5,7 @@ import { LSP } from "@/lsp/lsp" import { Vcs } from "@/project/vcs" import { Skill } from "@/skill" import { Schema } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" @@ -23,11 +23,25 @@ export const VcsDiffQuery = Schema.Struct({ mode: Vcs.Mode, }) +export class ApiVcsApplyError extends Schema.ErrorClass("VcsApplyError")( + { + name: Schema.Literal("VcsApplyError"), + data: Schema.Struct({ + message: Schema.String, + reason: Schema.Literals(["non-git", "not-clean"]), + }), + }, + { httpApiStatus: 400 }, +) {} + export const InstancePaths = { dispose: "/instance/dispose", path: "/path", vcs: "/vcs", + vcsStatus: "/vcs/status", vcsDiff: "/vcs/diff", + vcsDiffRaw: "/vcs/diff/raw", + vcsApply: "/vcs/apply", command: "/command", agent: "/agent", skill: "/skill", @@ -68,6 +82,15 @@ export const InstanceApi = HttpApi.make("instance") "Retrieve version control system (VCS) information for the current project, such as git branch.", }), ), + HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, { + success: described(Schema.Array(Vcs.FileStatus), "VCS status"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.status", + summary: "Get VCS status", + description: "Retrieve changed files in the current working tree without patches.", + }), + ), HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, { query: VcsDiffQuery, success: described(Schema.Array(Vcs.FileDiff), "VCS diff"), @@ -78,6 +101,29 @@ export const InstanceApi = HttpApi.make("instance") description: "Retrieve the current git diff for the working tree or against the default branch.", }), ), + HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, { + success: described( + Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })), + "Raw VCS diff", + ), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.diff.raw", + summary: "Get raw VCS diff", + description: "Retrieve a raw patch for current uncommitted changes.", + }), + ), + HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, { + payload: Vcs.ApplyInput, + success: described(Vcs.ApplyResult, "VCS patch applied"), + error: ApiVcsApplyError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.apply", + summary: "Apply VCS patch", + description: "Apply a raw patch to the current working tree.", + }), + ), HttpApiEndpoint.get("command", InstancePaths.command, { success: described(Schema.Array(Command.Info), "List of commands"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index f197ab976541..66422c13b6fd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -2,6 +2,7 @@ import { Workspace } from "@/control-plane/workspace" import { WorkspaceAdapterEntry } from "@/control-plane/types" import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { ApiVcsApplyError } from "./instance" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" @@ -12,8 +13,19 @@ export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fie export const WarpPayload = Schema.Struct({ id: Schema.NullOr(Workspace.Info.fields.id), sessionID: Workspace.SessionWarpInput.fields.sessionID, + copyChanges: Workspace.SessionWarpInput.fields.copyChanges, }) +export class ApiWorkspaceWarpError extends Schema.ErrorClass("WorkspaceWarpError")( + { + name: Schema.Literal("WorkspaceWarpError"), + data: Schema.Struct({ + message: Schema.String, + }), + }, + { httpApiStatus: 400 }, +) {} + export const WorkspacePaths = { adapters: `${root}/adapter`, list: root, @@ -78,7 +90,7 @@ export const WorkspaceApi = HttpApi.make("workspace") HttpApiEndpoint.post("warp", WorkspacePaths.warp, { payload: WarpPayload, success: described(HttpApiSchema.NoContent, "Session warped"), - error: HttpApiError.BadRequest, + error: [ApiWorkspaceWarpError, ApiVcsApplyError], }).annotateMerge( OpenApi.annotations({ identifier: "experimental.workspace.warp", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts index c2a4503b481f..50a7fecfa70b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts @@ -9,6 +9,7 @@ import { Skill } from "@/skill" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" +import { ApiVcsApplyError } from "../groups/instance" import { markInstanceForDisposal } from "../lifecycle" export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) => @@ -41,10 +42,33 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" return { branch, default_branch } }) + const getVcsStatus = Effect.fn("InstanceHttpApi.vcsStatus")(function* () { + return yield* vcs.status() + }) + const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) { return yield* vcs.diff(ctx.query.mode) }) + const getVcsDiffRaw = Effect.fn("InstanceHttpApi.vcsDiffRaw")(function* () { + return yield* vcs.diffRaw() + }) + + const applyVcs = Effect.fn("InstanceHttpApi.vcsApply")(function* (ctx: { payload: Vcs.ApplyInput }) { + return yield* vcs.apply(ctx.payload).pipe( + Effect.mapError( + (error) => + new ApiVcsApplyError({ + name: "VcsApplyError", + data: { + message: error.message, + reason: error.reason, + }, + }), + ), + ) + }) + const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { return yield* command.list() }) @@ -69,7 +93,10 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" .handle("dispose", dispose) .handle("path", getPath) .handle("vcs", getVcs) + .handle("vcsStatus", getVcsStatus) .handle("vcsDiff", getVcsDiff) + .handle("vcsDiffRaw", getVcsDiffRaw) + .handle("vcsApply", applyVcs) .handle("command", getCommand) .handle("agent", getAgent) .handle("skill", getSkill) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index b415943a6242..d908eda9d1ac 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -1,10 +1,12 @@ import { listAdapters } from "@/control-plane/adapters" import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" +import { Vcs } from "@/project/vcs" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { CreatePayload, WarpPayload } from "../groups/workspace" +import { ApiVcsApplyError } from "../groups/instance" +import { ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace" export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => Effect.gen(function* () { @@ -44,8 +46,27 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac .sessionWarp({ workspaceID: ctx.payload.id, sessionID: ctx.payload.sessionID, + copyChanges: ctx.payload.copyChanges, }) - .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + .pipe( + Effect.mapError((error) => { + if (error instanceof Vcs.PatchApplyError) { + return new ApiVcsApplyError({ + name: "VcsApplyError", + data: { + message: error.message, + reason: error.reason, + }, + }) + } + return new ApiWorkspaceWarpError({ + name: "WorkspaceWarpError", + data: { + message: error.message, + }, + }) + }), + ) }) return handlers diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 71662dea903d..b6bf8baa7496 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -27,7 +27,7 @@ import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" import { InstanceMiddleware } from "./middleware" -import { jsonRequest } from "./trace" +import { jsonRequest, runRequest } from "./trace" import { ExperimentalHttpApiServer } from "./httpapi/server" import { EventPaths } from "./httpapi/event" import { ExperimentalPaths } from "./httpapi/groups/experimental" @@ -40,6 +40,7 @@ import { SyncPaths } from "./httpapi/groups/sync" import { TuiPaths } from "./httpapi/groups/tui" import { WorkspacePaths } from "./httpapi/groups/workspace" import type { CorsOptions } from "@/server/cors" +import { errors } from "@/server/error" export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => { const app = new Hono() @@ -86,7 +87,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H app.get(InstancePaths.path, (c) => handler(c.req.raw, context)) app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context)) app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsStatus, (c) => handler(c.req.raw, context)) app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsDiffRaw, (c) => handler(c.req.raw, context)) + app.post(InstancePaths.vcsApply, (c) => handler(c.req.raw, context)) app.get(InstancePaths.command, (c) => handler(c.req.raw, context)) app.get(InstancePaths.agent, (c) => handler(c.req.raw, context)) app.get(InstancePaths.skill, (c) => handler(c.req.raw, context)) @@ -288,6 +292,98 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H return yield* vcs.diff(c.req.valid("query").mode) }), ) + .get( + "/vcs/status", + describeRoute({ + summary: "Get VCS status", + description: "Retrieve changed files in the current working tree without patches.", + operationId: "vcs.status", + responses: { + 200: { + description: "VCS status", + content: { + "application/json": { + schema: resolver(Vcs.FileStatus.zod.array()), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("InstanceRoutes.vcs.status", c, function* () { + const vcs = yield* Vcs.Service + return yield* vcs.status() + }), + ) + .get( + "/vcs/diff/raw", + describeRoute({ + summary: "Get raw VCS diff", + description: "Retrieve a raw patch for current uncommitted changes.", + operationId: "vcs.diff.raw", + responses: { + 200: { + description: "Raw VCS diff", + content: { + "text/x-diff": { + schema: resolver(z.string()), + }, + }, + }, + }, + }), + async (c) => { + const patch = await runRequest( + "InstanceRoutes.vcs.diffRaw", + c, + Vcs.Service.use((vcs) => vcs.diffRaw()), + ) + return c.text(patch, 200, { "content-type": "text/x-diff; charset=utf-8" }) + }, + ) + .post( + "/vcs/apply", + describeRoute({ + summary: "Apply VCS patch", + description: "Apply a raw patch to the current working tree.", + operationId: "vcs.apply", + responses: { + 200: { + description: "VCS patch applied", + content: { + "application/json": { + schema: resolver(Vcs.ApplyResult.zod), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Vcs.ApplyInput.zodObject), + async (c) => { + const result = await runRequest( + "InstanceRoutes.vcs.apply", + c, + Vcs.Service.use((vcs) => vcs.apply(c.req.valid("json") as Vcs.ApplyInput)).pipe( + Effect.match({ + onFailure: (error) => ({ ok: false as const, error }), + onSuccess: (value) => ({ ok: true as const, value }), + }), + ), + ) + if (result.ok) return c.json(result.value) + return c.json( + { + name: "VcsApplyError", + data: { + message: result.error.message, + reason: result.error.reason, + }, + }, + 400, + ) + }, + ) .get( "/command", describeRoute({ diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 49f60e931190..ec900b441679 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -63,6 +63,11 @@ export function truncate(str: string, len: number): string { return str.slice(0, len - 1) + "…" } +export function truncateLeft(str: string, len: number): string { + if (str.length <= len) return str + return "…" + str.slice(-(len - 1)) +} + export function truncateMiddle(str: string, maxLength: number = 35): string { if (str.length <= maxLength) return str diff --git a/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts index 7d051923f69a..a32dc611254d 100644 --- a/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts +++ b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts @@ -35,4 +35,29 @@ describe("recentConnectedWorkspaces", () => { expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"]) }) + + test("omits the active workspace before limiting recent workspaces", () => { + const workspaces = [ + { id: "wrk_a", name: "alpha" }, + { id: "wrk_b", name: "beta" }, + { id: "wrk_c", name: "gamma" }, + { id: "wrk_d", name: "delta" }, + ] + + const { recent, hasMore } = recentConnectedWorkspaces({ + sessions: [ + { workspaceID: "wrk_a", time: { updated: 400 } }, + { workspaceID: "wrk_b", time: { updated: 300 } }, + { workspaceID: "wrk_c", time: { updated: 200 } }, + { workspaceID: "wrk_d", time: { updated: 100 } }, + ], + get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID), + status: () => "connected", + limit: 3, + omitWorkspaceID: "wrk_a", + }) + + expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_c", "wrk_d"]) + expect(hasMore).toBe(false) + }) }) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 769e78fe9a3e..0eba431e1ac0 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { $ } from "bun" import fs from "node:fs/promises" import Http from "node:http" import path from "node:path" @@ -29,12 +30,17 @@ import { WorkspaceTable } from "../../src/control-plane/workspace.sql" import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" import * as WorkspaceOld from "../../src/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" +import { InstanceStore } from "@/project/instance-store" +import { InstanceBootstrap } from "@/project/bootstrap" void Log.init({ print: false }) const testServerLayer = Layer.mergeAll( NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), - WorkspaceOld.defaultLayer, + WorkspaceOld.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), + ), SessionNs.defaultLayer, ) const it = testEffect(testServerLayer) @@ -107,6 +113,18 @@ async function withInstance(fn: (dir: string) => T | Promise) { }) } +async function initGitRepo(dir: string) { + await fs.mkdir(dir, { recursive: true }) + await $`git init`.cwd(dir).quiet() + await $`git config core.fsmonitor false`.cwd(dir).quiet() + await $`git config commit.gpgsign false`.cwd(dir).quiet() + await $`git config user.email "test@opencode.test"`.cwd(dir).quiet() + await $`git config user.name "Test"`.cwd(dir).quiet() + await fs.writeFile(path.join(dir, "tracked.txt"), "base\n") + await $`git add tracked.txt`.cwd(dir).quiet() + await $`git commit -m "base"`.cwd(dir).quiet() +} + const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) const createWorkspace = (input: WorkspaceOld.CreateInput) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input))) @@ -644,6 +662,33 @@ describe("workspace-old CRUD", () => { }) }) + test("sessionWarp applies source workspace patch to local target workspace", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-patch-prev-local") + const targetType = unique("warp-patch-target-local") + const previousDir = path.join(dir, "warp-patch-prev-local") + const targetDir = path.join(dir, "warp-patch-target-local") + await initGitRepo(previousDir) + await initGitRepo(targetDir) + await fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n") + await fs.writeFile(path.join(previousDir, "new.txt"), "new\n") + + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter) + registerAdapter(Instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) + + expect(await fs.readFile(path.join(targetDir, "tracked.txt"), "utf8")).toBe("changed\n") + expect(await fs.readFile(path.join(targetDir, "new.txt"), "utf8")).toBe("new\n") + }) + }) + test("sessionWarp detaches a session to the local project and claims project ownership", async () => { await withInstance(async (dir) => { const previousType = unique("warp-detach-local") @@ -696,10 +741,12 @@ describe("workspace-old CRUD", () => { }, ]) } + if (call.url.pathname === "/warp-source/vcs/diff/raw") return HttpServerResponse.text("remote patch") if (call.url.pathname === "/warp-target/sync/replay") return yield* HttpServerResponse.json({ sessionID: "ok" }) if (call.url.pathname === "/warp-target/sync/steal") return yield* HttpServerResponse.json({ sessionID: "ok" }) + if (call.url.pathname === "/warp-target/vcs/apply") return yield* HttpServerResponse.json({ applied: true }) return HttpServerResponse.text("unexpected", { status: 500 }) }), ) @@ -722,15 +769,18 @@ describe("workspace-old CRUD", () => { historySessionID = session.id historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 - yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id }) + yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([ "POST /warp-source/sync/history", + "GET /warp-source/vcs/diff/raw", + "POST /warp-target/vcs/apply", "POST /warp-target/sync/replay", "POST /warp-target/sync/steal", ]) expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 }) - expect(calls[1].json).toMatchObject({ + expect(calls[2].json).toEqual({ patch: "remote patch" }) + expect(calls[3].json).toMatchObject({ directory: "remote-target-dir", events: [ { @@ -745,7 +795,7 @@ describe("workspace-old CRUD", () => { }, ], }) - expect(calls[2].json).toEqual({ sessionID: session.id }) + expect(calls[4].json).toEqual({ sessionID: session.id }) expect((yield* sessionSvc.get(session.id)).title).toBe("from source history") expect(sessionSequenceOwner(session.id)).toBe(target.id) }), diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 249087808d48..9199a85a6151 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -12,8 +12,14 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" const { Flag } = await import("@opencode-ai/core/flag/flag") const { Plugin } = await import("../../src/plugin/index") const { Workspace } = await import("../../src/control-plane/workspace") +const { InstanceBootstrap } = await import("../../src/project/bootstrap") const { Instance } = await import("../../src/project/instance") -const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, Workspace.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const { InstanceStore } = await import("../../src/project/instance-store") +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) +const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer)) const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 410dbe742658..5e00d7770857 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -10,8 +10,10 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { Instance } from "../../src/project/instance" import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" @@ -36,6 +38,11 @@ const testStateLayer = Layer.effectDiscard( }), ) +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) + const it = testEffect( Layer.mergeAll( testStateLayer, @@ -43,7 +50,7 @@ const it = testEffect( NodeServices.layer, InstanceLayer.layer, Project.defaultLayer, - Workspace.defaultLayer, + workspaceLayer, ), ) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index c45aacce75a9..c1d82446b970 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" @@ -9,6 +9,8 @@ import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { WithInstance } from "../../src/project/with-instance" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" @@ -30,6 +32,10 @@ void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) function app(experimental = true) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental @@ -106,7 +112,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri extra: null, projectID: input.projectID, }), - ).pipe(Effect.provide(Workspace.defaultLayer)) + ).pipe(Effect.provide(workspaceLayer)) }) function request(path: string, init?: RequestInit) { diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index b0b276841d7b..379b71a91e53 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -20,6 +20,8 @@ import { WorkspaceID } from "../../src/control-plane/schema" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { @@ -45,13 +47,18 @@ const testStateLayer = Layer.effectDiscard( }), ) +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) + const it = testEffect( Layer.mergeAll( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, Project.defaultLayer, - Workspace.defaultLayer, + workspaceLayer, Socket.layerWebSocketConstructorGlobal, ), ) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 21bf4120c951..9b38cb44a2ea 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -14,6 +14,8 @@ import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { WorkspaceRef } from "../../src/effect/instance-ref" @@ -23,9 +25,11 @@ void Log.init({ print: false }) const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -const it = testEffect( - Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer), +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), ) +const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer)) function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) { return Effect.promise(() => { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 803d9ed16e00..ebedb1dd6ba0 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -202,8 +202,12 @@ import type { V2SessionMessagesResponses, V2SessionPromptResponses, V2SessionWaitResponses, + VcsApplyErrors, + VcsApplyResponses, + VcsDiffRawResponses, VcsDiffResponses, VcsGetResponses, + VcsStatusResponses, WorktreeCreateErrors, WorktreeCreateInput, WorktreeCreateResponses, @@ -1022,6 +1026,7 @@ export class Workspace extends HeyApiClient { workspace?: string id?: string | null sessionID?: string + copyChanges?: boolean }, options?: Options, ) { @@ -1034,6 +1039,7 @@ export class Workspace extends HeyApiClient { { in: "query", key: "workspace" }, { in: "body", key: "id" }, { in: "body", key: "sessionID" }, + { in: "body", key: "copyChanges" }, ], }, ], @@ -1555,6 +1561,38 @@ export class Path extends HeyApiClient { } } +export class Diff extends HeyApiClient { + /** + * Get raw VCS diff + * + * Retrieve a raw patch for current uncommitted changes. + */ + public raw( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/diff/raw", + ...options, + ...params, + }) + } +} + export class Vcs extends HeyApiClient { /** * Get VCS info @@ -1586,6 +1624,36 @@ export class Vcs extends HeyApiClient { }) } + /** + * Get VCS status + * + * Retrieve changed files in the current working tree without patches. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/status", + ...options, + ...params, + }) + } + /** * Get VCS diff * @@ -1617,6 +1685,48 @@ export class Vcs extends HeyApiClient { ...params, }) } + + /** + * Apply VCS patch + * + * Apply a raw patch to the current working tree. + */ + public apply( + parameters?: { + directory?: string + workspace?: string + patch?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "patch" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/apply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _diff?: Diff + get diff2(): Diff { + return (this._diff ??= new Diff({ client: this.client })) + } } export class Command extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b58f6cfc2b51..175fe69e6611 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1474,6 +1474,13 @@ export type VcsInfo = { default_branch?: string } +export type VcsFileStatus = { + file: string + additions: number + deletions: number + status: "added" | "deleted" | "modified" +} + export type VcsFileDiff = { file: string patch: string @@ -1482,6 +1489,14 @@ export type VcsFileDiff = { status?: "added" | "deleted" | "modified" } +export type VcsApplyError = { + name: "VcsApplyError" + data: { + message: string + reason: "non-git" | "not-clean" + } +} + export type Command = { name: string description?: string @@ -1736,6 +1751,13 @@ export type Workspace = { projectID: string } +export type WorkspaceWarpError = { + name: "WorkspaceWarpError" + data: { + message: string + } +} + export type SyncEventMessageUpdated = { type: "sync" name: "message.updated.1" @@ -4020,6 +4042,25 @@ export type VcsGetResponses = { export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] +export type VcsStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/status" +} + +export type VcsStatusResponses = { + /** + * VCS status + */ + 200: Array +} + +export type VcsStatusResponse = VcsStatusResponses[keyof VcsStatusResponses] + export type VcsDiffData = { body?: never path?: never @@ -4040,6 +4081,57 @@ export type VcsDiffResponses = { export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] +export type VcsDiffRawData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/diff/raw" +} + +export type VcsDiffRawResponses = { + /** + * Raw VCS diff + */ + 200: string +} + +export type VcsDiffRawResponse = VcsDiffRawResponses[keyof VcsDiffRawResponses] + +export type VcsApplyData = { + body?: { + patch: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/apply" +} + +export type VcsApplyErrors = { + /** + * VcsApplyError + */ + 400: VcsApplyError +} + +export type VcsApplyError2 = VcsApplyErrors[keyof VcsApplyErrors] + +export type VcsApplyResponses = { + /** + * VCS patch applied + */ + 200: { + applied: boolean + } +} + +export type VcsApplyResponse = VcsApplyResponses[keyof VcsApplyResponses] + export type CommandListData = { body?: never path?: never @@ -6667,6 +6759,7 @@ export type ExperimentalWorkspaceWarpData = { body?: { id: string | null sessionID: string + copyChanges?: boolean } path?: never query?: { @@ -6678,9 +6771,9 @@ export type ExperimentalWorkspaceWarpData = { export type ExperimentalWorkspaceWarpErrors = { /** - * Bad request + * WorkspaceWarpError | VcsApplyError */ - 400: BadRequestError + 400: WorkspaceWarpError | VcsApplyError } export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors] From 98e091796b6a293cf20b4187e8fc6a949e0295e4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 14:25:30 +0000 Subject: [PATCH 0027/1034] chore: generate --- .../cmd/tui/component/dialog-session-list.tsx | 6 +- .../tui/component/dialog-workspace-create.tsx | 8 +- packages/sdk/openapi.json | 252 +++++++++++++++++- 3 files changed, 257 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index a521e07b1dcf..e8dbaee3944b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -12,11 +12,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { - openWorkspaceSelect, - type WorkspaceSelection, - warpWorkspaceSession, -} from "./dialog-workspace-create" +import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 31955dcf31df..d7e212ab15e8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -166,7 +166,9 @@ export async function confirmWorkspaceFileChanges(input: { sourceWorkspaceID?: string }) { const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined) - const fileChangeChoice = status?.data?.length ? await DialogWorkspaceFileChanges.show(input.dialog, status.data) : "no" + const fileChangeChoice = status?.data?.length + ? await DialogWorkspaceFileChanges.show(input.dialog, status.data) + : "no" if (!fileChangeChoice) return return fileChangeChoice === "yes" } @@ -262,7 +264,9 @@ export function DialogWorkspaceSelect(props: { return } - dialog.replace(() => ) + dialog.replace(() => ( + + )) }} /> ) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 477145f017dc..04c34e2dc100 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1897,6 +1897,54 @@ ] } }, + "/vcs/status": { + "get": { + "tags": ["instance"], + "operationId": "vcs.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "VCS status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VcsFileStatus" + }, + "description": "VCS status" + } + } + } + } + }, + "description": "Retrieve changed files in the current working tree without patches.", + "summary": "Get VCS status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.status({\n ...\n})" + } + ] + } + }, "/vcs/diff": { "get": { "tags": ["instance"], @@ -1954,6 +2002,128 @@ ] } }, + "/vcs/diff/raw": { + "get": { + "tags": ["instance"], + "operationId": "vcs.diff.raw", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Raw VCS diff", + "content": { + "text/x-diff; charset=utf-8": { + "schema": { + "type": "string" + } + } + } + } + }, + "description": "Retrieve a raw patch for current uncommitted changes.", + "summary": "Get raw VCS diff", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff.raw({\n ...\n})" + } + ] + } + }, + "/vcs/apply": { + "post": { + "tags": ["instance"], + "operationId": "vcs.apply", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "VCS patch applied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "applied": { + "type": "boolean" + } + }, + "required": ["applied"], + "additionalProperties": false, + "description": "VCS patch applied" + } + } + } + }, + "400": { + "description": "VcsApplyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VcsApplyError" + } + } + } + } + }, + "description": "Apply a raw patch to the current working tree.", + "summary": "Apply VCS patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "patch": { + "type": "string" + } + }, + "required": ["patch"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.apply({\n ...\n})" + } + ] + } + }, "/command": { "get": { "tags": ["instance"], @@ -8396,11 +8566,18 @@ "description": "Session warped" }, "400": { - "description": "Bad request", + "description": "WorkspaceWarpError | VcsApplyError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceWarpError" + }, + { + "$ref": "#/components/schemas/VcsApplyError" + } + ] } } } @@ -8426,6 +8603,9 @@ }, "sessionID": { "type": "string" + }, + "copyChanges": { + "type": "boolean" } }, "required": ["id", "sessionID"], @@ -12665,6 +12845,28 @@ }, "additionalProperties": false }, + "VcsFileStatus": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "additions": { + "type": "integer", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "minimum": 0 + }, + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] + } + }, + "required": ["file", "additions", "deletions", "status"], + "additionalProperties": false + }, "VcsFileDiff": { "type": "object", "properties": { @@ -12690,6 +12892,31 @@ "required": ["file", "patch", "additions", "deletions"], "additionalProperties": false }, + "VcsApplyError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["VcsApplyError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": ["non-git", "not-clean"] + } + }, + "required": ["message", "reason"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "Command": { "type": "object", "properties": { @@ -13431,6 +13658,27 @@ "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"], "additionalProperties": false }, + "WorkspaceWarpError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["WorkspaceWarpError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "SyncEventMessageUpdated": { "type": "object", "properties": { From fe594693a447fb4f456327888ae2fe5ffc4b6f3d Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 7 May 2026 14:52:09 +0000 Subject: [PATCH 0028/1034] sync release versions for v1.14.41 --- bun.lock | 30 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 37 insertions(+), 37 deletions(-) diff --git a/bun.lock b/bun.lock index 8e3c9b7452de..4e7057630669 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.40", + "version": "1.14.41", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -282,7 +282,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -311,7 +311,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -327,7 +327,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.40", + "version": "1.14.41", "bin": { "opencode": "./bin/opencode", }, @@ -469,7 +469,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -504,7 +504,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "cross-spawn": "catalog:", }, @@ -519,7 +519,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -554,7 +554,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -603,7 +603,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 45908e45b843..600c011b6b3f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.40", + "version": "1.14.41", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 298ae4a8cfba..f2471d2926b7 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.40", + "version": "1.14.41", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index c1acfab6e0b7..4ca29eb4c7df 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.40", + "version": "1.14.41", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 9c0ce79d7477..7e1d77d7dc54 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.40", + "version": "1.14.41", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index d9648b324314..34ddd073f0ec 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 9d92e96e1d14..995ab18ee533 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.40", + "version": "1.14.41", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 431de79bc552..49e35c5db8d2 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.40", + "version": "1.14.41", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 867d2155da7f..beccdb6991d4 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.40", + "version": "1.14.41", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 666198d55eff..8b4850c885b7 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.40" +version = "1.14.41" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index f5bd20d0be8b..70812ab10a5f 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.40", + "version": "1.14.41", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 245bb86621ba..985e2c747fc7 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.40", + "version": "1.14.41", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index fa9e4214e8a3..861208770c33 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.40", + "version": "1.14.41", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 8029d2c9ae98..2959cba2dd07 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.40", + "version": "1.14.41", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 6d2cd71e3040..34175d66a220 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.40", + "version": "1.14.41", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 3e875f7524c2..fc065be9effd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.40", + "version": "1.14.41", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 59390274d5dd..252c81a295c4 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.40", + "version": "1.14.41", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 4052393c0d5c..3eaca42fb774 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.40", + "version": "1.14.41", "publisher": "sst-dev", "repository": { "type": "git", From a300a6cc7a6e7e0ec99897d86b978fea7bb091c7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 7 May 2026 12:38:29 -0400 Subject: [PATCH 0029/1034] rebase migrations properly --- .opencode/opencode.jsonc | 2 +- .../snapshot.json | 56 +++++++++++++------ 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 82ab6d1b35b1..dab531d337eb 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -3,7 +3,7 @@ "provider": {}, "permission": { "edit": { - "packages/opencode/migration/*": "deny", + "packages/opencode/migration/*": "ask", }, }, "mcp": {}, diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json index 4f6ebe00c0a2..7a0d10337d21 100644 --- a/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json +++ b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json @@ -2,7 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "27114226-085b-421a-9a40-29b88747e29a", - "prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"], + "prevIds": ["2ec89846-dcf1-4977-ab5e-244ddc9e3d67"], "ddl": [ { "name": "account_state", @@ -37,7 +37,7 @@ "entityType": "tables" }, { - "name": "session_entry", + "name": "session_message", "entityType": "tables" }, { @@ -598,7 +598,7 @@ "generated": null, "name": "id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -608,7 +608,7 @@ "generated": null, "name": "session_id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -618,7 +618,7 @@ "generated": null, "name": "type", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -628,7 +628,7 @@ "generated": null, "name": "time_created", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -638,7 +638,7 @@ "generated": null, "name": "time_updated", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -648,7 +648,7 @@ "generated": null, "name": "data", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -810,6 +810,26 @@ "entityType": "columns", "table": "session" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, { "type": "integer", "notNull": true, @@ -1122,9 +1142,9 @@ "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, - "name": "fk_session_entry_session_id_session_id_fk", + "name": "fk_session_message_session_id_session_id_fk", "entityType": "fks", - "table": "session_entry" + "table": "session_message" }, { "columns": ["project_id"], @@ -1236,8 +1256,8 @@ { "columns": ["id"], "nameExplicit": false, - "name": "session_entry_pk", - "table": "session_entry", + "name": "session_message_pk", + "table": "session_message", "entityType": "pks" }, { @@ -1332,9 +1352,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_idx", + "name": "session_message_session_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1350,9 +1370,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_type_idx", + "name": "session_message_session_type_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1364,9 +1384,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_time_created_idx", + "name": "session_message_time_created_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ From 626a488fb89208cd148aa97aae546d4b08b37f55 Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Thu, 7 May 2026 19:38:04 +0200 Subject: [PATCH 0030/1034] chore: track model variant in honeycomb (#26188) --- packages/console/app/src/routes/zen/go/v1/chat/completions.ts | 1 + packages/console/app/src/routes/zen/go/v1/messages.ts | 1 + packages/console/app/src/routes/zen/util/handler.ts | 3 +++ packages/console/app/src/routes/zen/v1/chat/completions.ts | 1 + packages/console/app/src/routes/zen/v1/messages.ts | 1 + packages/console/app/src/routes/zen/v1/models/[model].ts | 1 + packages/console/app/src/routes/zen/v1/responses.ts | 1 + packages/console/function/src/log-processor.ts | 2 +- 8 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/zen/go/v1/chat/completions.ts b/packages/console/app/src/routes/zen/go/v1/chat/completions.ts index 9a57e893fb4f..a5cf41f8f0a5 100644 --- a/packages/console/app/src/routes/zen/go/v1/chat/completions.ts +++ b/packages/console/app/src/routes/zen/go/v1/chat/completions.ts @@ -7,6 +7,7 @@ export function POST(input: APIEvent) { modelList: "lite", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, + parseVariant: (url: string, body: any) => body.reasoningEffort ?? body.reasoning_effort, parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/go/v1/messages.ts b/packages/console/app/src/routes/zen/go/v1/messages.ts index ee401e6aa2c3..e66f3658c660 100644 --- a/packages/console/app/src/routes/zen/go/v1/messages.ts +++ b/packages/console/app/src/routes/zen/go/v1/messages.ts @@ -7,6 +7,7 @@ export function POST(input: APIEvent) { modelList: "lite", parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, parseModel: (url: string, body: any) => body.model, + parseVariant: (url: string, body: any) => body.effort, parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index c12129ff1db3..eb56ed829c17 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -71,6 +71,7 @@ export async function handler( modelList: "lite" | "full" parseApiKey: (headers: Headers) => string | undefined parseModel: (url: string, body: any) => string + parseVariant: (url: string, body: any) => string | undefined parseIsStream: (url: string, body: any) => boolean }, ) { @@ -93,6 +94,7 @@ export async function handler( const url = input.request.url const body = await input.request.json() const model = opts.parseModel(url, body) + const variant = opts.parseVariant(url, body) const isStream = opts.parseIsStream(url, body) const rawIp = input.request.headers.get("x-real-ip") ?? "" const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp @@ -109,6 +111,7 @@ export async function handler( request: requestId, client: ocClient, user_agent: userAgent, + "model.variant": variant, }) const zenData = ZenData.list(opts.modelList) const modelInfo = validateModel(zenData, model) diff --git a/packages/console/app/src/routes/zen/v1/chat/completions.ts b/packages/console/app/src/routes/zen/v1/chat/completions.ts index e9e05197e2e2..a55d74356589 100644 --- a/packages/console/app/src/routes/zen/v1/chat/completions.ts +++ b/packages/console/app/src/routes/zen/v1/chat/completions.ts @@ -7,6 +7,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, + parseVariant: (url: string, body: any) => body.reasoningEffort ?? body.reasoning_effort, parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/v1/messages.ts b/packages/console/app/src/routes/zen/v1/messages.ts index 9c09315a6e89..f023d4f9ac20 100644 --- a/packages/console/app/src/routes/zen/v1/messages.ts +++ b/packages/console/app/src/routes/zen/v1/messages.ts @@ -7,6 +7,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, parseModel: (url: string, body: any) => body.model, + parseVariant: (url: string, body: any) => body.effort, parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/v1/models/[model].ts b/packages/console/app/src/routes/zen/v1/models/[model].ts index bc1168eb0c50..bfe6e8654099 100644 --- a/packages/console/app/src/routes/zen/v1/models/[model].ts +++ b/packages/console/app/src/routes/zen/v1/models/[model].ts @@ -7,6 +7,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined, parseModel: (url: string, _body: any) => url.split("/").pop()?.split(":")?.[0] ?? "", + parseVariant: (url: string, body: any) => body.thinkingLevel, parseIsStream: (url: string, _body: any) => // ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse' url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false, diff --git a/packages/console/app/src/routes/zen/v1/responses.ts b/packages/console/app/src/routes/zen/v1/responses.ts index cae625cf6fa9..539b2fdad059 100644 --- a/packages/console/app/src/routes/zen/v1/responses.ts +++ b/packages/console/app/src/routes/zen/v1/responses.ts @@ -7,6 +7,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, + parseVariant: (url: string, body: any) => body.reasoning?.effort, parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/function/src/log-processor.ts b/packages/console/function/src/log-processor.ts index f8b2cf5270b6..2bb741b7aa34 100644 --- a/packages/console/function/src/log-processor.ts +++ b/packages/console/function/src/log-processor.ts @@ -19,7 +19,7 @@ export default { url.pathname !== "/zen/go/v1/responses" && !url.pathname.startsWith("/zen/go/v1/models/") ) - return + continue let data = { "cf.continent": event.event.request.cf?.continent, From 474e311f6f8aa4cef394bb57ef30b70dd745c0ff Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 7 May 2026 14:23:21 -0400 Subject: [PATCH 0031/1034] sync --- packages/console/app/src/routes/zen/util/handler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index eb56ed829c17..7cee86b47e3d 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -165,6 +165,7 @@ export async function handler( if (typeof v === "string") { if (v === "$ip") return [[k, ip]] if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] + if (v === "$session") return sessionId ? [[k, sessionId]] : [] if (v.startsWith("$header.")) { const headerValue = input.request.headers.get(v.slice(8)) return headerValue ? [[k, headerValue]] : [] From 98f5e6e71334c3b600ad325441a022b7fcb4098a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 7 May 2026 20:35:31 +0200 Subject: [PATCH 0032/1034] introduce opentui keymap as sole key/cmd engine (#26053) --- .opencode/plugins/tui-smoke.tsx | 660 +++++++------ .opencode/tui.json | 19 +- bun.lock | 31 +- package.json | 6 +- packages/opencode/package.json | 2 +- packages/opencode/script/schema.ts | 2 +- packages/opencode/specs/tui-plugins.md | 61 +- packages/opencode/specs/v2/keymappings.md | 26 + packages/opencode/src/cli/cmd/tui/app.tsx | 882 +++++++++--------- .../cli/cmd/tui/component/dialog-command.tsx | 172 ---- .../cmd/tui/component/dialog-go-upsell.tsx | 37 +- .../src/cli/cmd/tui/component/dialog-mcp.tsx | 7 +- .../cli/cmd/tui/component/dialog-model.tsx | 11 +- .../cli/cmd/tui/component/dialog-provider.tsx | 23 +- .../dialog-session-delete-failed.tsx | 24 +- .../cmd/tui/component/dialog-session-list.tsx | 12 +- .../cli/cmd/tui/component/dialog-stash.tsx | 10 +- .../dialog-workspace-unavailable.tsx | 28 +- .../cmd/tui/component/prompt/autocomplete.tsx | 118 ++- .../cli/cmd/tui/component/prompt/index.tsx | 647 +++++++------ .../cli/cmd/tui/component/prompt/traits.ts | 9 +- .../cmd/tui/component/textarea-keybindings.ts | 73 -- .../cmd/tui/config/legacy-keymap-transform.ts | 177 ++++ .../src/cli/cmd/tui/config/tui-schema.ts | 310 +++++- .../opencode/src/cli/cmd/tui/config/tui.ts | 60 +- .../cli/cmd/tui/context/command-palette.tsx | 163 ++++ .../src/cli/cmd/tui/context/keybind.tsx | 105 --- .../cli/cmd/tui/context/plugin-keybinds.ts | 41 - .../src/cli/cmd/tui/context/tui-config.tsx | 2 +- .../cli/cmd/tui/feature-plugins/home/tips.tsx | 37 +- .../tui/feature-plugins/system/plugins.tsx | 65 +- .../tui/feature-plugins/system/session-v2.tsx | 50 +- packages/opencode/src/cli/cmd/tui/keymap.tsx | 91 ++ .../opencode/src/cli/cmd/tui/plugin/api.tsx | 37 +- .../src/cli/cmd/tui/plugin/runtime.ts | 85 +- .../src/cli/cmd/tui/routes/session/index.tsx | 159 ++-- .../cli/cmd/tui/routes/session/permission.tsx | 142 +-- .../cli/cmd/tui/routes/session/question.tsx | 236 +++-- .../tui/routes/session/subagent-footer.tsx | 22 +- .../src/cli/cmd/tui/ui/dialog-alert.tsx | 21 +- .../src/cli/cmd/tui/ui/dialog-confirm.tsx | 41 +- .../cli/cmd/tui/ui/dialog-export-options.tsx | 70 +- .../src/cli/cmd/tui/ui/dialog-help.tsx | 20 +- .../src/cli/cmd/tui/ui/dialog-prompt.tsx | 16 - .../src/cli/cmd/tui/ui/dialog-select.tsx | 164 +++- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 55 +- .../opencode/src/cli/cmd/tui/util/scroll.ts | 4 +- .../src/cli/cmd/tui/util/selection.ts | 42 +- packages/opencode/src/config/keybinds.ts | 18 +- packages/opencode/src/util/keybind.ts | 103 -- .../test/cli/tui/keybind-plugin.test.ts | 90 -- .../opencode/test/cli/tui/plugin-add.test.ts | 11 +- .../test/cli/tui/plugin-install.test.ts | 6 +- .../cli/tui/plugin-loader-entrypoint.test.ts | 33 +- .../test/cli/tui/plugin-loader-pure.test.ts | 5 +- .../test/cli/tui/plugin-loader.test.ts | 308 +++++- .../test/cli/tui/plugin-toggle.test.ts | 9 +- packages/opencode/test/config/tui.test.ts | 161 ++++ packages/opencode/test/fixture/tui-plugin.ts | 67 +- packages/opencode/test/fixture/tui-runtime.ts | 43 +- packages/opencode/test/keybind.test.ts | 421 --------- packages/plugin/package.json | 9 +- packages/plugin/src/tui.ts | 87 +- packages/web/src/content/docs/config.mdx | 16 +- packages/web/src/content/docs/keybinds.mdx | 387 +++++--- packages/web/src/content/docs/tui.mdx | 17 +- .../script => script}/upgrade-opentui.ts | 31 +- 67 files changed, 3889 insertions(+), 3008 deletions(-) delete mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx delete mode 100644 packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts create mode 100644 packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts create mode 100644 packages/opencode/src/cli/cmd/tui/context/command-palette.tsx delete mode 100644 packages/opencode/src/cli/cmd/tui/context/keybind.tsx delete mode 100644 packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts create mode 100644 packages/opencode/src/cli/cmd/tui/keymap.tsx delete mode 100644 packages/opencode/src/util/keybind.ts delete mode 100644 packages/opencode/test/cli/tui/keybind-plugin.test.ts delete mode 100644 packages/opencode/test/keybind.test.ts rename {packages/opencode/script => script}/upgrade-opentui.ts (63%) diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx index 63f9f331e04d..fc890537ec60 100644 --- a/.opencode/plugins/tui-smoke.tsx +++ b/.opencode/plugins/tui-smoke.tsx @@ -1,37 +1,89 @@ /** @jsxImportSource @opentui/solid */ -import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid" -import { RGBA, VignetteEffect } from "@opentui/core" -import type { - TuiKeybindSet, - TuiPlugin, - TuiPluginApi, - TuiPluginMeta, - TuiPluginModule, - TuiSlotPlugin, -} from "@opencode-ai/plugin/tui" +import { useTerminalDimensions, type JSX } from "@opentui/solid" +import { useBindings, useKeymapSelector } from "@opentui/keymap/solid" +import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core" +import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras" +import type { Binding } from "@opentui/keymap" +import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui" const tabs = ["overview", "counter", "help"] -const bind = { - modal: "ctrl+shift+m", - screen: "ctrl+shift+o", - home: "escape,ctrl+h", - left: "left,h", - right: "right,l", - up: "up,k", - down: "down,j", - alert: "a", - confirm: "c", - prompt: "p", - select: "s", - modal_accept: "enter,return", - modal_close: "escape", - dialog_close: "escape", - local: "x", - local_push: "enter,return", - local_close: "q,backspace", - host: "z", +const command = { + modal: "plugin.smoke.modal", + screen: "plugin.smoke.screen", + alert: "plugin.smoke.alert", + confirm: "plugin.smoke.confirm", + prompt: "plugin.smoke.prompt", + select: "plugin.smoke.select", + host: "plugin.smoke.host", + home: "plugin.smoke.home", + toast: "plugin.smoke.toast", + dialog_close: "plugin.smoke.dialog.close", + local_push: "plugin.smoke.local.push", + local_pop: "plugin.smoke.local.pop", + screen_home: "plugin.smoke.screen.home", + screen_left: "plugin.smoke.screen.left", + screen_right: "plugin.smoke.screen.right", + screen_up: "plugin.smoke.screen.up", + screen_down: "plugin.smoke.screen.down", + screen_modal: "plugin.smoke.screen.modal", + screen_local: "plugin.smoke.screen.local", + screen_host: "plugin.smoke.screen.host", + screen_alert: "plugin.smoke.screen.alert", + screen_confirm: "plugin.smoke.screen.confirm", + screen_prompt: "plugin.smoke.screen.prompt", + screen_select: "plugin.smoke.screen.select", + modal_accept: "plugin.smoke.modal.accept", + modal_close: "plugin.smoke.modal.close", +} as const + +const sectionNames = ["global", "dialog", "local", "screen", "modal"] as const +type SectionName = (typeof sectionNames)[number] +type SectionConfig = Record> +type ResolvedSections = Record[]> +type SmokeKeymap = { + sections?: Partial> } +type SmokeOptions = { + enabled?: boolean + label?: unknown + route?: unknown + vignette?: unknown + keymap?: SmokeKeymap +} + +const defaultKeymap = { + global: { + [command.modal]: "ctrl+shift+m", + [command.screen]: "ctrl+shift+o", + }, + dialog: { + [command.dialog_close]: "escape", + }, + local: { + [command.local_push]: "enter,return", + [command.local_pop]: "escape,q,backspace", + }, + screen: { + [command.screen_home]: "escape,ctrl+h", + [command.screen_left]: "left,h", + [command.screen_right]: "right,l", + [command.screen_up]: "up,k", + [command.screen_down]: "down,j", + [command.screen_modal]: "ctrl+shift+m", + [command.screen_local]: "x", + [command.screen_host]: "z", + [command.screen_alert]: "a", + [command.screen_confirm]: "c", + [command.screen_prompt]: "p", + [command.screen_select]: "s", + }, + modal: { + [command.modal_accept]: "enter,return", + [command.modal_close]: "escape", + }, +} satisfies Record + const pick = (value: unknown, fallback: string) => { if (typeof value !== "string") return fallback if (!value.trim()) return fallback @@ -43,16 +95,11 @@ const num = (value: unknown, fallback: number) => { return value } -const rec = (value: unknown) => { - if (!value || typeof value !== "object" || Array.isArray(value)) return - return Object.fromEntries(Object.entries(value)) -} - type Cfg = { label: string route: string vignette: number - keybinds: Record | undefined + keymap: SmokeKeymap | undefined } type Route = { @@ -69,12 +116,12 @@ type State = { local: number } -const cfg = (options: Record | undefined) => { +const cfg = (options: SmokeOptions | undefined) => { return { label: pick(options?.label, "smoke"), route: pick(options?.route, "workspace-smoke"), vignette: Math.max(0, num(options?.vignette, 0.35)), - keybinds: rec(options?.keybinds), + keymap: options?.keymap, } } @@ -85,7 +132,25 @@ const names = (input: Cfg) => { } } -type Keys = TuiKeybindSet +function createKeys(input: SmokeKeymap | undefined): { sections: ResolvedSections } { + const sections = resolveBindingSections( + { + global: { ...defaultKeymap.global, ...input?.sections?.global }, + dialog: { ...defaultKeymap.dialog, ...input?.sections?.dialog }, + local: { ...defaultKeymap.local, ...input?.sections?.local }, + screen: { ...defaultKeymap.screen, ...input?.sections?.screen }, + modal: { ...defaultKeymap.modal, ...input?.sections?.modal }, + } satisfies BindingSectionsConfig, + { sections: sectionNames }, + ).sections + + return { + sections, + } +} + +type Keys = ReturnType + const ui = { panel: "#1d1d1d", border: "#4a4a4a", @@ -292,125 +357,161 @@ const Screen = (props: { } const pop = (base?: State) => { const next = base ?? current(props.api, props.route) - const local = Math.max(0, next.local - 1) - set(local, next) + set(Math.max(0, next.local - 1), next) } const show = () => { setTimeout(() => { open() }, 0) } - useKeyboard((evt) => { - if (props.api.route.current.name !== props.route.screen) return - const next = current(props.api, props.route) - if (props.api.ui.dialog.open) { - if (props.keys.match("dialog_close", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.ui.dialog.clear() - return - } - return - } - - if (next.local > 0) { - if (evt.name === "escape" || props.keys.match("local_close", evt)) { - evt.preventDefault() - evt.stopPropagation() - pop(next) - return - } - - if (props.keys.match("local_push", evt)) { - evt.preventDefault() - evt.stopPropagation() - push(next) - return - } - return - } - - if (props.keys.match("home", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate("home") - return - } - - if (props.keys.match("left", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length }) - return - } - - if (props.keys.match("right", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length }) - return - } - - if (props.keys.match("up", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 }) - return - } - - if (props.keys.match("down", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 }) - return - } - - if (props.keys.match("modal", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.modal, next) - return - } - - if (props.keys.match("local", evt)) { - evt.preventDefault() - evt.stopPropagation() - open() - return - } - - if (props.keys.match("host", evt)) { - evt.preventDefault() - evt.stopPropagation() - host(props.api, props.input, skin) - return - } - - if (props.keys.match("alert", evt)) { - evt.preventDefault() - evt.stopPropagation() - warn(props.api, props.route, next) - return - } - - if (props.keys.match("confirm", evt)) { - evt.preventDefault() - evt.stopPropagation() - check(props.api, props.route, next) - return - } - - if (props.keys.match("prompt", evt)) { - evt.preventDefault() - evt.stopPropagation() - entry(props.api, props.route, next) - return - } - - if (props.keys.match("select", evt)) { - evt.preventDefault() - evt.stopPropagation() - picker(props.api, props.route, next) + const screenActive = () => props.api.route.current.name === props.route.screen + + useBindings(() => ({ + enabled: () => screenActive() && props.api.ui.dialog.open, + commands: [ + { + name: command.dialog_close, + run() { + props.api.ui.dialog.clear() + }, + }, + ], + bindings: props.keys.sections.dialog, + })) + + useBindings(() => ({ + enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local > 0, + commands: [ + { + name: command.local_push, + run() { + push(current(props.api, props.route)) + }, + }, + { + name: command.local_pop, + run() { + pop(current(props.api, props.route)) + }, + }, + ], + bindings: props.keys.sections.local, + })) + + useBindings(() => ({ + enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local === 0, + commands: [ + { + name: command.screen_home, + run() { + props.api.route.navigate("home") + }, + }, + { + name: command.screen_left, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length }) + }, + }, + { + name: command.screen_right, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length }) + }, + }, + { + name: command.screen_up, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 }) + }, + }, + { + name: command.screen_down, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 }) + }, + }, + { + name: command.screen_modal, + run() { + props.api.route.navigate(props.route.modal, current(props.api, props.route)) + }, + }, + { + name: command.screen_local, + run() { + open() + }, + }, + { + name: command.screen_host, + run() { + host(props.api, props.input, skin) + }, + }, + { + name: command.screen_alert, + run() { + warn(props.api, props.route, current(props.api, props.route)) + }, + }, + { + name: command.screen_confirm, + run() { + check(props.api, props.route, current(props.api, props.route)) + }, + }, + { + name: command.screen_prompt, + run() { + entry(props.api, props.route, current(props.api, props.route)) + }, + }, + { + name: command.screen_select, + run() { + picker(props.api, props.route, current(props.api, props.route)) + }, + }, + ], + bindings: props.keys.sections.screen, + })) + const shortcuts = useKeymapSelector((keymap) => { + const bindings = keymap.getCommandBindings({ + visibility: "registered", + commands: [ + command.screen_home, + command.screen_up, + command.screen_down, + command.screen_modal, + command.screen_alert, + command.screen_confirm, + command.screen_prompt, + command.screen_select, + command.screen_local, + command.screen_host, + command.local_push, + command.local_pop, + ], + }) + + return { + screen_home: props.api.keys.formatBindings(bindings.get(command.screen_home)) ?? "", + screen_up: props.api.keys.formatBindings(bindings.get(command.screen_up)) ?? "", + screen_down: props.api.keys.formatBindings(bindings.get(command.screen_down)) ?? "", + screen_modal: props.api.keys.formatBindings(bindings.get(command.screen_modal)) ?? "", + screen_alert: props.api.keys.formatBindings(bindings.get(command.screen_alert)) ?? "", + screen_confirm: props.api.keys.formatBindings(bindings.get(command.screen_confirm)) ?? "", + screen_prompt: props.api.keys.formatBindings(bindings.get(command.screen_prompt)) ?? "", + screen_select: props.api.keys.formatBindings(bindings.get(command.screen_select)) ?? "", + screen_local: props.api.keys.formatBindings(bindings.get(command.screen_local)) ?? "", + screen_host: props.api.keys.formatBindings(bindings.get(command.screen_host)) ?? "", + local_push: props.api.keys.formatBindings(bindings.get(command.local_push)) ?? "", + local_pop: props.api.keys.formatBindings(bindings.get(command.local_pop)) ?? "", } }) @@ -430,7 +531,7 @@ const Screen = (props: { {props.input.label} screen plugin route - {props.keys.print("home")} home + {shortcuts().screen_home} home @@ -477,7 +578,7 @@ const Screen = (props: { Counter: {value.count} - {props.keys.print("up")} / {props.keys.print("down")} change value + {shortcuts().screen_up} / {shortcuts().screen_down} change value ) : null} @@ -485,17 +586,16 @@ const Screen = (props: { {value.tab === 2 ? ( - {props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "} - confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select + {shortcuts().screen_modal} modal | {shortcuts().screen_alert} alert | {shortcuts().screen_confirm}{" "} + confirm | {shortcuts().screen_prompt} prompt | {shortcuts().screen_select} select - {props.keys.print("local")} local stack | {props.keys.print("host")} host stack + {shortcuts().screen_local} local stack | {shortcuts().screen_host} host stack - local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "} - close + local open: {shortcuts().local_push} push nested · {shortcuts().local_pop} close - {props.keys.print("home")} returns home + {shortcuts().screen_home} returns home ) : null} @@ -548,7 +648,7 @@ const Screen = (props: { Plugin-owned stack depth: {value.local} - {props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close + {shortcuts().local_push} push nested · {shortcuts().local_pop} pop/close @@ -571,20 +671,35 @@ const Modal = (props: { const value = parse(props.params) const skin = tone(props.api) - useKeyboard((evt) => { - if (props.api.route.current.name !== props.route.modal) return - - if (props.keys.match("modal_accept", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...value, source: "modal" }) - return - } - - if (props.keys.match("modal_close", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate("home") + useBindings(() => ({ + enabled: () => props.api.route.current.name === props.route.modal, + commands: [ + { + name: command.modal_accept, + run() { + props.api.route.navigate(props.route.screen, { ...parse(props.params), source: "modal" }) + }, + }, + { + name: command.modal_close, + run() { + props.api.route.navigate("home") + }, + }, + ], + bindings: props.keys.sections.modal, + })) + const shortcuts = useKeymapSelector((keymap) => { + const bindings = keymap.getCommandBindings({ + visibility: "registered", + commands: [command.modal, command.screen, command.modal_accept, command.modal_close], + }) + + return { + modal: props.api.keys.formatBindings(bindings.get(command.modal)) ?? "", + screen: props.api.keys.formatBindings(bindings.get(command.screen)) ?? "", + modal_accept: props.api.keys.formatBindings(bindings.get(command.modal_accept)) ?? "", + modal_close: props.api.keys.formatBindings(bindings.get(command.modal_close)) ?? "", } }) @@ -595,10 +710,10 @@ const Modal = (props: { {props.input.label} modal - {props.keys.print("modal")} modal command - {props.keys.print("screen")} screen command + {shortcuts().modal} modal command + {shortcuts().screen} screen command - {props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes + {shortcuts().modal_accept} opens screen · {shortcuts().modal_close} closes [ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => { const route = names(input) - api.command.register(() => [ - { - title: `${input.label} modal`, - value: "plugin.smoke.modal", - keybind: keys.get("modal"), - category: "Plugin", - slash: { - name: "smoke", - }, - onSelect: () => { - api.route.navigate(route.modal, { source: "command" }) - }, - }, - { - title: `${input.label} screen`, - value: "plugin.smoke.screen", - keybind: keys.get("screen"), - category: "Plugin", - slash: { - name: "smoke-screen", + api.keymap.registerLayer({ + commands: [ + { + name: command.modal, + title: `${input.label} modal`, + category: "Plugin", + namespace: "palette", + slashName: "smoke", + run() { + api.route.navigate(route.modal, { source: "command" }) + }, }, - onSelect: () => { - api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 }) + { + name: command.screen, + title: `${input.label} screen`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-screen", + run() { + api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 }) + }, }, - }, - { - title: `${input.label} alert dialog`, - value: "plugin.smoke.alert", - category: "Plugin", - slash: { - name: "smoke-alert", - }, - onSelect: () => { - warn(api, route, current(api, route)) - }, - }, - { - title: `${input.label} confirm dialog`, - value: "plugin.smoke.confirm", - category: "Plugin", - slash: { - name: "smoke-confirm", + { + name: command.alert, + title: `${input.label} alert dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-alert", + run() { + warn(api, route, current(api, route)) + }, }, - onSelect: () => { - check(api, route, current(api, route)) + { + name: command.confirm, + title: `${input.label} confirm dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-confirm", + run() { + check(api, route, current(api, route)) + }, }, - }, - { - title: `${input.label} prompt dialog`, - value: "plugin.smoke.prompt", - category: "Plugin", - slash: { - name: "smoke-prompt", + { + name: command.prompt, + title: `${input.label} prompt dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-prompt", + run() { + entry(api, route, current(api, route)) + }, }, - onSelect: () => { - entry(api, route, current(api, route)) + { + name: command.select, + title: `${input.label} select dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-select", + run() { + picker(api, route, current(api, route)) + }, }, - }, - { - title: `${input.label} select dialog`, - value: "plugin.smoke.select", - category: "Plugin", - slash: { - name: "smoke-select", + { + name: command.host, + title: `${input.label} host overlay`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-host", + run() { + host(api, input, tone(api)) + }, }, - onSelect: () => { - picker(api, route, current(api, route)) + { + name: command.home, + title: `${input.label} go home`, + category: "Plugin", + namespace: "palette", + enabled: () => api.route.current.name !== "home", + run() { + api.route.navigate("home") + }, }, - }, - { - title: `${input.label} host overlay`, - value: "plugin.smoke.host", - category: "Plugin", - slash: { - name: "smoke-host", + { + name: command.toast, + title: `${input.label} toast`, + category: "Plugin", + namespace: "palette", + run() { + api.ui.toast({ + variant: "info", + title: "Smoke", + message: "Plugin toast works", + duration: 2000, + }) + }, }, - onSelect: () => { - host(api, input, tone(api)) - }, - }, - { - title: `${input.label} go home`, - value: "plugin.smoke.home", - category: "Plugin", - enabled: api.route.current.name !== "home", - onSelect: () => { - api.route.navigate("home") - }, - }, - { - title: `${input.label} toast`, - value: "plugin.smoke.toast", - category: "Plugin", - onSelect: () => { - api.ui.toast({ - variant: "info", - title: "Smoke", - message: "Plugin toast works", - duration: 2000, - }) - }, - }, - ]) + ], + bindings: keys.sections.global, + }) } const tui: TuiPlugin = async (api, options, meta) => { - if (options?.enabled === false) return + const input = options as SmokeOptions | undefined + if (input?.enabled === false) return await api.theme.install("./smoke-theme.json") api.theme.set("smoke-theme") - const value = cfg(options ?? undefined) + const value = cfg(input) const route = names(value) - const keys = api.keybind.create(bind, value.keybinds) + const keys = createKeys(value.keymap) const fx = new VignetteEffect(value.vignette) const post = fx.apply.bind(fx) api.renderer.addPostProcessFn(post) diff --git a/.opencode/tui.json b/.opencode/tui.json index 1eee01b30220..e795209d9c65 100644 --- a/.opencode/tui.json +++ b/.opencode/tui.json @@ -6,11 +6,20 @@ { "enabled": false, "label": "workspace", - "keybinds": { - "modal": "ctrl+alt+m", - "screen": "ctrl+alt+o", - "home": "escape,ctrl+shift+h", - "dialog_close": "escape,q" + "keymap": { + "sections": { + "global": { + "plugin.smoke.modal": "ctrl+alt+m", + "plugin.smoke.screen": "ctrl+alt+o" + }, + "screen": { + "plugin.smoke.screen.home": "escape,ctrl+shift+h", + "plugin.smoke.screen.modal": "ctrl+alt+m" + }, + "dialog": { + "plugin.smoke.dialog.close": "escape,q" + } + } } } ] diff --git a/bun.lock b/bun.lock index 4e7057630669..2f21ed7d542f 100644 --- a/bun.lock +++ b/bun.lock @@ -379,6 +379,7 @@ "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", @@ -477,6 +478,7 @@ }, "devDependencies": { "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", @@ -484,11 +486,13 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.2", - "@opentui/solid": ">=0.2.2", + "@opentui/core": ">=0.2.4", + "@opentui/keymap": ">=0.2.4", + "@opentui/solid": ">=0.2.4", }, "optionalPeers": [ "@opentui/core", + "@opentui/keymap", "@opentui/solid", ], }, @@ -663,8 +667,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.2", - "@opentui/solid": "0.2.2", + "@opentui/core": "0.2.4", + "@opentui/keymap": "0.2.4", + "@opentui/solid": "0.2.4", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1589,21 +1594,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="], + "@opentui/core": ["@opentui/core@0.2.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.4", "@opentui/core-darwin-x64": "0.2.4", "@opentui/core-linux-arm64": "0.2.4", "@opentui/core-linux-x64": "0.2.4", "@opentui/core-win32-arm64": "0.2.4", "@opentui/core-win32-x64": "0.2.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6xRdxmSgCFsEIwUwv7Pr+XKS1gBOwYF0tS/DE4KxTNzuH39VQDot7blzm8UKl6okdurFAxkt2+1HJJStl+rICw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2GlEndoBQkA8qSxr9RQEOgprdheCBRZvbUIfui5AUUmREZfgIQP+w399cJwmhlwSoNVNtfzLQGHxUFGfPhzMrQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-5V/Fcwg1rYTeKH9/bj3pMG1837APMIaYPfNWz0Ha87m5wcUKjodQOMf/xTzz2NJuLE/m5rydSuvY9uh5EJx3QA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-yQoWlEH9sQ/OfpCYcoGxzV4mbPaMCbYIl3thD/vcvIqOSa386vjKZFdTeU5Lu1PHiz3MMU/8Fzej5pJ6ZFJFZA=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-OxhBUqFHNcIhiKzon3+HYo4T2Me0ooRJQJW8bDVfEc7gtcWGm3ix/+8o7feQAdcbQW63Chwzed2glvbrOrDCzg=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-UWECe8y9vzdcotRf1ljOvWFOjNiGqAnmeC/SyylhvvoNhh/TqvbZawHVFifw/GZUlBEBdZcXF0f3XGGsW4M5Nw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-P7yBRAWwiMZXDnVzzXCNksjkCzAvQ5b2X32JzL3gACf+yobs4bvA9F47Ud+XgKZiqILf/c7fa0cud2E0cfWxlA=="], - "@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="], + "@opentui/keymap": ["@opentui/keymap@0.2.4", "", { "dependencies": { "@opentui/core": "0.2.4" }, "peerDependencies": { "@opentui/react": "0.2.4", "@opentui/solid": "0.2.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-TOEkPKlcfhP2Xqo6xtU6zYTsvwHv4syqZ2v89hjNLCuY476j4UMTxeszJCfuSABplwm0OfllV94rVFl0BheWVw=="], + + "@opentui/solid": ["@opentui/solid@0.2.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-EKuUwcTElRW0jKrXNJrTiWVOBvok78wk8viVwsyy3h8sD9qcLyCQA+XGmOINapADNGvgBohW9dKOSTFsqjZlvA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/package.json b/package.json index 9d9207c5ea3e..15d96e131c66 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", "typecheck": "bun turbo typecheck", + "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/opencode fix-node-pty", "prepare": "husky", "random": "echo 'Random script'", @@ -34,8 +35,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.2", - "@opentui/solid": "0.2.2", + "@opentui/core": "0.2.4", + "@opentui/keymap": "0.2.4", + "@opentui/solid": "0.2.4", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 985e2c747fc7..06c1ac73710a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -11,7 +11,6 @@ "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "build": "bun run script/build.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", - "upgrade-opentui": "bun run script/upgrade-opentui.ts", "dev": "bun run --conditions=browser ./src/index.ts", "dev:temporary": "bun run --conditions=browser ./src/temporary.ts", "db": "bun drizzle-kit" @@ -125,6 +124,7 @@ "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index 7a7cc4a7306f..b1a587075eb7 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -59,5 +59,5 @@ await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2)) if (tuiFile) { console.log(tuiFile) - await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.Info), null, 2)) + await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.JsonSchemaInfo), null, 2)) } diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 943125b79c61..1a337a60c836 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -53,13 +53,21 @@ Minimal module shape: import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" const tui: TuiPlugin = async (api, options, meta) => { - api.command.register(() => [ - { - title: "Demo", - value: "demo.open", - onSelect: () => api.route.navigate("demo"), - }, - ]) + api.keymap.registerLayer({ + commands: [ + { + name: "demo.open", + title: "Demo", + category: "Plugin", + namespace: "palette", + slashName: "demo", + run() { + api.route.navigate("demo") + }, + }, + ], + bindings: [{ key: "ctrl+shift+m", cmd: "demo.open", desc: "Open demo" }], + }) api.route.register([ { @@ -194,10 +202,10 @@ That is what makes local config-scoped plugins able to import `@opencode-ai/plug Top-level API groups exposed to `tui(api, options, meta)`: - `api.app.version` -- `api.command.register(cb)` / `api.command.trigger(value)` / `api.command.show()` +- `api.keys.formatSequence(parts)`, `formatBindings(bindings)` +- `api.keymap` - `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current` - `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Slot`, `Prompt`, `ui.toast`, `ui.dialog` -- `api.keybind.match`, `print`, `create` - `api.tuiConfig` - `api.kv.get`, `set`, `ready` - `api.state` @@ -209,23 +217,23 @@ Top-level API groups exposed to `tui(api, options, meta)`: - `api.plugins.list()`, `activate(id)`, `deactivate(id)`, `add(spec)`, `install(spec, options?)` - `api.lifecycle.signal`, `api.lifecycle.onDispose(fn)` -### Commands +### Keymap -`api.command.register` returns an unregister function. Command rows support: +- `api.keymap` exposes the raw `Keymap` instance from the host. +- The host already installs the default OpenTUI bundle (`default keys`, metadata fields, and enabled fields) plus OpenCode's comma bindings, leader token, base layout fallback, pending-sequence helpers, and managed textarea layer. +- Register commands with `api.keymap.registerLayer({ commands: [...] })`. +- Register key bindings with `bindings: [{ key, cmd, desc }]` in the same layer or a separate layer. +- Use `api.keymap.acquireResource(...)` for shared plugin addon setup that should ref-count against the host keymap. +- To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command. +- Use `api.keymap.dispatchCommand(name)` for user-style execution semantics and `api.keymap.runCommand(name)` only for forced programmatic execution. +- Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself. -- `title`, `value` -- `description`, `category` -- `keybind` -- `suggested`, `hidden`, `enabled` -- `slash: { name, aliases? }` -- `onSelect` +### Keys -Command behavior: - -- Registrations are reactive. -- Later registrations win for duplicate `value` and for keybind handling. -- Hidden commands are removed from the command dialog and slash list, but still respond to keybinds and `command.trigger(value)` if `enabled !== false`. -- `api.command.show()` opens the host command dialog directly. +- `api.keys` exposes host-formatted shortcut display helpers for plugin UI. +- `formatSequence(parts)` formats parsed key sequence parts using the host's display policy. +- `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show. +- For generic config-to-bindings helpers, import `resolveBindingSections` from `@opencode-ai/plugin/tui`. ### Routes @@ -252,13 +260,6 @@ Command behavior: - `setSize("medium" | "large" | "xlarge")` - readonly `size`, `depth`, `open` -### Keybinds - -- `api.keybind.match(key, evt)` and `print(key)` use the host keybind parser/printer. -- `api.keybind.create(defaults, overrides?)` builds a plugin-local keybind set. -- Only missing, blank, or non-string overrides are ignored. Key syntax is not validated. -- Returned keybind set exposes `all`, `get(name)`, `match(name, evt)`, `print(name)`. - ### KV, state, client, events - `api.kv` is the shared app KV store backed by `state/kv.json`. It is not plugin-namespaced. diff --git a/packages/opencode/specs/v2/keymappings.md b/packages/opencode/specs/v2/keymappings.md index 5b23db795493..30a298eee457 100644 --- a/packages/opencode/specs/v2/keymappings.md +++ b/packages/opencode/specs/v2/keymappings.md @@ -8,3 +8,29 @@ Make it `keymappings`, closer to neovim. Can be layered like `abc`. Comm _Why_ Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right. + +## OpenTUI Keymap Migration + +The v2 TUI uses `@opentui/keymap` as the key/cmd engine. The remaining legacy compatibility is config-only and exists to migrate users from `keybinds` to `keymap`: + +- `packages/opencode/src/config/keybinds.ts`: old `keybinds` schema, defaults, and legacy key names. +- `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`: transforms parsed legacy `keybinds` into OpenTUI `keymap` sections. +- `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`: migrates legacy TUI keys from `opencode.json` into `tui.json`, including `theme`, `keybinds`, and nested `tui`. +- `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`: still accepts deprecated `keybinds` via `KeybindOverride` and marks it as deprecated. This file also contains the new `keymap` config schema. +- `packages/opencode/src/cli/cmd/tui/config/tui.ts`: parses legacy `keybinds`, applies the Windows `terminal_suspend`/`input_undo` adjustment, and uses `LegacyKeymapTransform.create(...)` as the fallback when no `keymap` section is configured. +- `packages/plugin/src/tui.ts`: plugin-facing `tuiConfig` still includes `keybinds` through `PluginConfig`; this should be removed when the public plugin API no longer exposes legacy config. + +The transform must stay while users are migrating. It lets users upgrade without first rewriting their existing `keybinds` config. If `keymap` is configured, `keybinds` are ignored for keymap resolution. If `keymap` is missing, `legacy-keymap-transform.ts` turns legacy `keybinds` into the resolved `keymap` consumed by OpenTUI. + +## Removing Legacy Later + +When switching fully to the new config style, remove legacy support with these exact changes: + +- Delete `packages/opencode/src/config/keybinds.ts`. +- Delete `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`. +- Delete `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`. +- In `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`, remove the `ConfigKeybinds` import, remove `KeybindOverride`, and delete the deprecated `keybinds` field from `TuiInfo`. +- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `migrateTuiConfig(...)`, remove `ConfigKeybinds`, remove the Windows legacy keybind adjustment, remove `LegacyKeymapTransform.create(...)`, and require/default `keymap` through the new config path instead. +- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `keybinds` from `Resolved`; resolved TUI config should expose `keymap` only. +- In `packages/plugin/src/tui.ts`, remove `keybinds` from plugin-facing `TuiConfigView`. +- Remove or rewrite tests that write or assert `keybinds`, especially in `packages/opencode/test/config/tui.test.ts`, `packages/opencode/test/fixture/tui-runtime.ts`, and TUI plugin loader tests. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ea742f699708..a8cc7946a9be 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,4 +1,5 @@ -import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import * as Clipboard from "@tui/util/clipboard" import * as Selection from "@tui/util/selection" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" @@ -11,6 +12,7 @@ import { ErrorBoundary, createSignal, onMount, + onCleanup, batch, Show, on, @@ -36,11 +38,9 @@ import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" -import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogConsoleOrg } from "@tui/component/dialog-console-org" -import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" import { Session } from "@tui/routes/session" @@ -60,15 +60,17 @@ import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfig } from "@/cli/cmd/tui/config/tui" -import { createTuiApi } from "@/cli/cmd/tui/plugin/api" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" +import { createTuiApi } from "@/cli/cmd/tui/plugin/api" import type { RouteMap } from "@/cli/cmd/tui/plugin/api" import { FormatError, FormatUnknownError } from "@/cli/error" +import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette" +import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap" import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" -function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { +function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig { const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true) return { @@ -111,7 +113,7 @@ function errorMessage(error: unknown) { export function tui(input: { url: string args: Args - config: TuiConfig.Info + config: TuiConfig.Resolved onSnapshot?: () => Promise directory?: string fetch?: typeof fetch @@ -130,6 +132,7 @@ export function tui(input: { } const onBeforeExit = async () => { + offKeymap() await TuiPluginRuntime.dispose() } @@ -138,6 +141,9 @@ export function tui(input: { void renderer.getPalette({ size: 16 }).catch(() => undefined) const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" + const keymap = createDefaultOpenTuiKeymap(renderer) + const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config) + await render(() => { return ( )} > - - - - - - - - - - - - - + + + + + + + + + + + + + - + @@ -185,22 +191,22 @@ export function tui(input: { - + - - - - - - - - - - - - - + + + + + + + + + + + + + ) }, renderer) @@ -209,14 +215,17 @@ export function tui(input: { function App(props: { onSnapshot?: () => Promise }) { const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() const dialog = useDialog() const local = useLocal() const kv = useKV() - const command = useCommandDialog() - const keybind = useKeybind() + const command = useCommandPalette() + const keymap = useOpencodeKeymap() const event = useEvent() const sdk = useSDK() const toast = useToast() @@ -233,10 +242,9 @@ function App(props: { onSnapshot?: () => Promise }) { } const api = createTuiApi({ - command, tuiConfig, dialog, - keybind, + keymap, kv, route, routes, @@ -260,40 +268,16 @@ function App(props: { onSnapshot?: () => Promise }) { setReady(true) }) - useKeyboard((evt) => { - if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return - const sel = renderer.getSelection() - if (!sel) return - - // Windows Terminal-like behavior: - // - Ctrl+C copies and dismisses selection - // - Esc dismisses selection - // - Most other key input dismisses selection and is passed through - if (evt.ctrl && evt.name === "c") { - if (!Selection.copy(renderer, toast)) { - renderer.clearSelection() - return - } - - evt.preventDefault() - evt.stopPropagation() - return - } - - if (evt.name === "escape") { - renderer.clearSelection() - evt.preventDefault() - evt.stopPropagation() - return - } - - const focus = renderer.currentFocusedRenderable - if (focus?.hasSelection() && sel.selectedRenderables.includes(focus)) { - return - } - - renderer.clearSelection() - }) + // Let selection copy/dismiss win ahead of normal bindings when the feature flag is on. + const offSelectionKeys = keymap.intercept( + "key", + ({ event }) => { + if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + Selection.handleSelectionKey(renderer, toast, event) + }, + { priority: 1 }, + ) + onCleanup(offSelectionKeys) // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { @@ -410,379 +394,365 @@ function App(props: { onSnapshot?: () => Promise }) { ) const connected = useConnected() - command.register(() => [ - { - title: "Switch session", - value: "session.list", - keybind: "session_list", - category: "Session", - suggested: sync.data.session.length > 0, - slash: { - name: "sessions", - aliases: ["resume", "continue"], - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - { - title: "New session", - suggested: route.data.type === "session", - value: "session.new", - keybind: "session_new", - category: "Session", - slash: { - name: "new", - aliases: ["clear"], - }, - onSelect: () => { - route.navigate({ - type: "home", - }) - dialog.clear() - }, - }, - { - title: "Switch model", - value: "model.list", - keybind: "model_list", - suggested: true, - category: "Agent", - slash: { - name: "models", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - { - title: "Model cycle", - value: "model.cycle_recent", - keybind: "model_cycle_recent", - category: "Agent", - hidden: true, - onSelect: () => { - local.model.cycle(1) - }, - }, - { - title: "Model cycle reverse", - value: "model.cycle_recent_reverse", - keybind: "model_cycle_recent_reverse", - category: "Agent", - hidden: true, - onSelect: () => { - local.model.cycle(-1) - }, - }, - { - title: "Favorite cycle", - value: "model.cycle_favorite", - keybind: "model_cycle_favorite", - category: "Agent", - hidden: true, - onSelect: () => { - local.model.cycleFavorite(1) - }, - }, - { - title: "Favorite cycle reverse", - value: "model.cycle_favorite_reverse", - keybind: "model_cycle_favorite_reverse", - category: "Agent", - hidden: true, - onSelect: () => { - local.model.cycleFavorite(-1) - }, - }, - { - title: "Switch agent", - value: "agent.list", - keybind: "agent_list", - category: "Agent", - slash: { - name: "agents", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - { - title: "Toggle MCPs", - value: "mcp.list", - category: "Agent", - slash: { - name: "mcps", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - { - title: "Agent cycle", - value: "agent.cycle", - keybind: "agent_cycle", - category: "Agent", - hidden: true, - onSelect: () => { - local.agent.move(1) - }, - }, - { - title: "Variant cycle", - value: "variant.cycle", - keybind: "variant_cycle", - category: "Agent", - onSelect: () => { - local.model.variant.cycle() - }, - }, - { - title: "Switch model variant", - value: "variant.list", - keybind: "variant_list", - category: "Agent", - hidden: local.model.variant.list().length === 0, - slash: { - name: "variants", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - { - title: "Agent cycle reverse", - value: "agent.cycle.reverse", - keybind: "agent_cycle_reverse", - category: "Agent", - hidden: true, - onSelect: () => { - local.agent.move(-1) - }, - }, - { - title: "Connect provider", - value: "provider.connect", - suggested: !connected(), - slash: { - name: "connect", - }, - onSelect: () => { - dialog.replace(() => ) - }, - category: "Provider", - }, - ...(sync.data.console_state.switchableOrgCount > 1 - ? [ - { - title: "Switch org", - value: "console.org.switch", - suggested: Boolean(sync.data.console_state.activeOrgName), - slash: { - name: "org", - aliases: ["orgs", "switch-org"], - }, - onSelect: () => { - dialog.replace(() => ) + const appCommands = createMemo(() => + [ + { + name: "command.palette.show", + title: "Show command palette", + hidden: true, + run: () => { + command.show() + }, + }, + { + name: "session.list", + title: "Switch session", + category: "Session", + suggested: sync.data.session.length > 0, + slashName: "sessions", + slashAliases: ["resume", "continue"], + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "session.new", + title: "New session", + suggested: route.data.type === "session", + category: "Session", + slashName: "new", + slashAliases: ["clear"], + run: () => { + route.navigate({ + type: "home", + }) + dialog.clear() + }, + }, + { + name: "model.list", + title: "Switch model", + suggested: true, + category: "Agent", + slashName: "models", + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "model.cycle_recent", + title: "Model cycle", + category: "Agent", + hidden: true, + run: () => { + local.model.cycle(1) + }, + }, + { + name: "model.cycle_recent_reverse", + title: "Model cycle reverse", + category: "Agent", + hidden: true, + run: () => { + local.model.cycle(-1) + }, + }, + { + name: "model.cycle_favorite", + title: "Favorite cycle", + category: "Agent", + hidden: true, + run: () => { + local.model.cycleFavorite(1) + }, + }, + { + name: "model.cycle_favorite_reverse", + title: "Favorite cycle reverse", + category: "Agent", + hidden: true, + run: () => { + local.model.cycleFavorite(-1) + }, + }, + { + name: "agent.list", + title: "Switch agent", + category: "Agent", + slashName: "agents", + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "mcp.list", + title: "Toggle MCPs", + category: "Agent", + slashName: "mcps", + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "agent.cycle", + title: "Agent cycle", + category: "Agent", + hidden: true, + run: () => { + local.agent.move(1) + }, + }, + { + name: "variant.cycle", + title: "Variant cycle", + category: "Agent", + run: () => { + local.model.variant.cycle() + }, + }, + { + name: "variant.list", + title: "Switch model variant", + category: "Agent", + hidden: local.model.variant.list().length === 0, + slashName: "variants", + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "agent.cycle.reverse", + title: "Agent cycle reverse", + category: "Agent", + hidden: true, + run: () => { + local.agent.move(-1) + }, + }, + { + name: "provider.connect", + title: "Connect provider", + suggested: !connected(), + slashName: "connect", + run: () => { + dialog.replace(() => ) + }, + category: "Provider", + }, + ...(sync.data.console_state.switchableOrgCount > 1 + ? [ + { + name: "console.org.switch", + title: "Switch org", + suggested: Boolean(sync.data.console_state.activeOrgName), + slashName: "org", + slashAliases: ["orgs", "switch-org"], + run: () => { + dialog.replace(() => ) + }, + category: "Provider", }, - category: "Provider", - }, - ] - : []), - { - title: "View status", - keybind: "status_view", - value: "opencode.status", - slash: { - name: "status", - }, - onSelect: () => { - dialog.replace(() => ) - }, - category: "System", - }, - { - title: "Switch theme", - value: "theme.switch", - keybind: "theme_list", - slash: { - name: "themes", - }, - onSelect: () => { - dialog.replace(() => ) - }, - category: "System", - }, - { - title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode", - value: "theme.switch_mode", - onSelect: (dialog) => { - setMode(mode() === "dark" ? "light" : "dark") - dialog.clear() - }, - category: "System", - }, - { - title: locked() ? "Unlock theme mode" : "Lock theme mode", - value: "theme.mode.lock", - onSelect: (dialog) => { - if (locked()) unlock() - else lock() - dialog.clear() - }, - category: "System", - }, - { - title: "Help", - value: "help.show", - slash: { - name: "help", - }, - onSelect: () => { - dialog.replace(() => ) - }, - category: "System", - }, - { - title: "Open docs", - value: "docs.open", - onSelect: () => { - open("https://opencode.ai/docs").catch(() => {}) - dialog.clear() - }, - category: "System", - }, - { - title: "Exit the app", - value: "app.exit", - slash: { - name: "exit", - aliases: ["quit", "q"], - }, - onSelect: () => exit(), - category: "System", - }, - { - title: "Toggle debug panel", - category: "System", - value: "app.debug", - onSelect: (dialog) => { - renderer.toggleDebugOverlay() - dialog.clear() - }, - }, - { - title: "Toggle console", - category: "System", - value: "app.console", - onSelect: (dialog) => { - renderer.console.toggle() - dialog.clear() - }, - }, - { - title: "Write heap snapshot", - category: "System", - value: "app.heap_snapshot", - onSelect: async (dialog) => { - const files = await props.onSnapshot?.() - toast.show({ - variant: "info", - message: `Heap snapshot written to ${files?.join(", ")}`, - duration: 5000, - }) - dialog.clear() - }, - }, - { - title: "Suspend terminal", - value: "terminal.suspend", - keybind: "terminal_suspend", - category: "System", - hidden: true, - enabled: tuiConfig.keybinds?.terminal_suspend !== "none", - onSelect: () => { - process.once("SIGCONT", () => { - renderer.resume() - }) + ] + : []), + { + name: "opencode.status", + title: "View status", + slashName: "status", + run: () => { + dialog.replace(() => ) + }, + category: "System", + }, + { + name: "theme.switch", + title: "Switch theme", + slashName: "themes", + run: () => { + dialog.replace(() => ) + }, + category: "System", + }, + { + name: "theme.switch_mode", + title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode", + run: () => { + setMode(mode() === "dark" ? "light" : "dark") + dialog.clear() + }, + category: "System", + }, + { + name: "theme.mode.lock", + title: locked() ? "Unlock theme mode" : "Lock theme mode", + run: () => { + if (locked()) unlock() + else lock() + dialog.clear() + }, + category: "System", + }, + { + name: "help.show", + title: "Help", + slashName: "help", + run: () => { + dialog.replace(() => ) + }, + category: "System", + }, + { + name: "docs.open", + title: "Open docs", + run: () => { + open("https://opencode.ai/docs").catch(() => {}) + dialog.clear() + }, + category: "System", + }, + { + name: "app.exit", + title: "Exit the app", + slashName: "exit", + slashAliases: ["quit", "q"], + enabled: () => { + const current = promptRef.current + if (!current?.focused) return true + return current.current.input === "" + }, + run: () => exit(), + category: "System", + }, + { + name: "app.debug", + title: "Toggle debug panel", + category: "System", + run: () => { + renderer.toggleDebugOverlay() + dialog.clear() + }, + }, + { + name: "app.console", + title: "Toggle console", + category: "System", + run: () => { + renderer.console.toggle() + dialog.clear() + }, + }, + { + name: "app.heap_snapshot", + title: "Write heap snapshot", + category: "System", + run: async () => { + const files = await props.onSnapshot?.() + toast.show({ + variant: "info", + message: `Heap snapshot written to ${files?.join(", ")}`, + duration: 5000, + }) + dialog.clear() + }, + }, + { + name: "terminal.suspend", + title: "Suspend terminal", + category: "System", + hidden: true, + enabled: process.platform !== "win32", + run: () => { + process.once("SIGCONT", () => { + renderer.resume() + }) - renderer.suspend() - // pid=0 means send the signal to all processes in the process group - process.kill(0, "SIGTSTP") - }, - }, - { - title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title", - value: "terminal.title.toggle", - keybind: "terminal_title_toggle", - category: "System", - onSelect: (dialog) => { - setTerminalTitleEnabled((prev) => { - const next = !prev - kv.set("terminal_title_enabled", next) - if (!next) renderer.setTerminalTitle("") - return next - }) - dialog.clear() - }, - }, - { - title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations", - value: "app.toggle.animations", - category: "System", - onSelect: (dialog) => { - kv.set("animations_enabled", !kv.get("animations_enabled", true)) - dialog.clear() - }, - }, - { - title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context", - value: "app.toggle.file_context", - category: "System", - onSelect: (dialog) => { - kv.set("file_context_enabled", !kv.get("file_context_enabled", true)) - dialog.clear() - }, - }, - { - title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary", - value: "app.toggle.paste_summary", - category: "System", - onSelect: (dialog) => { - setPasteSummaryEnabled((prev) => { - const next = !prev - kv.set("paste_summary_enabled", next) - return next - }) - dialog.clear() - }, - }, - { - title: kv.get("session_directory_filter_enabled", true) - ? "Disable session directory filtering" - : "Enable session directory filtering", - value: "app.toggle.session_directory_filter", - category: "System", - onSelect: async (dialog) => { - kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true)) - await sync.session.refresh() - dialog.clear() - }, - }, - { - title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", - value: "app.toggle.diffwrap", - category: "System", - onSelect: (dialog) => { - const current = kv.get("diff_wrap_mode", "word") - kv.set("diff_wrap_mode", current === "word" ? "none" : "word") - dialog.clear() - }, - }, - ]) + renderer.suspend() + process.kill(0, "SIGTSTP") + }, + }, + { + name: "terminal.title.toggle", + title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title", + category: "System", + run: () => { + setTerminalTitleEnabled((prev) => { + const next = !prev + kv.set("terminal_title_enabled", next) + if (!next) renderer.setTerminalTitle("") + return next + }) + dialog.clear() + }, + }, + { + name: "app.toggle.animations", + title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations", + category: "System", + run: () => { + kv.set("animations_enabled", !kv.get("animations_enabled", true)) + dialog.clear() + }, + }, + { + name: "app.toggle.file_context", + title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context", + category: "System", + run: () => { + kv.set("file_context_enabled", !kv.get("file_context_enabled", true)) + dialog.clear() + }, + }, + { + name: "app.toggle.diffwrap", + title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", + category: "System", + run: () => { + const current = kv.get("diff_wrap_mode", "word") + kv.set("diff_wrap_mode", current === "word" ? "none" : "word") + dialog.clear() + }, + }, + { + name: "app.toggle.paste_summary", + title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary", + category: "System", + run: () => { + setPasteSummaryEnabled((prev) => { + const next = !prev + kv.set("paste_summary_enabled", next) + return next + }) + dialog.clear() + }, + }, + { + name: "app.toggle.session_directory_filter", + title: kv.get("session_directory_filter_enabled", true) + ? "Disable session directory filtering" + : "Enable session directory filtering", + category: "System", + run: async () => { + kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true)) + await sync.session.refresh() + dialog.clear() + }, + }, + ].map((command) => ({ + namespace: "palette", + ...command, + })), + ) + + useBindings(() => ({ + commands: appCommands(), + })) + + useBindings(() => ({ + enabled: command.matcher, + bindings: sections.global, + })) event.on(TuiEvent.CommandExecute.type, (evt) => { - command.trigger(evt.properties.command) + command.run(evt.properties.command) }) event.on(TuiEvent.ToastShow.type, (evt) => { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx deleted file mode 100644 index 49bf42c63e85..000000000000 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select" -import { - createContext, - createMemo, - createSignal, - getOwner, - onCleanup, - runWithOwner, - useContext, - type Accessor, - type ParentProps, -} from "solid-js" -import { useKeyboard } from "@opentui/solid" -import { useKeybind } from "@tui/context/keybind" - -type Context = ReturnType -const ctx = createContext() - -export type Slash = { - name: string - aliases?: string[] -} - -export type CommandOption = DialogSelectOption & { - keybind?: string - suggested?: boolean - slash?: Slash - hidden?: boolean - enabled?: boolean -} - -function init() { - const root = getOwner() - const [registrations, setRegistrations] = createSignal[]>([]) - const [suspendCount, setSuspendCount] = createSignal(0) - const dialog = useDialog() - const keybind = useKeybind() - - const entries = createMemo(() => { - const all = registrations().flatMap((x) => x()) - return all.map((x) => ({ - ...x, - footer: x.keybind ? keybind.print(x.keybind) : undefined, - })) - }) - - const isEnabled = (option: CommandOption) => option.enabled !== false - const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden - - const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option))) - const suggestedOptions = createMemo(() => - visibleOptions() - .filter((option) => option.suggested) - .map((option) => ({ - ...option, - value: `suggested:${option.value}`, - category: "Suggested", - })), - ) - const suspended = () => suspendCount() > 0 - - useKeyboard((evt) => { - if (suspended()) return - if (dialog.stack.length > 0) return - if (evt.defaultPrevented) return - for (const option of entries()) { - if (!isEnabled(option)) continue - if (option.keybind && keybind.match(option.keybind, evt)) { - evt.preventDefault() - option.onSelect?.(dialog) - return - } - } - }) - - const result = { - trigger(name: string) { - for (const option of entries()) { - if (option.value === name) { - if (!isEnabled(option)) return - option.onSelect?.(dialog) - return - } - } - }, - slashes() { - return visibleOptions().flatMap((option) => { - const slash = option.slash - if (!slash) return [] - return { - display: "/" + slash.name, - description: option.description ?? option.title, - aliases: slash.aliases?.map((alias) => "/" + alias), - onSelect: () => result.trigger(option.value), - } - }) - }, - keybinds(enabled: boolean) { - setSuspendCount((count) => count + (enabled ? -1 : 1)) - }, - suspended, - show() { - dialog.replace(() => ) - }, - register(cb: () => CommandOption[]) { - const owner = getOwner() ?? root - if (!owner) return () => {} - - let list: Accessor | undefined - - // TUI plugins now register commands via an async store that runs outside an active reactive scope. - // runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly. - runWithOwner(owner, () => { - list = createMemo(cb) - const ref = list - if (!ref) return - setRegistrations((arr) => [ref, ...arr]) - onCleanup(() => { - setRegistrations((arr) => arr.filter((x) => x !== ref)) - }) - }) - - if (!list) return () => {} - let done = false - return () => { - if (done) return - done = true - const ref = list - if (!ref) return - setRegistrations((arr) => arr.filter((x) => x !== ref)) - } - }, - } - return result -} - -export function useCommandDialog() { - const value = useContext(ctx) - if (!value) { - throw new Error("useCommandDialog must be used within a CommandProvider") - } - return value -} - -export function CommandProvider(props: ParentProps) { - const value = init() - const dialog = useDialog() - const keybind = useKeybind() - - useKeyboard((evt) => { - if (value.suspended()) return - if (dialog.stack.length > 0) return - if (evt.defaultPrevented) return - if (keybind.match("command_list", evt)) { - evt.preventDefault() - value.show() - return - } - }) - - return {props.children} -} - -function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) { - let ref: DialogSelectRef - const list = () => { - if (ref?.filter) return props.options - return [...props.suggestedOptions, ...props.options] - } - return (ref = r)} title="Commands" options={list()} /> -} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx index b512f9021c37..3a1fd97b2cc4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx @@ -1,5 +1,4 @@ import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core" -import { useKeyboard } from "@opentui/solid" import open from "open" import { createSignal, onCleanup, onMount } from "solid-js" import { selectedForeground, useTheme } from "@tui/context/theme" @@ -7,6 +6,7 @@ import { useDialog, type DialogContext } from "@tui/ui/dialog" import { Link } from "@tui/ui/link" import { GoLogo } from "./logo" import { BgPulse, type BgPulseMask } from "./bg-pulse" +import { useBindings } from "../keymap" const GO_URL = "https://opencode.ai/go" const PAD_X = 3 @@ -71,18 +71,29 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync) }) - useKeyboard((evt) => { - if (evt.name === "left" || evt.name === "right" || evt.name === "tab") { - setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe")) - return - } - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - if (selected() === "subscribe") subscribe(props, dialog) - else dismiss(props, dialog) - } - }) + useBindings(() => ({ + bindings: [ + { + key: "left", + cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + }, + { + key: "right", + cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + }, + { + key: "tab", + cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + }, + { + key: "return", + cmd: () => { + if (selected() === "subscribe") subscribe(props, dialog) + else dismiss(props, dialog) + }, + }, + ], + })) return ( (content = item)}> diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index 173c5ff60cd6..faa26dc3a62f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -4,7 +4,6 @@ import { useSync } from "@tui/context/sync" import { map, pipe, entries, sortBy } from "remeda" import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select" import { useTheme } from "../context/theme" -import { Keybind } from "@/util/keybind" import { TextAttributes } from "@opentui/core" import { useSDK } from "@tui/context/sdk" @@ -45,9 +44,9 @@ export function DialogMcp() { ) }) - const keybinds = createMemo(() => [ + const actions = createMemo(() => [ { - keybind: Keybind.parse("space")[0], + command: "dialog.action.toggle", title: "toggle", onTrigger: async (option: DialogSelectOption) => { // Prevent toggling while an operation is already in progress @@ -77,7 +76,7 @@ export function DialogMcp() { ref={setRef} title="MCPs" options={options()} - keybind={keybinds()} + actions={actions()} onSelect={(_option) => { // Don't close on select, only on escape }} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 06723f3c2bd3..068c6a1e03ff 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -6,15 +6,15 @@ import { DialogSelect } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { DialogVariant } from "./dialog-variant" -import { useKeybind } from "../context/keybind" import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" +import { useTuiConfig } from "../context/tui-config" export function DialogModel(props: { providerID?: string }) { const local = useLocal() const sync = useSync() const dialog = useDialog() - const keybind = useKeybind() + const tuiConfig = useTuiConfig() const [query, setQuery] = createSignal("") const connected = useConnected() @@ -150,16 +150,16 @@ export function DialogModel(props: { providerID?: string }) { return ( [number]["value"]> options={options()} - keybind={[ + actions={[ { - keybind: keybind.all.model_provider_list?.[0], + command: "model.dialog.provider", title: connected() ? "Connect provider" : "View all providers", onTrigger() { dialog.replace(() => ) }, }, { - keybind: keybind.all.model_favorite_toggle?.[0], + command: "model.dialog.favorite", title: "Favorite", disabled: !connected(), onTrigger: (option) => { @@ -167,6 +167,7 @@ export function DialogModel(props: { providerID?: string }) { }, }, ]} + bindings={tuiConfig.keymap.sections.model} onFilter={setQuery} flat={true} skipFilter={true} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index e12492a2d035..db7cf1bb0a53 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -10,11 +10,11 @@ import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2" import { DialogModel } from "./dialog-model" -import { useKeyboard } from "@opentui/solid" import * as Clipboard from "@tui/util/clipboard" import { useToast } from "../ui/toast" import { isConsoleManagedProvider } from "@tui/util/provider-origin" import { useConnected } from "./use-connected" +import { useBindings } from "../keymap" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -239,14 +239,19 @@ function AutoMethod(props: AutoMethodProps) { const sync = useSync() const toast = useToast() - useKeyboard((evt) => { - if (evt.name === "c" && !evt.ctrl && !evt.meta) { - const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url - Clipboard.copy(code) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) - } - }) + useBindings(() => ({ + bindings: [ + { + key: "c", + cmd: () => { + const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url + Clipboard.copy(code) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + }, + }, + ], + })) onMount(async () => { const result = await sdk.client.provider.oauth.callback({ diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx index 3d3059d9534c..cdd50019e447 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx @@ -3,7 +3,7 @@ import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" -import { useKeyboard } from "@opentui/solid" +import { useBindings } from "../keymap" export function DialogSessionDeleteFailed(props: { session: string @@ -40,19 +40,15 @@ export function DialogSessionDeleteFailed(props: { if (!props.onDone) dialog.clear() } - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - void confirm() - } - if (evt.name === "left" || evt.name === "up") { - setStore("active", "delete") - } - if (evt.name === "right" || evt.name === "down") { - setStore("active", "restore") - } - }) + useBindings(() => ({ + bindings: [ + { key: "return", cmd: () => void confirm() }, + { key: "left", cmd: () => setStore("active", "delete") }, + { key: "up", cmd: () => setStore("active", "delete") }, + { key: "right", cmd: () => setStore("active", "restore") }, + { key: "down", cmd: () => setStore("active", "restore") }, + ], + })) return ( diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index e8dbaee3944b..6d3322151a57 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -5,7 +5,6 @@ import { useSync } from "@tui/context/sync" import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js" import { Locale } from "@/util/locale" import { useProject } from "@tui/context/project" -import { useKeybind } from "../context/keybind" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { Flag } from "@opencode-ai/core/flag/flag" @@ -17,18 +16,19 @@ import { Spinner } from "./spinner" import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" import { WorkspaceLabel } from "./workspace-label" +import { useCommandShortcut } from "../keymap" export function DialogSessionList() { const dialog = useDialog() const route = useRoute() const sync = useSync() const project = useProject() - const keybind = useKeybind() const { theme } = useTheme() const sdk = useSDK() const toast = useToast() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) + const deleteHint = useCommandShortcut("dialog.action.delete") const [searchResults, { refetch }] = createResource( () => ({ query: search(), filter: sync.session.query() }), @@ -156,7 +156,7 @@ export function DialogSessionList() { const status = sync.data.session_status?.[x.id] const isWorking = status?.type === "busy" return { - title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, + title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title, bg: isDeleting ? theme.error : undefined, value: x.id, category, @@ -187,9 +187,9 @@ export function DialogSessionList() { }) dialog.clear() }} - keybind={[ + actions={[ { - keybind: keybind.all.session_delete?.[0], + command: "dialog.action.delete", title: "delete", onTrigger: async (option) => { if (toDelete() === option.value) { @@ -237,7 +237,7 @@ export function DialogSessionList() { }, }, { - keybind: keybind.all.session_rename?.[0], + command: "dialog.action.rename", title: "rename", onTrigger: async (option) => { dialog.replace(() => ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx index e8664f6289bc..62843c2527ea 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx @@ -3,8 +3,8 @@ import { DialogSelect } from "@tui/ui/dialog-select" import { createMemo, createSignal } from "solid-js" import { Locale } from "@/util/locale" import { useTheme } from "../context/theme" -import { useKeybind } from "../context/keybind" import { usePromptStash, type StashEntry } from "./prompt/stash" +import { useCommandShortcut } from "../keymap" function getRelativeTime(timestamp: number): string { const now = Date.now() @@ -30,9 +30,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const dialog = useDialog() const stash = usePromptStash() const { theme } = useTheme() - const keybind = useKeybind() const [toDelete, setToDelete] = createSignal() + const deleteHint = useCommandShortcut("dialog.action.delete") const options = createMemo(() => { const entries = stash.list() @@ -42,7 +42,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const isDeleting = toDelete() === index const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1 return { - title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input), + title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input), bg: isDeleting ? theme.error : undefined, value: index, description: getRelativeTime(entry.timestamp), @@ -68,9 +68,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { } dialog.clear() }} - keybind={[ + actions={[ { - keybind: keybind.all.stash_delete?.[0], + command: "dialog.action.delete", title: "delete", onTrigger: (option) => { if (toDelete() === option.value) { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx index 7a217985348b..0da7394bc43e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx @@ -1,9 +1,9 @@ import { TextAttributes } from "@opentui/core" -import { useKeyboard } from "@opentui/solid" import { createStore } from "solid-js/store" import { For } from "solid-js" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" +import { useBindings } from "../keymap" export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise }) { const dialog = useDialog() @@ -23,25 +23,13 @@ export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | if (result === false) return } - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - void confirm() - return - } - if (evt.name === "left") { - evt.preventDefault() - evt.stopPropagation() - setStore("active", "cancel") - return - } - if (evt.name === "right") { - evt.preventDefault() - evt.stopPropagation() - setStore("active", "restore") - } - }) + useBindings(() => ({ + bindings: [ + { key: "return", cmd: () => void confirm() }, + { key: "left", cmd: () => setStore("active", "cancel") }, + { key: "right", cmd: () => setStore("active", "restore") }, + ], + })) return ( diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 47bb162cb4bc..7a2548704db9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -1,4 +1,4 @@ -import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core" +import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core" import { pathToFileURL } from "bun" import fuzzysort from "fuzzysort" import path from "path" @@ -12,11 +12,12 @@ import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" import { useTheme, selectedForeground } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" -import { useCommandDialog } from "@tui/component/dialog-command" +import { useCommandPalette } from "../../context/command-palette" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" +import { useBindings } from "../../keymap" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -52,7 +53,6 @@ function extractLineRange(input: string) { export type AutocompleteRef = { onInput: (value: string) => void - onKeyDown: (e: KeyEvent) => void visible: false | "@" | "/" } @@ -82,12 +82,14 @@ export function Autocomplete(props: { const editor = useEditorContext() const sdk = useSDK() const sync = useSync() - const command = useCommandDialog() + const command = useCommandPalette() const { theme } = useTheme() const dimensions = useTerminalDimensions() const frecency = useFrecency() const tuiConfig = useTuiConfig() - + const { + keymap: { sections }, + } = tuiConfig const [store, setStore] = createStore({ index: 0, selected: 0, @@ -282,7 +284,7 @@ export function Autocomplete(props: { const { filename, part } = createFilePart(item, lineRange) const index = store.visible === "@" ? store.index : props.input().cursorOffset - command.keybinds(true) + command.suspend(false) setStore("visible", false) setStore("index", index) insertPart(filename, part) @@ -520,8 +522,54 @@ export function Autocomplete(props: { setStore("selected", 0) } + useBindings(() => ({ + target: props.input, + enabled: () => Boolean(store.visible), + commands: [ + { + name: "prompt.autocomplete.prev", + run() { + setStore("input", "keyboard") + move(-1) + }, + }, + { + name: "prompt.autocomplete.next", + run() { + setStore("input", "keyboard") + move(1) + }, + }, + { + name: "prompt.autocomplete.hide", + run() { + hide() + }, + }, + { + name: "prompt.autocomplete.select", + run() { + select() + }, + }, + { + name: "prompt.autocomplete.complete", + run() { + const selected = options()[store.selected] + if (selected?.isDirectory) { + expandDirectory() + return + } + + select() + }, + }, + ], + bindings: sections.autocomplete, + })) + function show(mode: "@" | "/") { - command.keybinds(false) + command.suspend(true) setStore({ visible: mode, index: props.input().cursorOffset, @@ -538,7 +586,7 @@ export function Autocomplete(props: { draft.input = props.input().plainText }) } - command.keybinds(true) + command.suspend(false) setStore("visible", false) } @@ -593,60 +641,6 @@ export function Autocomplete(props: { setStore("index", idx) } }, - onKeyDown(e: KeyEvent) { - if (store.visible) { - const name = e.name?.toLowerCase() - const ctrlOnly = e.ctrl && !e.meta && !e.shift - const isNavUp = name === "up" || (ctrlOnly && name === "p") - const isNavDown = name === "down" || (ctrlOnly && name === "n") - - if (isNavUp) { - setStore("input", "keyboard") - move(-1) - e.preventDefault() - return - } - if (isNavDown) { - setStore("input", "keyboard") - move(1) - e.preventDefault() - return - } - if (name === "escape") { - hide() - e.preventDefault() - return - } - if (name === "return") { - select() - e.preventDefault() - return - } - if (name === "tab") { - const selected = options()[store.selected] - if (selected?.isDirectory) { - expandDirectory() - } else { - select() - } - e.preventDefault() - return - } - } - if (!store.visible) { - if (e.name === "@") { - const cursorOffset = props.input().cursorOffset - const charBeforeCursor = - cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset) - const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor) - if (canTrigger) show("@") - } - - if (e.name === "/") { - if (props.input().cursorOffset === 0) show("/") - } - } - }, }) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 73ef5477e972..71fb256a7f83 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,4 +1,14 @@ -import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core" +import { + BoxRenderable, + RGBA, + TextareaRenderable, + MouseEvent, + PasteEvent, + decodePasteBytes, + type KeyEvent, + type Renderable, +} from "@opentui/core" +import type { CommandContext } from "@opentui/keymap" import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import path from "path" @@ -16,14 +26,12 @@ import { useEvent } from "@tui/context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" import { createStore, produce, unwrap } from "solid-js/store" -import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" import { computePromptTraits } from "./traits" import { assign } from "./part" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" -import { useCommandDialog } from "../dialog-command" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import * as Editor from "@tui/util/editor" import { useExit } from "../../context/exit" @@ -40,7 +48,6 @@ import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" -import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" import { confirmWorkspaceFileChanges, @@ -51,7 +58,15 @@ import { import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" import { Flag } from "@opencode-ai/core/flag/flag" -import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label" +import { type WorkspaceStatus } from "../workspace-label" +import { useCommandPalette } from "../../context/command-palette" +import { + useBindings, + useCommandShortcut, + useLeaderActive, + useOpencodeKeymap, +} from "../../keymap" +import { useTuiConfig } from "../../context/tui-config" export type PromptProps = { sessionID?: string @@ -124,9 +139,9 @@ let stashed: { prompt: PromptInfo; cursor: number } | undefined export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable - let autocomplete: AutocompleteRef + const [inputTarget, setInputTarget] = createSignal() - const keybind = useKeybind() + const leader = useLeaderActive() const local = useLocal() const args = useArgs() const sdk = useSDK() @@ -134,12 +149,17 @@ export function Prompt(props: PromptProps) { const route = useRoute() const project = useProject() const sync = useSync() + const tuiConfig = useTuiConfig() + const keymapConfig = tuiConfig.keymap const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) const history = usePromptHistory() const stash = usePromptStash() - const command = useCommandDialog() + const command = useCommandPalette() + const keymap = useOpencodeKeymap() + const agentShortcut = useCommandShortcut("agent.cycle") + const paletteShortcut = useCommandShortcut("command.palette.show") const renderer = useRenderer() const dimensions = useTerminalDimensions() const { theme, syntax } = useTheme() @@ -184,6 +204,7 @@ export function Prompt(props: PromptProps) { const [workspaceCreating, setWorkspaceCreating] = createSignal(false) const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3) const [warpNotice, setWarpNotice] = createSignal() + const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current()) @@ -287,9 +308,6 @@ export function Prompt(props: PromptProps) { setDismissedEditorSelectionKey(editorSelectionKey(editorContext())) editor.clearSelection() } - - const textareaKeybindings = useTextareaKeybindings() - const fileStyleId = syntax().getStyleId("extmark.file")! const agentStyleId = syntax().getStyleId("extmark.agent")! const pasteStyleId = syntax().getStyleId("extmark.paste")! @@ -391,26 +409,30 @@ export function Prompt(props: PromptProps) { } }) - command.register(() => { - return [ + const promptCommands = createMemo(() => + [ { title: "Clear prompt", - value: "prompt.clear", + name: "prompt.clear", category: "Prompt", hidden: true, - onSelect: (dialog) => { - input.extmarks.clear() + run: () => { input.clear() + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) dialog.clear() }, }, { title: "Submit prompt", - value: "prompt.submit", - keybind: "input_submit", + name: "prompt.submit", category: "Prompt", hidden: true, - onSelect: async (dialog) => { + run: async () => { if (!input.focused) return const handled = await submit() if (!handled) return @@ -420,21 +442,22 @@ export function Prompt(props: PromptProps) { }, { title: "Remove editor context", - value: "prompt.editor_context.clear", + name: "prompt.editor_context.clear", category: "Prompt", enabled: Boolean(editorContext()), - onSelect: (dialog) => { + run: () => { dismissEditorContext() dialog.clear() }, }, { title: "Paste", - value: "prompt.paste", - keybind: "input_paste", + name: "prompt.paste", category: "Prompt", hidden: true, - onSelect: async () => { + run: async (ctx: CommandContext) => { + ctx.event.preventDefault() + ctx.event.stopPropagation() const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { await pasteAttachment({ @@ -442,18 +465,21 @@ export function Prompt(props: PromptProps) { mime: content.mime, content: content.data, }) + return + } + if (content?.mime === "text/plain") { + await pasteInputText(content.data) } }, }, { title: "Interrupt session", - value: "session.interrupt", - keybind: "session_interrupt", + name: "session.interrupt", category: "Session", hidden: true, enabled: status().type !== "idle", - onSelect: (dialog) => { - if (autocomplete.visible) return + run: () => { + if (auto()?.visible) return if (!input.focused) return // TODO: this should be its own command if (store.mode === "shell") { @@ -480,12 +506,9 @@ export function Prompt(props: PromptProps) { { title: "Open editor", category: "Session", - keybind: "editor_open", - value: "prompt.editor", - slash: { - name: "editor", - }, - onSelect: async (dialog) => { + name: "prompt.editor", + slashName: "editor", + run: async () => { dialog.clear() // replace summarized text parts with the actual text @@ -566,12 +589,10 @@ export function Prompt(props: PromptProps) { }, { title: "Skills", - value: "prompt.skills", + name: "prompt.skills", category: "Prompt", - slash: { - name: "skills", - }, - onSelect: () => { + slashName: "skills", + run: () => { dialog.replace(() => ( { @@ -588,14 +609,12 @@ export function Prompt(props: PromptProps) { }, { title: "Warp", - description: "Change the workspace for the session", - value: "workspace.set", + desc: "Change the workspace for the session", + name: "workspace.set", category: "Session", enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, - slash: { - name: "warp", - }, - onSelect: (dialog) => { + slashName: "warp", + run: () => { void openWorkspaceSelect({ dialog, sdk, @@ -607,8 +626,29 @@ export function Prompt(props: PromptProps) { }) }, }, - ] - }) + ].map((entry) => ({ + namespace: "palette", + ...entry, + })), + ) + + useBindings(() => ({ + commands: promptCommands(), + })) + + useBindings(() => ({ + enabled: command.matcher, + bindings: keymapConfig.pick("prompt", [ + "prompt.submit", + "prompt.editor", + "prompt.editor_context.clear", + "prompt.stash", + "prompt.stash.pop", + "prompt.stash.list", + "session.interrupt", + "workspace.set", + ]), + })) const ref: PromptRef = { get focused() { @@ -659,6 +699,7 @@ export function Prompt(props: PromptProps) { if (store.prompt.input) { stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } } + setInputTarget(undefined) props.ref?.(undefined) }) @@ -676,11 +717,14 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - input.traits = computePromptTraits({ - mode: store.mode, - disabled: !!props.disabled, - autocompleteVisible: !!auto()?.visible, - }) + input.traits = { + ...input.traits, + ...computePromptTraits({ + mode: store.mode, + disabled: !!props.disabled, + autocompleteVisible: !!auto()?.visible, + }), + } }) function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { @@ -761,60 +805,195 @@ export function Prompt(props: PromptProps) { ) } - command.register(() => [ - { - title: "Stash prompt", - value: "prompt.stash", - category: "Prompt", - enabled: !!store.prompt.input, - onSelect: (dialog) => { - if (!store.prompt.input) return - stash.push({ - input: store.prompt.input, - parts: store.prompt.parts, - }) - input.extmarks.clear() - input.clear() - setStore("prompt", { input: "", parts: [] }) - setStore("extmarkToPartIndex", new Map()) - dialog.clear() + const stashCommands = createMemo(() => + [ + { + title: "Stash prompt", + name: "prompt.stash", + category: "Prompt", + enabled: !!store.prompt.input, + run: () => { + if (!store.prompt.input) return + stash.push({ + input: store.prompt.input, + parts: store.prompt.parts, + }) + input.extmarks.clear() + input.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + dialog.clear() + }, }, - }, - { - title: "Stash pop", - value: "prompt.stash.pop", - category: "Prompt", - enabled: stash.list().length > 0, - onSelect: (dialog) => { - const entry = stash.pop() - if (entry) { - input.setText(entry.input) - setStore("prompt", { input: entry.input, parts: entry.parts }) - restoreExtmarksFromParts(entry.parts) - input.gotoBufferEnd() - } - dialog.clear() + { + title: "Stash pop", + name: "prompt.stash.pop", + category: "Prompt", + enabled: stash.list().length > 0, + run: () => { + const entry = stash.pop() + if (entry) { + input.setText(entry.input) + setStore("prompt", { input: entry.input, parts: entry.parts }) + restoreExtmarksFromParts(entry.parts) + input.gotoBufferEnd() + } + dialog.clear() + }, }, - }, - { - title: "Stash list", - value: "prompt.stash.list", - category: "Prompt", - enabled: stash.list().length > 0, - onSelect: (dialog) => { - dialog.replace(() => ( - { - input.setText(entry.input) - setStore("prompt", { input: entry.input, parts: entry.parts }) - restoreExtmarksFromParts(entry.parts) - input.gotoBufferEnd() - }} - /> - )) + { + title: "Stash list", + name: "prompt.stash.list", + category: "Prompt", + enabled: stash.list().length > 0, + run: () => { + dialog.replace(() => ( + { + input.setText(entry.input) + setStore("prompt", { input: entry.input, parts: entry.parts }) + restoreExtmarksFromParts(entry.parts) + input.gotoBufferEnd() + }} + /> + )) + }, }, - }, - ]) + ].map((entry) => ({ + namespace: "palette", + ...entry, + })), + ) + + useBindings(() => ({ + commands: stashCommands(), + })) + + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && !props.disabled, + bindings: keymapConfig.pick("prompt", ["prompt.paste"]), + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "", + bindings: keymapConfig.pick("prompt", ["prompt.clear"]), + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0 + })(), + bindings: [ + { + key: "!", + cmd: () => { + setStore("placeholder", randomIndex(shell().length)) + setStore("mode", "shell") + }, + }, + ], + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && store.mode === "shell", + bindings: [{ key: "escape", cmd: () => setStore("mode", "normal") }], + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0 + })(), + bindings: [{ key: "backspace", cmd: () => setStore("mode", "normal") }], + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return ( + inputTarget() !== undefined && + !props.disabled && + !auto()?.visible && + input !== undefined && + (input.cursorOffset === 0 || input.visualCursor.visualRow === 0) + ) + })(), + commands: [ + { + name: "prompt.history.previous", + run() { + if (input.cursorOffset !== 0) { + input.cursorOffset = 0 + return + } + + const item = history.move(-1, input.plainText) + if (!item) return + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + input.cursorOffset = 0 + }, + }, + ], + bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]), + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return ( + inputTarget() !== undefined && + !props.disabled && + !auto()?.visible && + input !== undefined && + (input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1) + ) + })(), + commands: [ + { + name: "prompt.history.next", + run() { + if (input.cursorOffset !== input.plainText.length) { + input.cursorOffset = input.plainText.length + return + } + + const item = history.move(1, input.plainText) + if (!item) return + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + input.cursorOffset = input.plainText.length + }, + }, + ], + bindings: keymapConfig.pick("prompt", ["prompt.history.next"]), + } + }) async function submit() { setWarpNotice(undefined) @@ -828,7 +1007,7 @@ export function Prompt(props: PromptProps) { } if (props.disabled) return false if (workspaceCreating()) return false - if (autocomplete?.visible) return false + if (auto()?.visible) return false if (!store.prompt.input) return false const agent = local.agent.current() if (!agent) return false @@ -1068,6 +1247,66 @@ export function Prompt(props: PromptProps) { ) } + async function pasteInputText(text: string) { + const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + const pastedContent = normalizedText.trim() + const filepath = iife(() => { + const raw = pastedContent.replace(/^['"]+|['"]+$/g, "") + if (raw.startsWith("file://")) { + try { + return fileURLToPath(raw) + } catch {} + } + if (process.platform === "win32") return raw + return raw.replace(/\\(.)/g, "$1") + }) + const isUrl = /^(https?):\/\//.test(filepath) + if (!isUrl) { + try { + const mime = await Filesystem.mimeType(filepath) + const filename = path.basename(filepath) + if (mime === "image/svg+xml") { + const content = await Filesystem.readText(filepath).catch(() => {}) + if (content) { + pasteText(content, `[SVG: ${filename ?? "image"}]`) + return + } + } + if (mime.startsWith("image/") || mime === "application/pdf") { + const content = await Filesystem.readArrayBuffer(filepath) + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => {}) + if (content) { + await pasteAttachment({ + filename, + filepath, + mime, + content, + }) + return + } + } + } catch {} + } + + const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 + if ( + (lineCount >= 3 || pastedContent.length > 150) && + kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary) + ) { + pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) + return + } + + input.insertText(normalizedText) + + setTimeout(() => { + if (!input || input.isDestroyed) return + input.getLayoutNode().markDirty() + renderer.requestRender() + }, 0) + } + async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset @@ -1117,7 +1356,7 @@ export function Prompt(props: PromptProps) { } const highlight = createMemo(() => { - if (keybind.leader) return theme.border + if (leader()) return theme.border if (store.mode === "shell") return theme.primary const agent = local.agent.current() if (!agent) return theme.border @@ -1206,30 +1445,7 @@ export function Prompt(props: PromptProps) { return ( <> - { - autocomplete = r - setAuto(() => r) - }} - anchor={() => anchor} - input={() => input} - setPrompt={(cb) => { - setStore("prompt", produce(cb)) - }} - setExtmark={(partIndex, extmarkId) => { - setStore("extmarkToPartIndex", (map: Map) => { - const newMap = new Map(map) - newMap.set(extmarkId, partIndex) - return newMap - }) - }} - value={store.prompt.input} - fileStyleId={fileStyleId} - agentStyleId={agentStyleId} - promptPartTypeId={() => promptPartTypeId} - /> - (anchor = r)} visible={props.visible !== false}> + (anchor = r)} visible={props.visible !== false}> { const value = input.plainText setStore("prompt", "input", value) - autocomplete.onInput(value) + auto()?.onInput(value) syncExtmarksWithPromptParts() + setCursorVersion((value) => value + 1) }} - keyBindings={textareaKeybindings()} - onKeyDown={async (e) => { + onCursorChange={() => setCursorVersion((value) => value + 1)} + onKeyDown={(e: { preventDefault(): void }) => { if (props.disabled) { e.preventDefault() return } - // Check clipboard for images before terminal-handled paste runs. - // This helps terminals that forward Ctrl+V to the app; Windows - // Terminal 1.25+ usually handles Ctrl+V before this path. - if (keybind.match("input_paste", e)) { - const content = await Clipboard.read() - if (content?.mime.startsWith("image/")) { - e.preventDefault() - await pasteAttachment({ - filename: "clipboard", - mime: content.mime, - content: content.data, - }) - return - } - // If no image, let the default paste behavior continue - } - if (keybind.match("input_clear", e) && store.prompt.input !== "") { - input.clear() - input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) - return - } - if (keybind.match("app_exit", e)) { - if (store.prompt.input === "") { - await exit() - // Don't preventDefault - let textarea potentially handle the event - e.preventDefault() - return - } - } - if (e.name === "!" && input.visualCursor.offset === 0) { - setStore("placeholder", randomIndex(shell().length)) - setStore("mode", "shell") - e.preventDefault() - return - } - if (store.mode === "shell") { - if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") { - setStore("mode", "normal") - e.preventDefault() - return - } - } - if (store.mode === "normal") autocomplete.onKeyDown(e) - if (!autocomplete.visible) { - if ( - (keybind.match("history_previous", e) && input.cursorOffset === 0) || - (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length) - ) { - const direction = keybind.match("history_previous", e) ? -1 : 1 - const item = history.move(direction, input.plainText) - - if (item) { - input.setText(item.input) - setStore("prompt", item) - setStore("mode", item.mode ?? "normal") - restoreExtmarksFromParts(item.parts) - e.preventDefault() - if (direction === -1) input.cursorOffset = 0 - if (direction === 1) input.cursorOffset = input.plainText.length - } - return - } - - if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0 - if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1) - input.cursorOffset = input.plainText.length - } }} onSubmit={() => { // IME: double-defer so the last composed character (e.g. Korean @@ -1358,7 +1503,7 @@ export function Prompt(props: PromptProps) { // Windows Terminal <1.25 can surface image-only clipboard as an // empty bracketed paste. Windows Terminal 1.25+ does not. if (!pastedContent) { - command.trigger("prompt.paste") + keymap.dispatchCommand("prompt.paste") return } @@ -1366,67 +1511,11 @@ export function Prompt(props: PromptProps) { // default paste unless we suppress it first and handle insertion ourselves. event.preventDefault() - const filepath = iife(() => { - const raw = pastedContent.replace(/^['"]+|['"]+$/g, "") - if (raw.startsWith("file://")) { - try { - return fileURLToPath(raw) - } catch {} - } - if (process.platform === "win32") return raw - return raw.replace(/\\(.)/g, "$1") - }) - const isUrl = /^(https?):\/\//.test(filepath) - if (!isUrl) { - try { - const mime = await Filesystem.mimeType(filepath) - const filename = path.basename(filepath) - // Handle SVG as raw text content, not as base64 image - if (mime === "image/svg+xml") { - const content = await Filesystem.readText(filepath).catch(() => {}) - if (content) { - pasteText(content, `[SVG: ${filename ?? "image"}]`) - return - } - } - if (mime.startsWith("image/") || mime === "application/pdf") { - const content = await Filesystem.readArrayBuffer(filepath) - .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(() => {}) - if (content) { - await pasteAttachment({ - filename, - filepath, - mime, - content, - }) - return - } - } - } catch {} - } - - const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 - if ( - (lineCount >= 3 || pastedContent.length > 150) && - kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary) - ) { - pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) - return - } - - input.insertText(normalizedText) - - // Force layout update and render for the pasted content - setTimeout(() => { - // setTimeout is a workaround and needs to be addressed properly - if (!input || input.isDestroyed) return - input.getLayoutNode().markDirty() - renderer.requestRender() - }, 0) + await pasteInputText(normalizedText) }} ref={(r: TextareaRenderable) => { input = r + setInputTarget(r) if (promptPartTypeId === 0) { promptPartTypeId = input.extmarks.registerType("prompt-part") } @@ -1455,7 +1544,7 @@ export function Prompt(props: PromptProps) { · {local.model.parsed().model} @@ -1646,12 +1735,12 @@ export function Prompt(props: PromptProps) { - {keybind.print("agent_cycle")} agents + {agentShortcut()} agents - {keybind.print("command_list")} commands + {paletteShortcut()} commands @@ -1664,6 +1753,28 @@ export function Prompt(props: PromptProps) { + { + setAuto(() => r) + }} + anchor={() => anchor} + input={() => input} + setPrompt={(cb) => { + setStore("prompt", produce(cb)) + }} + setExtmark={(partIndex, extmarkId) => { + setStore("extmarkToPartIndex", (map: Map) => { + const newMap = new Map(map) + newMap.set(extmarkId, partIndex) + return newMap + }) + }} + value={store.prompt.input} + fileStyleId={fileStyleId} + agentStyleId={agentStyleId} + promptPartTypeId={() => promptPartTypeId} + /> ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts index e47a1aeba5fe..a70139656286 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts @@ -8,6 +8,11 @@ export interface PromptTraitsInput { autocompleteVisible: boolean } +export type PromptTraits = EditorTraits & { + owner: "opencode" + role: "prompt" +} + /** * Compute the textarea editor traits for the prompt. * @@ -16,7 +21,7 @@ export interface PromptTraitsInput { * editing mode — only `disabled` should suspend the textarea, otherwise * users can type in shell mode but cannot delete or move the cursor. */ -export function computePromptTraits(input: PromptTraitsInput): EditorTraits { +export function computePromptTraits(input: PromptTraitsInput): PromptTraits { const capture = input.mode === "normal" ? input.autocompleteVisible @@ -27,5 +32,7 @@ export function computePromptTraits(input: PromptTraitsInput): EditorTraits { capture, suspend: input.disabled, status: input.mode === "shell" ? "SHELL" : undefined, + owner: "opencode", + role: "prompt", } } diff --git a/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts b/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts deleted file mode 100644 index 36ab03de545c..000000000000 --- a/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createMemo } from "solid-js" -import type { KeyBinding } from "@opentui/core" -import { useKeybind } from "../context/keybind" -import { Keybind } from "@/util/keybind" - -const TEXTAREA_ACTIONS = [ - "submit", - "newline", - "move-left", - "move-right", - "move-up", - "move-down", - "select-left", - "select-right", - "select-up", - "select-down", - "line-home", - "line-end", - "select-line-home", - "select-line-end", - "visual-line-home", - "visual-line-end", - "select-visual-line-home", - "select-visual-line-end", - "buffer-home", - "buffer-end", - "select-buffer-home", - "select-buffer-end", - "delete-line", - "delete-to-line-end", - "delete-to-line-start", - "backspace", - "delete", - "undo", - "redo", - "word-forward", - "word-backward", - "select-word-forward", - "select-word-backward", - "delete-word-forward", - "delete-word-backward", -] as const - -function mapTextareaKeybindings( - keybinds: Record, - action: (typeof TEXTAREA_ACTIONS)[number], -): KeyBinding[] { - const configKey = `input_${action.replace(/-/g, "_")}` - const bindings = keybinds[configKey] - if (!bindings) return [] - return bindings.map((binding) => ({ - name: binding.name, - ctrl: binding.ctrl || undefined, - meta: binding.meta || undefined, - shift: binding.shift || undefined, - super: binding.super || undefined, - action, - })) -} - -export function useTextareaKeybindings() { - const keybind = useKeybind() - - return createMemo(() => { - const keybinds = keybind.all - - return [ - { name: "return", action: "submit" }, - { name: "return", meta: true, action: "newline" }, - ...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)), - ] satisfies KeyBinding[] - }) -} diff --git a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts new file mode 100644 index 000000000000..c0c621862e76 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts @@ -0,0 +1,177 @@ +import type { KeyEvent, Renderable } from "@opentui/core" +import type { Binding } from "@opentui/keymap" +import type { BindingValue } from "@opentui/keymap/extras" +import { ConfigKeybinds } from "@/config/keybinds" +import { type KeymapConfigInput, type KeymapSection } from "./tui-schema" + +type LegacyKeybinds = Partial +type SectionsConfig = Record>> + +const inputCommands = { + input_submit: "input.submit", + input_newline: "input.newline", + input_move_left: "input.move.left", + input_move_right: "input.move.right", + input_move_up: "input.move.up", + input_move_down: "input.move.down", + input_select_left: "input.select.left", + input_select_right: "input.select.right", + input_select_up: "input.select.up", + input_select_down: "input.select.down", + input_line_home: "input.line.home", + input_line_end: "input.line.end", + input_select_line_home: "input.select.line.home", + input_select_line_end: "input.select.line.end", + input_visual_line_home: "input.visual.line.home", + input_visual_line_end: "input.visual.line.end", + input_select_visual_line_home: "input.select.visual.line.home", + input_select_visual_line_end: "input.select.visual.line.end", + input_buffer_home: "input.buffer.home", + input_buffer_end: "input.buffer.end", + input_select_buffer_home: "input.select.buffer.home", + input_select_buffer_end: "input.select.buffer.end", + input_delete_line: "input.delete.line", + input_delete_to_line_end: "input.delete.to.line.end", + input_delete_to_line_start: "input.delete.to.line.start", + input_backspace: "input.backspace", + input_delete: "input.delete", + input_undo: "input.undo", + input_redo: "input.redo", + input_word_forward: "input.word.forward", + input_word_backward: "input.word.backward", + input_select_word_forward: "input.select.word.forward", + input_select_word_backward: "input.select.word.backward", + input_delete_word_forward: "input.delete.word.forward", + input_delete_word_backward: "input.delete.word.backward", + input_select_all: "input.select.all", +} as const satisfies Partial> + +function add(config: SectionsConfig, section: KeymapSection, command: string, binding: BindingValue | undefined) { + if (binding === undefined) return + config[section] ??= {} + config[section][command] = binding +} + +function bindingWith(key: string | undefined, input: Omit, "key" | "cmd">) { + if (!key) return undefined + if (key === "none") return "none" + return { ...input, key } +} + +function combineBindings(...keys: (string | undefined)[]) { + const result = Array.from( + new Set( + keys.flatMap((key) => { + if (!key || key === "none") return [] + return key + .split(",") + .map((part) => part.trim()) + .filter((part) => part && part !== "none") + }), + ), + ) + if (result.length) return result.join(",") + if (keys.some((key) => key === "none")) return "none" + return undefined +} + +export function create(keybinds: LegacyKeybinds): KeymapConfigInput { + const config: SectionsConfig = {} + + add(config, "global", "command.palette.show", keybinds.command_list) + add(config, "global", "session.list", keybinds.session_list) + add(config, "global", "session.new", keybinds.session_new) + add(config, "global", "model.list", keybinds.model_list) + add(config, "global", "model.cycle_recent", keybinds.model_cycle_recent) + add(config, "global", "model.cycle_recent_reverse", keybinds.model_cycle_recent_reverse) + add(config, "global", "model.cycle_favorite", keybinds.model_cycle_favorite) + add(config, "global", "model.cycle_favorite_reverse", keybinds.model_cycle_favorite_reverse) + add(config, "global", "agent.list", keybinds.agent_list) + add(config, "global", "agent.cycle", keybinds.agent_cycle) + add(config, "global", "agent.cycle.reverse", keybinds.agent_cycle_reverse) + add(config, "global", "variant.cycle", keybinds.variant_cycle) + add(config, "global", "variant.list", keybinds.variant_list) + add(config, "prompt", "prompt.editor", keybinds.editor_open) + add(config, "global", "opencode.status", keybinds.status_view) + add(config, "global", "theme.switch", keybinds.theme_list) + add(config, "global", "app.exit", keybinds.app_exit) + add(config, "global", "terminal.suspend", keybinds.terminal_suspend) + add(config, "global", "terminal.title.toggle", keybinds.terminal_title_toggle) + + add(config, "session", "session.share", keybinds.session_share) + add(config, "session", "session.rename", keybinds.session_rename) + add(config, "session", "session.timeline", keybinds.session_timeline) + add(config, "session", "session.fork", keybinds.session_fork) + add(config, "session", "session.compact", keybinds.session_compact) + add(config, "session", "session.unshare", keybinds.session_unshare) + add(config, "session", "session.undo", keybinds.messages_undo) + add(config, "session", "session.redo", keybinds.messages_redo) + add(config, "session", "session.sidebar.toggle", keybinds.sidebar_toggle) + add(config, "session", "session.toggle.conceal", keybinds.messages_toggle_conceal) + add(config, "session", "session.toggle.thinking", keybinds.display_thinking) + add(config, "session", "session.toggle.actions", keybinds.tool_details) + add(config, "session", "session.toggle.scrollbar", keybinds.scrollbar_toggle) + add(config, "session", "session.page.up", keybinds.messages_page_up) + add(config, "session", "session.page.down", keybinds.messages_page_down) + add(config, "session", "session.line.up", keybinds.messages_line_up) + add(config, "session", "session.line.down", keybinds.messages_line_down) + add(config, "session", "session.half.page.up", keybinds.messages_half_page_up) + add(config, "session", "session.half.page.down", keybinds.messages_half_page_down) + add(config, "session", "session.first", keybinds.messages_first) + add(config, "session", "session.last", keybinds.messages_last) + add(config, "session", "session.messages_last_user", keybinds.messages_last_user) + add(config, "session", "session.message.next", keybinds.messages_next) + add(config, "session", "session.message.previous", keybinds.messages_previous) + add(config, "session", "messages.copy", keybinds.messages_copy) + add(config, "session", "session.export", keybinds.session_export) + add(config, "session", "session.child.first", keybinds.session_child_first) + add(config, "session", "session.parent", keybinds.session_parent) + add(config, "session", "session.child.next", keybinds.session_child_cycle) + add(config, "session", "session.child.previous", keybinds.session_child_cycle_reverse) + + add(config, "prompt", "session.interrupt", keybinds.session_interrupt) + add(config, "prompt", "prompt.clear", keybinds.input_clear) + add(config, "prompt", "prompt.paste", bindingWith(keybinds.input_paste, { preventDefault: false })) + add(config, "prompt", "prompt.history.previous", keybinds.history_previous) + add(config, "prompt", "prompt.history.next", keybinds.history_next) + + add(config, "autocomplete", "prompt.autocomplete.prev", keybinds["prompt.autocomplete.prev"]) + add(config, "autocomplete", "prompt.autocomplete.next", keybinds["prompt.autocomplete.next"]) + add(config, "autocomplete", "prompt.autocomplete.hide", keybinds["prompt.autocomplete.hide"]) + add(config, "autocomplete", "prompt.autocomplete.select", keybinds["prompt.autocomplete.select"]) + add(config, "autocomplete", "prompt.autocomplete.complete", keybinds["prompt.autocomplete.complete"]) + + for (const [legacy, command] of Object.entries(inputCommands) as [keyof typeof inputCommands, string][]) { + add(config, "input", command, keybinds[legacy]) + } + + add(config, "dialog_select", "dialog.select.prev", keybinds["dialog.select.prev"]) + add(config, "dialog_select", "dialog.select.next", keybinds["dialog.select.next"]) + add(config, "dialog_select", "dialog.select.page_up", keybinds["dialog.select.page_up"]) + add(config, "dialog_select", "dialog.select.page_down", keybinds["dialog.select.page_down"]) + add(config, "dialog_select", "dialog.select.home", keybinds["dialog.select.home"]) + add(config, "dialog_select", "dialog.select.end", keybinds["dialog.select.end"]) + add(config, "dialog_select", "dialog.select.submit", keybinds["dialog.select.submit"]) + add(config, "dialog_actions", "dialog.action.delete", combineBindings(keybinds.stash_delete, keybinds.session_delete)) + add(config, "dialog_actions", "dialog.action.rename", keybinds.session_rename) + add(config, "dialog_actions", "dialog.action.toggle", combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"])) + add(config, "model", "model.dialog.provider", keybinds.model_provider_list) + add(config, "model", "model.dialog.favorite", keybinds.model_favorite_toggle) + + add(config, "permission", "permission.reject.cancel", keybinds.app_exit) + add(config, "permission", "permission.prompt.escape", keybinds.app_exit) + add(config, "permission", "permission.prompt.fullscreen", keybinds["permission.prompt.fullscreen"]) + add(config, "question", "question.reject", keybinds.app_exit) + add(config, "question", "question.edit.clear", keybinds.input_clear) + + add(config, "plugins", "plugins.list", keybinds.plugin_manager) + add(config, "plugins", "plugin.dialog.install", keybinds["dialog.plugins.install"]) + add(config, "home_tips", "tips.toggle", keybinds.tips_toggle) + + return { + ...(keybinds.leader && keybinds.leader !== "none" && { leader: keybinds.leader }), + sections: config, + } +} + +export * as LegacyKeymapTransform from "./legacy-keymap-transform" diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index ed79e8e52418..400eb3852846 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,4 +1,7 @@ import z from "zod" +import type { KeyEvent, Renderable } from "@opentui/core" +import type { Binding } from "@opentui/keymap" +import type { ResolvedBindingSections } from "@opentui/keymap/extras" import { ConfigPlugin } from "@/config/plugin" import { ConfigKeybinds } from "@/config/keybinds" @@ -11,6 +14,303 @@ const KeybindOverride = z ) .strict() +const KeyStroke = z + .object({ + name: z.string(), + ctrl: z.boolean().optional(), + shift: z.boolean().optional(), + meta: z.boolean().optional(), + super: z.boolean().optional(), + hyper: z.boolean().optional(), + }) + .strict() + +const KeymapBindingObject = z + .object({ + key: z.union([z.string(), KeyStroke]), + event: z.enum(["press", "release"]).optional(), + preventDefault: z.boolean().optional(), + fallthrough: z.boolean().optional(), + }) + .passthrough() + +const KeymapBindingItem = z.union([z.string(), KeyStroke, KeymapBindingObject]) +const KeymapBindingValue = z.union([z.literal(false), z.literal("none"), KeymapBindingItem, z.array(KeymapBindingItem)]) + +const keymapBinding = (value: z.input | (() => z.input)) => + KeymapBindingValue.prefault(value) +const keymapSection = (shape: Shape) => { + const schema = z.object(shape).strict() + return schema.prefault({} as z.input) +} +const keymapSectionInput = (shape: Shape) => + z + .object( + Object.fromEntries(Object.keys(shape).map((key) => [key, KeymapBindingValue.optional()])) as { + [Key in keyof Shape]: z.ZodOptional + }, + ) + .strict() + +const GlobalKeymapSection = { + "command.palette.show": keymapBinding("ctrl+p"), + "session.list": keymapBinding("l"), + "session.new": keymapBinding("n"), + "model.list": keymapBinding("m"), + "model.cycle_recent": keymapBinding("f2"), + "model.cycle_recent_reverse": keymapBinding("shift+f2"), + "model.cycle_favorite": keymapBinding("none"), + "model.cycle_favorite_reverse": keymapBinding("none"), + "agent.list": keymapBinding("a"), + "mcp.list": keymapBinding("none"), + "agent.cycle": keymapBinding("tab"), + "agent.cycle.reverse": keymapBinding("shift+tab"), + "variant.cycle": keymapBinding("ctrl+t"), + "variant.list": keymapBinding("none"), + "provider.connect": keymapBinding("none"), + "console.org.switch": keymapBinding("none"), + "opencode.status": keymapBinding("s"), + "theme.switch": keymapBinding("t"), + "theme.switch_mode": keymapBinding("none"), + "theme.mode.lock": keymapBinding("none"), + "help.show": keymapBinding("none"), + "docs.open": keymapBinding("none"), + "app.exit": keymapBinding("ctrl+c,ctrl+d,q"), + "app.debug": keymapBinding("none"), + "app.console": keymapBinding("none"), + "app.heap_snapshot": keymapBinding("none"), + "app.toggle.animations": keymapBinding("none"), + "app.toggle.file_context": keymapBinding("none"), + "app.toggle.diffwrap": keymapBinding("none"), + "app.toggle.paste_summary": keymapBinding("none"), + "app.toggle.session_directory_filter": keymapBinding("none"), + "terminal.suspend": keymapBinding(() => (process.platform === "win32" ? "none" : "ctrl+z")), + "terminal.title.toggle": keymapBinding("none"), +} + +const SessionKeymapSection = { + "session.share": keymapBinding("none"), + "session.rename": keymapBinding("ctrl+r"), + "session.timeline": keymapBinding("g"), + "session.fork": keymapBinding("none"), + "session.compact": keymapBinding("c"), + "session.unshare": keymapBinding("none"), + "session.undo": keymapBinding("u"), + "session.redo": keymapBinding("r"), + "session.sidebar.toggle": keymapBinding("b"), + "session.toggle.conceal": keymapBinding("h"), + "session.toggle.timestamps": keymapBinding("none"), + "session.toggle.thinking": keymapBinding("none"), + "session.toggle.actions": keymapBinding("none"), + "session.toggle.scrollbar": keymapBinding("none"), + "session.toggle.generic_tool_output": keymapBinding("none"), + "session.page.up": keymapBinding("pageup,ctrl+alt+b"), + "session.page.down": keymapBinding("pagedown,ctrl+alt+f"), + "session.line.up": keymapBinding("ctrl+alt+y"), + "session.line.down": keymapBinding("ctrl+alt+e"), + "session.half.page.up": keymapBinding("ctrl+alt+u"), + "session.half.page.down": keymapBinding("ctrl+alt+d"), + "session.first": keymapBinding("ctrl+g,home"), + "session.last": keymapBinding("ctrl+alt+g,end"), + "session.messages_last_user": keymapBinding("none"), + "session.message.next": keymapBinding("none"), + "session.message.previous": keymapBinding("none"), + "messages.copy": keymapBinding("y"), + "session.copy": keymapBinding("none"), + "session.export": keymapBinding("x"), + "session.child.first": keymapBinding("down"), + "session.parent": keymapBinding("up"), + "session.child.next": keymapBinding("right"), + "session.child.previous": keymapBinding("left"), +} + +const PromptKeymapSection = { + "prompt.submit": keymapBinding("none"), + "prompt.editor": keymapBinding("e"), + "prompt.editor_context.clear": keymapBinding("none"), + "prompt.skills": keymapBinding("none"), + "prompt.stash": keymapBinding("none"), + "prompt.stash.pop": keymapBinding("none"), + "prompt.stash.list": keymapBinding("none"), + "workspace.set": keymapBinding("none"), + "session.interrupt": keymapBinding("escape"), + "prompt.clear": keymapBinding("ctrl+c"), + "prompt.paste": keymapBinding({ key: "ctrl+v", preventDefault: false }), + "prompt.history.previous": keymapBinding("up"), + "prompt.history.next": keymapBinding("down"), +} + +const AutocompleteKeymapSection = { + "prompt.autocomplete.prev": keymapBinding("up,ctrl+p"), + "prompt.autocomplete.next": keymapBinding("down,ctrl+n"), + "prompt.autocomplete.hide": keymapBinding("escape"), + "prompt.autocomplete.select": keymapBinding("return"), + "prompt.autocomplete.complete": keymapBinding("tab"), +} + +const InputKeymapSection = { + "input.submit": keymapBinding("return"), + "input.newline": keymapBinding("shift+return,ctrl+return,alt+return,ctrl+j"), + "input.move.left": keymapBinding("left,ctrl+b"), + "input.move.right": keymapBinding("right,ctrl+f"), + "input.move.up": keymapBinding("up"), + "input.move.down": keymapBinding("down"), + "input.select.left": keymapBinding("shift+left"), + "input.select.right": keymapBinding("shift+right"), + "input.select.up": keymapBinding("shift+up"), + "input.select.down": keymapBinding("shift+down"), + "input.line.home": keymapBinding("ctrl+a"), + "input.line.end": keymapBinding("ctrl+e"), + "input.select.line.home": keymapBinding("ctrl+shift+a"), + "input.select.line.end": keymapBinding("ctrl+shift+e"), + "input.visual.line.home": keymapBinding("alt+a"), + "input.visual.line.end": keymapBinding("alt+e"), + "input.select.visual.line.home": keymapBinding("alt+shift+a"), + "input.select.visual.line.end": keymapBinding("alt+shift+e"), + "input.buffer.home": keymapBinding("home"), + "input.buffer.end": keymapBinding("end"), + "input.select.buffer.home": keymapBinding("shift+home"), + "input.select.buffer.end": keymapBinding("shift+end"), + "input.delete.line": keymapBinding("ctrl+shift+d"), + "input.delete.to.line.end": keymapBinding("ctrl+k"), + "input.delete.to.line.start": keymapBinding("ctrl+u"), + "input.backspace": keymapBinding("backspace,shift+backspace"), + "input.delete": keymapBinding("ctrl+d,delete,shift+delete"), + "input.undo": keymapBinding(() => (process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")), + "input.redo": keymapBinding("ctrl+.,super+shift+z"), + "input.word.forward": keymapBinding("alt+f,alt+right,ctrl+right"), + "input.word.backward": keymapBinding("alt+b,alt+left,ctrl+left"), + "input.select.word.forward": keymapBinding("alt+shift+f,alt+shift+right"), + "input.select.word.backward": keymapBinding("alt+shift+b,alt+shift+left"), + "input.delete.word.forward": keymapBinding("alt+d,alt+delete,ctrl+delete"), + "input.delete.word.backward": keymapBinding("ctrl+w,ctrl+backspace,alt+backspace"), + "input.select.all": keymapBinding("super+a"), +} + +const DialogSelectKeymapSection = { + "dialog.select.prev": keymapBinding("up,ctrl+p"), + "dialog.select.next": keymapBinding("down,ctrl+n"), + "dialog.select.page_up": keymapBinding("pageup"), + "dialog.select.page_down": keymapBinding("pagedown"), + "dialog.select.home": keymapBinding("home"), + "dialog.select.end": keymapBinding("end"), + "dialog.select.submit": keymapBinding("return"), +} + +const DialogActionsKeymapSection = { + "dialog.action.toggle": keymapBinding("space"), + "dialog.action.delete": keymapBinding("ctrl+d"), + "dialog.action.rename": keymapBinding("ctrl+r"), +} + +const ModelKeymapSection = { + "model.dialog.provider": keymapBinding("ctrl+a"), + "model.dialog.favorite": keymapBinding("ctrl+f"), +} + +const PermissionKeymapSection = { + "permission.reject.cancel": keymapBinding("ctrl+c,ctrl+d,q"), + "permission.prompt.escape": keymapBinding("ctrl+c,ctrl+d,q"), + "permission.prompt.fullscreen": keymapBinding("ctrl+f"), +} + +const QuestionKeymapSection = { + "question.reject": keymapBinding("ctrl+c,ctrl+d,q"), + "question.edit.clear": keymapBinding("ctrl+c"), +} + +const PluginsKeymapSection = { + "plugins.list": keymapBinding("none"), + "plugins.install": keymapBinding("none"), + "plugin.dialog.install": keymapBinding("shift+i"), +} + +const HomeTipsKeymapSection = { + "tips.toggle": keymapBinding("h"), +} + +const KeymapSectionsShape = { + global: keymapSection(GlobalKeymapSection), + session: keymapSection(SessionKeymapSection), + prompt: keymapSection(PromptKeymapSection), + autocomplete: keymapSection(AutocompleteKeymapSection), + input: keymapSection(InputKeymapSection), + dialog_select: keymapSection(DialogSelectKeymapSection), + dialog_actions: keymapSection(DialogActionsKeymapSection), + model: keymapSection(ModelKeymapSection), + permission: keymapSection(PermissionKeymapSection), + question: keymapSection(QuestionKeymapSection), + plugins: keymapSection(PluginsKeymapSection), + home_tips: keymapSection(HomeTipsKeymapSection), +} + +const KeymapSectionsInputShape = { + global: keymapSectionInput(GlobalKeymapSection).optional(), + session: keymapSectionInput(SessionKeymapSection).optional(), + prompt: keymapSectionInput(PromptKeymapSection).optional(), + autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(), + input: keymapSectionInput(InputKeymapSection).optional(), + dialog_select: keymapSectionInput(DialogSelectKeymapSection).optional(), + dialog_actions: keymapSectionInput(DialogActionsKeymapSection).optional(), + model: keymapSectionInput(ModelKeymapSection).optional(), + permission: keymapSectionInput(PermissionKeymapSection).optional(), + question: keymapSectionInput(QuestionKeymapSection).optional(), + plugins: keymapSectionInput(PluginsKeymapSection).optional(), + home_tips: keymapSectionInput(HomeTipsKeymapSection).optional(), +} + +export const KeymapSections = z.object(KeymapSectionsShape).strict().prefault({}) +export type KeymapSections = z.output +export type KeymapSection = keyof KeymapSections +export const KeymapSectionNames = Object.keys(KeymapSectionsShape) as KeymapSection[] +export const KeymapLeaderTimeoutDefault = 2000 +export type KeymapInfo = { + leader: string + leader_timeout: number +} & ResolvedBindingSections + +export const KeymapSectionGroups = { + global: "Global", + session: "Session", + prompt: "Prompt", + autocomplete: "Autocomplete", + input: "Text Editing", + dialog_select: "Dialog", + dialog_actions: "Dialog", + model: "Model", + permission: "Permission", + question: "Question", + plugins: "Plugins", + home_tips: "Home", +} satisfies Record + +export function keymapBindingDefaults(input: { section: string; binding: Readonly> }) { + if (input.binding.group !== undefined) return + if (!Object.hasOwn(KeymapSectionGroups, input.section)) return + return { group: KeymapSectionGroups[input.section as KeymapSection] } +} + +export const KeymapConfig = z + .object({ + leader: z.string().prefault("ctrl+x"), + leader_timeout: z.number().int().positive().prefault(KeymapLeaderTimeoutDefault).describe("Leader key timeout in milliseconds"), + sections: KeymapSections, + }) + .strict() + .describe("TUI keymap configuration") +export type KeymapConfig = z.output + +const KeymapSectionsInput = z.object(KeymapSectionsInputShape).strict().optional() +export const KeymapConfigInput = z + .object({ + leader: z.string().optional(), + leader_timeout: z.number().int().positive().optional().describe("Leader key timeout in milliseconds"), + sections: KeymapSectionsInput, + }) + .strict() + .describe("TUI keymap configuration") +export type KeymapConfigInput = z.output + export const TuiOptions = z.object({ scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), scroll_acceleration: z @@ -30,9 +330,17 @@ export const TuiInfo = z .object({ $schema: z.string().optional(), theme: z.string().optional(), - keybinds: KeybindOverride.optional(), + keybinds: KeybindOverride.optional().meta({ + deprecated: true, + description: "Use keymap instead. This will be removed in opencode v2.0.", + }), + keymap: KeymapConfigInput.optional(), plugin: ConfigPlugin.Spec.zod.array().optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(), }) .extend(TuiOptions.shape) .strict() + +export const TuiJsonSchemaInfo = TuiInfo.extend({ + keymap: KeymapConfig.optional(), +}).strict() diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 890f73622853..095bc2c882ca 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -1,12 +1,14 @@ export * as TuiConfig from "./tui" -import z from "zod" +import type z from "zod" +import type { KeyEvent, Renderable } from "@opentui/core" +import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" import { ConfigParse } from "@/config/parse" import * as ConfigPaths from "@/config/paths" import { migrateTuiConfig } from "./tui-migrate" -import { TuiInfo } from "./tui-schema" +import { KeymapConfig, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema" import { Flag } from "@opencode-ai/core/flag/flag" import { isRecord } from "@/util/record" import { Global } from "@opencode-ai/core/global" @@ -20,27 +22,34 @@ import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" +import { LegacyKeymapTransform } from "./legacy-keymap-transform" +import { + KeymapSectionNames, + keymapBindingDefaults, + type KeymapInfo, + type KeymapSection, +} from "./tui-schema" const log = Log.create({ service: "tui.config" }) export const Info = TuiInfo +export const JsonSchemaInfo = TuiJsonSchemaInfo +export type Info = z.output type Acc = { result: Info + plugin_origins: ConfigPlugin.Origin[] } -type State = { - config: Info - deps: Array> -} - -export type Info = z.output & { +export type Resolved = Omit & { + keybinds: ConfigKeybinds.Keybinds + keymap: KeymapInfo // Internal resolved plugin list used by runtime loading. plugin_origins?: ConfigPlugin.Origin[] } export interface Interface { - readonly get: () => Effect.Effect + readonly get: () => Effect.Effect readonly waitForDependencies: () => Effect.Effect } @@ -128,11 +137,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const scope = pluginScope(file, ctx) const plugins = ConfigPlugin.deduplicatePluginOrigins([ - ...(acc.result.plugin_origins ?? []), + ...acc.plugin_origins, ...data.plugin.map((spec) => ({ spec, scope, source: file })), ]) acc.result.plugin = plugins.map((item) => item.spec) - acc.result.plugin_origins = plugins + acc.plugin_origins = plugins }) // Every config dir we may read from: global config dir, any `.opencode` @@ -144,6 +153,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const acc: Acc = { result: {}, + plugin_origins: [], } // 1. Global tui config (lowest precedence). @@ -184,11 +194,33 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","), ]).join(",") } - acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds) + const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds) + const keymapInput = acc.result.keymap ?? LegacyKeymapTransform.create(acc.result.keybinds ?? {}) + const keymapConfig = KeymapConfig.parse(keymapInput) + const keymap = { + leader: !keymapConfig.leader || keymapConfig.leader === "none" ? "ctrl+x" : keymapConfig.leader, + leader_timeout: keymapConfig.leader_timeout, + ...resolveBindingSections, KeymapSection>( + keymapConfig.sections, + { + sections: KeymapSectionNames, + bindingDefaults: keymapBindingDefaults, + }, + ), + } + const result: Resolved = { + ...acc.result, + keybinds: parsedKeybinds, + plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined, + // `keybinds` is deprecated and will be removed in opencode v2.0. Keep it + // only as the legacy fallback; once `keymap` is configured, ignore + // `keybinds` for keymap resolution. + keymap, + } return { - config: acc.result, - dirs: acc.result.plugin?.length ? dirs : [], + config: result, + dirs: result.plugin?.length ? dirs : [], } }) diff --git a/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx b/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx new file mode 100644 index 000000000000..07cca99074c9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx @@ -0,0 +1,163 @@ +import { createContext, createMemo, createSignal, useContext, type Accessor, type ParentProps } from "solid-js" +import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" +import { useDialog, type DialogContext } from "@tui/ui/dialog" +import { + formatKeyBindings, + reactiveMatcherFromSignal, + type OpenTuiKeymap, + useKeymapSelector, + useOpencodeKeymap, +} from "../keymap" +import { useTuiConfig } from "./tui-config" + +type SlashEntry = { + display: string + description?: string + aliases?: string[] + onSelect: () => void +} + +type CommandPaletteContext = { + run(command: string): void + show(): void + slashes: Accessor + suspend(enabled: boolean): void + readonly suspended: boolean + matcher: ReturnType +} + +const COMMAND_PALETTE_DIALOG = "command.palette.show" +const ctx = createContext() +type PaletteCommandEntry = ReturnType[number] + +function isVisiblePaletteCommand(entry: PaletteCommandEntry) { + return entry.command.hidden !== true && entry.command.name !== COMMAND_PALETTE_DIALOG +} + +function isSuggestedPaletteCommand(entry: PaletteCommandEntry) { + const suggested = entry.command.suggested + if (typeof suggested === "boolean") return suggested + if (typeof suggested === "function") return suggested() === true + return false +} + +export function CommandPaletteProvider(props: ParentProps) { + const dialog = useDialog() + const keymap = useOpencodeKeymap() + const [suspendCount, setSuspendCount] = createSignal(0) + const entries = useKeymapSelector((keymap: OpenTuiKeymap) => + keymap + .getCommandEntries({ + visibility: "reachable", + namespace: "palette", + }) + .filter(isVisiblePaletteCommand), + ) + + const run = (command: string) => { + keymap.dispatchCommand(command) + } + + const slashes = createMemo(() => + entries().flatMap((entry) => { + const slashName = entry.command.slashName + if (typeof slashName !== "string" || !slashName) return [] + const slashAliases = entry.command.slashAliases + return { + display: `/${slashName}`, + description: + typeof entry.command.desc === "string" + ? entry.command.desc + : typeof entry.command.title === "string" + ? entry.command.title + : undefined, + aliases: Array.isArray(slashAliases) + ? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`) + : undefined, + onSelect: () => run(entry.command.name), + } + }), + ) + + const value: CommandPaletteContext = { + run, + show() { + dialog.replace(() => ) + }, + slashes, + suspend(enabled: boolean) { + setSuspendCount((count) => Math.max(0, count + (enabled ? 1 : -1))) + }, + get suspended() { + return suspendCount() > 0 || dialog.stack.length > 0 + }, + matcher: reactiveMatcherFromSignal(() => suspendCount() === 0 && dialog.stack.length === 0), + } + + return {props.children} +} + +export function useCommandPalette() { + const value = useContext(ctx) + if (!value) throw new Error("CommandPalette context must be used within a CommandPaletteProvider") + return value +} + +function CommandPaletteDialog(props: { run(command: string): void }) { + const config = useTuiConfig() + const entries = useKeymapSelector((keymap: OpenTuiKeymap) => { + const query = { + namespace: "palette", + } + const reachable = keymap + .getCommandEntries({ + ...query, + visibility: "reachable", + }) + .filter(isVisiblePaletteCommand) + const registeredBindings = keymap.getCommandBindings({ + visibility: "registered", + commands: reachable.map((entry) => entry.command.name), + }) + + return reachable.map((entry) => ({ + ...entry, + bindings: registeredBindings.get(entry.command.name) ?? entry.bindings, + })) + }) + const options = createMemo(() => + entries().map((entry) => ({ + title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name, + description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, + category: typeof entry.command.category === "string" ? entry.command.category : undefined, + footer: formatKeyBindings(entry.bindings, config), + value: entry.command.name, + suggested: isSuggestedPaletteCommand(entry), + onSelect: (dialog: DialogContext) => { + dialog.clear() + props.run(entry.command.name) + }, + })), + ) + + let ref: DialogSelectRef + const list = () => { + if (ref?.filter) return options() + return [ + ...options() + .filter((option) => option.suggested) + .map((option) => ({ + ...option, + value: `suggested:${option.value}`, + category: "Suggested", + })), + ...options(), + ] + } + + return (ref = value)} title="Commands" options={list()} /> +} + +export function useCommandSlashes(): Accessor { + return useCommandPalette().slashes +} diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx deleted file mode 100644 index 2c1ab245a50c..000000000000 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { createMemo } from "solid-js" -import { Keybind } from "@/util/keybind" -import { pipe, mapValues } from "remeda" -import type { TuiConfig } from "@/cli/cmd/tui/config/tui" -import type { ParsedKey, Renderable } from "@opentui/core" -import { createStore } from "solid-js/store" -import { useKeyboard, useRenderer } from "@opentui/solid" -import { createSimpleContext } from "./helper" -import { useTuiConfig } from "./tui-config" - -export type KeybindKey = keyof NonNullable & string - -export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({ - name: "Keybind", - init: () => { - const config = useTuiConfig() - const keybinds = createMemo>(() => { - return pipe( - (config.keybinds ?? {}) as Record, - mapValues((value) => Keybind.parse(value)), - ) - }) - const [store, setStore] = createStore({ - leader: false, - }) - const renderer = useRenderer() - - let focus: Renderable | null - let timeout: NodeJS.Timeout - function leader(active: boolean) { - if (active) { - setStore("leader", true) - focus = renderer.currentFocusedRenderable - focus?.blur() - if (timeout) clearTimeout(timeout) - timeout = setTimeout(() => { - if (!store.leader) return - leader(false) - if (!focus || focus.isDestroyed) return - focus.focus() - }, 2000) - return - } - - if (!active) { - if (focus && !renderer.currentFocusedRenderable) { - focus.focus() - } - setStore("leader", false) - } - } - - useKeyboard(async (evt) => { - if (!store.leader && result.match("leader", evt)) { - leader(true) - return - } - - if (store.leader && evt.name) { - setImmediate(() => { - if (focus && renderer.currentFocusedRenderable === focus) { - focus.focus() - } - leader(false) - }) - } - }) - - const result = { - get all() { - return keybinds() - }, - get leader() { - return store.leader - }, - parse(evt: ParsedKey): Keybind.Info { - // Handle special case for Ctrl+Underscore (represented as \x1F) - if (evt.name === "\x1F") { - return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader) - } - return Keybind.fromParsedKey(evt, store.leader) - }, - match(key: string, evt: ParsedKey) { - const list = keybinds()[key] ?? Keybind.parse(key) - if (!list.length) return false - const parsed: Keybind.Info = result.parse(evt) - for (const item of list) { - if (Keybind.match(item, parsed)) { - return true - } - } - return false - }, - print(key: string) { - const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0) - if (!first) return "" - const text = Keybind.toString(first) - const lead = keybinds().leader?.[0] - if (!lead) return text - return text.replace("", Keybind.toString(lead)) - }, - } - return result - }, -}) diff --git a/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts b/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts deleted file mode 100644 index a84e10128c39..000000000000 --- a/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ParsedKey } from "@opentui/core" - -export type PluginKeybindMap = Record - -type Base = { - match: (key: string, evt: ParsedKey) => boolean - print: (key: string) => string -} - -export type PluginKeybind = { - readonly all: PluginKeybindMap - get: (name: string) => string - match: (name: string, evt: ParsedKey) => boolean - print: (name: string) => string -} - -const txt = (value: unknown) => { - if (typeof value !== "string") return - if (!value.trim()) return - return value -} - -export function createPluginKeybind( - base: Base, - defaults: PluginKeybindMap, - overrides?: Record, -): PluginKeybind { - const all = Object.freeze( - Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])), - ) - const get = (name: string) => all[name] ?? name - - return { - get all() { - return all - }, - get, - match: (name, evt) => base.match(get(name), evt), - print: (name) => base.print(get(name)), - } -} diff --git a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx index 05fdd025c7ac..9691ae595969 100644 --- a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx @@ -3,7 +3,7 @@ import { createSimpleContext } from "./helper" export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({ name: "TuiConfig", - init: (props: { config: TuiConfig.Info }) => { + init: (props: { config: TuiConfig.Resolved }) => { return props.config }, }) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx index 26c03ee347bb..a9542fc127a5 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx @@ -1,10 +1,27 @@ -import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" import { createMemo, Show } from "solid-js" import { Tips } from "./tips-view" +import { useBindings } from "../../keymap" const id = "internal:home-tips" -function View(props: { show: boolean; connected: boolean }) { +function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) { + useBindings(() => ({ + commands: [ + { + name: "tips.toggle", + title: props.hidden ? "Show tips" : "Hide tips", + category: "System", + namespace: "palette", + run() { + props.api.kv.set("tips_hidden", !props.api.kv.get("tips_hidden", false)) + props.api.ui.dialog.clear() + }, + }, + ], + bindings: props.api.tuiConfig.keymap.sections.home_tips, + })) + return ( @@ -15,20 +32,6 @@ function View(props: { show: boolean; connected: boolean }) { } const tui: TuiPlugin = async (api) => { - api.command.register(() => [ - { - title: api.kv.get("tips_hidden", false) ? "Show tips" : "Hide tips", - value: "tips.toggle", - keybind: "tips_toggle", - category: "System", - hidden: api.route.current.name !== "home", - onSelect() { - api.kv.set("tips_hidden", !api.kv.get("tips_hidden", false)) - api.ui.dialog.clear() - }, - }, - ]) - api.slots.register({ order: 100, slots: { @@ -41,7 +44,7 @@ const tui: TuiPlugin = async (api) => { ), ) const show = createMemo(() => (!first() || !connected()) && !hidden()) - return + return )} @@ -209,10 +203,10 @@ function View(props: { api: TuiPluginApi }) { options={rows()} current={cur()} onMove={(item) => setCur(item.value)} - keybind={[ + actions={[ { title: "toggle", - keybind: key, + command: "dialog.action.toggle", disabled: lock(), onTrigger: (item) => { setCur(item.value) @@ -221,13 +215,14 @@ function View(props: { api: TuiPluginApi }) { }, { title: "install", - keybind: add, + command: "plugin.dialog.install", disabled: lock(), onTrigger: () => { showInstall(props.api) }, }, ]} + bindings={props.api.tuiConfig.keymap.pick("plugins", ["plugin.dialog.install"])} onSelect={(item) => { setCur(item.value) flip(item.value) @@ -241,25 +236,29 @@ function show(api: TuiPluginApi) { } const tui: TuiPlugin = async (api) => { - api.command.register(() => [ - { - title: "Plugins", - value: "plugins.list", - keybind: "plugin_manager", - category: "System", - onSelect() { - show(api) + api.keymap.registerLayer({ + commands: [ + { + name: "plugins.list", + title: "Plugins", + category: "System", + namespace: "palette", + run() { + show(api) + }, }, - }, - { - title: "Install plugin", - value: "plugins.install", - category: "System", - onSelect() { - showInstall(api) + { + name: "plugins.install", + title: "Install plugin", + category: "System", + namespace: "palette", + run() { + showInstall(api) + }, }, - }, - ]) + ], + bindings: api.tuiConfig.keymap.omit("plugins", ["plugin.dialog.install"]), + }) } const plugin: TuiPluginModule & { id: string } = { diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 2e5cea9804e3..0d899a8bae67 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -4,8 +4,9 @@ import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" import { useTheme } from "@tui/context/theme" import { useLocal } from "@tui/context/local" -import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" +import { useBindings } from "../../keymap" import { Locale } from "@/util/locale" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import path from "path" @@ -53,12 +54,16 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { void sync.session.message.sync(props.sessionID) }) - useKeyboard((event) => { - if (event.name !== "escape") return - event.preventDefault() - event.stopPropagation() - props.api.route.navigate("session", { sessionID: props.sessionID }) - }) + useBindings(() => ({ + bindings: [ + { + key: "escape", + cmd() { + props.api.route.navigate("session", { sessionID: props.sessionID }) + }, + }, + ], + })) return ( @@ -1113,21 +1118,24 @@ const tui: TuiPlugin = async (api) => { }, ]) - api.command.register(() => [ - { - title: "View v2 session messages", - value: route, - category: "Debug", - suggested: api.route.current.name === "session", - enabled: api.route.current.name === "session", - onSelect() { - const sessionID = currentSessionID(api) - if (!sessionID) return - api.route.navigate(route, { sessionID }) - api.ui.dialog.clear() + api.keymap.registerLayer({ + commands: [ + { + name: route, + title: "View v2 session messages", + category: "Debug", + namespace: "palette", + suggested: () => api.route.current.name === "session", + enabled: () => api.route.current.name === "session", + run() { + const sessionID = currentSessionID(api) + if (!sessionID) return + api.route.navigate(route, { sessionID }) + api.ui.dialog.clear() + }, }, - }, - ]) + ], + }) } const plugin: TuiPluginModule & { id: string } = { diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx new file mode 100644 index 000000000000..0d65057d79fe --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -0,0 +1,91 @@ +import { type CliRenderer } from "@opentui/core" +import * as addons from "@opentui/keymap/addons/opentui" +import { + formatCommandBindings as formatCommandBindingsExtra, + formatKeySequence as formatKeySequenceExtra, +} from "@opentui/keymap/extras" +import { + KeymapProvider, + reactiveMatcherFromSignal, + useBindings, + useKeymap, + useKeymapSelector, +} from "@opentui/keymap/solid" +import type { Accessor } from "solid-js" +import type { TuiConfig } from "./config/tui" +import { useTuiConfig } from "./context/tui-config" + +export const LEADER_TOKEN = "leader" + +export const OpencodeKeymapProvider = KeymapProvider +export const useOpencodeKeymap = useKeymap + +export { reactiveMatcherFromSignal, useBindings, useKeymapSelector } + +export type OpenTuiKeymap = ReturnType + +function formatOptions(config: TuiConfig.Resolved) { + return { + tokenDisplay: { + [LEADER_TOKEN]: config.keymap.leader, + }, + keyNameAliases: { + pageup: "pgup", + pagedown: "pgdn", + delete: "del", + }, + modifierAliases: { + meta: "alt", + }, + } as const +} + +export function formatKeySequence(parts: Parameters[0], config: TuiConfig.Resolved) { + return formatKeySequenceExtra(parts, formatOptions(config)) +} + +export function formatKeyBindings( + bindings: Parameters[0], + config: TuiConfig.Resolved, +) { + return formatCommandBindingsExtra(bindings, formatOptions(config)) +} + +export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) { + const offCommaBindings = addons.registerCommaBindings(keymap) + const offBaseLayout = addons.registerBaseLayoutFallback(keymap) + const offLeader = addons.registerTimedLeader(keymap, { + trigger: config.keymap.leader, + name: LEADER_TOKEN, + timeoutMs: config.keymap.leader_timeout, + }) + const offEscape = addons.registerEscapeClearsPendingSequence(keymap) + const offBackspace = addons.registerBackspacePopsPendingSequence(keymap) + const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, { + enabled: () => renderer.currentFocusedEditor !== null, + bindings: config.keymap.sections.input, + }) + + return () => { + offInputBindings() + offBackspace() + offEscape() + offLeader() + offBaseLayout() + offCommaBindings() + } +} + +export function useCommandShortcut(command: string): Accessor { + const config = useTuiConfig() + return useKeymapSelector((keymap) => + formatKeySequence( + keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence, + config, + ), + ) +} + +export function useLeaderActive(): Accessor { + return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN) +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 25ea3ac9edb4..7b7ce0bbb533 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -1,15 +1,12 @@ -import type { ParsedKey } from "@opentui/core" import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui" -import type { useCommandDialog } from "@tui/component/dialog-command" import type { useEvent } from "@tui/context/event" -import type { useKeybind } from "@tui/context/keybind" import type { useRoute } from "@tui/context/route" import type { useSDK } from "@tui/context/sdk" import type { useSync } from "@tui/context/sync" import type { useTheme } from "@tui/context/theme" import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog" import type { TuiConfig } from "@/cli/cmd/tui/config/tui" -import { createPluginKeybind } from "../context/plugin-keybinds" +import type { useOpencodeKeymap } from "../keymap" import type { useKV } from "../context/kv" import { DialogAlert } from "../ui/dialog-alert" import { DialogConfirm } from "../ui/dialog-confirm" @@ -19,6 +16,7 @@ import { Prompt } from "../component/prompt" import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import * as Keymap from "../keymap" type RouteEntry = { key: symbol @@ -28,10 +26,9 @@ type RouteEntry = { export type RouteMap = Map type Input = { - command: ReturnType - tuiConfig: TuiConfig.Info + tuiConfig: TuiConfig.Resolved dialog: ReturnType - keybind: ReturnType + keymap: ReturnType kv: ReturnType route: ReturnType routes: RouteMap @@ -201,20 +198,17 @@ export function createTuiApi(input: Input): TuiPluginApi { return () => {} }, } - return { app: appApi(), - command: { - register(cb) { - return input.command.register(() => cb()) - }, - trigger(value) { - input.command.trigger(value) + keys: { + formatSequence(parts) { + return Keymap.formatKeySequence(parts, input.tuiConfig) }, - show() { - input.command.show() + formatBindings(bindings) { + return Keymap.formatKeyBindings(bindings, input.tuiConfig) }, }, + keymap: input.keymap, route: { register(list) { return routeRegister(input.routes, list, input.bump) @@ -306,17 +300,6 @@ export function createTuiApi(input: Input): TuiPluginApi { }, }, }, - keybind: { - match(key, evt: ParsedKey) { - return input.keybind.match(key, evt) - }, - print(key) { - return input.keybind.print(key) - }, - create(defaults, overrides) { - return createPluginKeybind(input.keybind, defaults, overrides) - }, - }, get tuiConfig() { return input.tuiConfig }, diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 73193d142e1d..a43f62deecff 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -1,4 +1,5 @@ -import "@opentui/solid/runtime-plugin-support" +import { runtimeModules as keymapRuntimeModules } from "@opentui/keymap/runtime-modules" +import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure" import { type TuiDispose, type TuiPlugin, @@ -39,6 +40,8 @@ import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "./slots" import { ConfigPlugin } from "@/config/plugin" +ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) + type PluginLoad = { options: ConfigPlugin.Options | undefined spec: string @@ -70,6 +73,36 @@ type PluginEntry = { scope?: PluginScope } +const ScopedKeymapMethods = new Set([ + "acquireResource", + "registerLayer", + "registerLayerFields", + "prependLayerBindingsTransformer", + "appendLayerBindingsTransformer", + "prependBindingTransformer", + "appendBindingTransformer", + "prependBindingParser", + "appendBindingParser", + "registerToken", + "registerSequencePattern", + "prependBindingExpander", + "appendBindingExpander", + "registerBindingFields", + "registerCommandFields", + "prependCommandTransformer", + "appendCommandTransformer", + "prependCommandResolver", + "appendCommandResolver", + "prependLayerAnalyzer", + "appendLayerAnalyzer", + "intercept", + "on", + "prependEventMatchResolver", + "appendEventMatchResolver", + "prependDisambiguationResolver", + "appendDisambiguationResolver", +]) + type RuntimeState = { directory: string api: Api @@ -104,6 +137,25 @@ function warn(message: string, data: Record) { console.warn(`[tui.plugin] ${message}`, data) } +function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope): TuiPluginApi["keymap"] { + const cache = new Map() + return new Proxy(keymap, { + get(target, prop) { + const value = Reflect.get(target, prop, target) + if (typeof value !== "function") return value + if (cache.has(prop)) return cache.get(prop) + const fn = ScopedKeymapMethods.has(prop) + ? (...args: unknown[]) => { + const dispose = (value as (...args: unknown[]) => unknown).apply(target, args) + return scope.track(typeof dispose === "function" ? (dispose as () => void) : undefined) + } + : (...args: unknown[]) => (value as (...args: unknown[]) => unknown).apply(target, args) + cache.set(prop, fn) + return fn + }, + }) +} + type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" } function runCleanup(fn: () => unknown, ms: number): Promise { @@ -327,14 +379,16 @@ function createPluginScope(load: PluginLoad, id: string) { const track = (fn: (() => void) | undefined) => { if (!fn) return () => {} - const off = onDispose(fn) let drop = false - return () => { + let off = () => {} + const wrapped = () => { if (drop) return drop = true off() fn() } + off = onDispose(wrapped) + return wrapped } const lifecycle: TuiPluginApi["lifecycle"] = { @@ -395,7 +449,7 @@ function readPluginEnabledMap(value: unknown) { ) } -function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) { +function pluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) { return { ...readPluginEnabledMap(config.plugin_enabled), ...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})), @@ -484,17 +538,6 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop const api = runtime.api const host = runtime.slots const load = plugin.load - const command: TuiPluginApi["command"] = { - register(cb) { - return scope.track(api.command.register(cb)) - }, - trigger(value) { - api.command.trigger(value) - }, - show() { - api.command.show() - }, - } const route: TuiPluginApi["route"] = { register(list) { @@ -518,6 +561,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop }, } + const keymap = createScopedKeymap(api.keymap, scope) + let count = 0 const slots: TuiPluginApi["slots"] = { @@ -531,10 +576,10 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop return { app: api.app, - command, + keys: api.keys, + keymap, route, ui: api.ui, - keybind: api.keybind, tuiConfig: api.tuiConfig, kv: api.kv, state: api.state, @@ -580,7 +625,7 @@ function addPluginEntry(state: RuntimeState, plugin: PluginEntry) { return true } -function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) { +function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) { const map = pluginEnabledState(state, config) for (const plugin of state.plugins) { const enabled = map[plugin.id] @@ -923,7 +968,7 @@ let loaded: Promise | undefined let runtime: RuntimeState | undefined export const Slot = View -export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) { +export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) { const cwd = process.cwd() if (loaded) { if (dir !== cwd) { @@ -972,7 +1017,7 @@ export async function dispose() { } } -async function load(input: { api: Api; config: TuiConfig.Info }) { +async function load(input: { api: Api; config: TuiConfig.Resolved }) { const { api, config } = input const cwd = process.cwd() const slots = setupSlots(api) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d43edd2dd5d7..81df91805989 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -49,12 +49,10 @@ import type { WebSearchTool } from "@/tool/websearch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" import type { SkillTool } from "@/tool/skill" -import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useEditorContext } from "@tui/context/editor" -import { useCommandDialog } from "@tui/component/dialog-command" import type { DialogContext } from "@tui/ui/dialog" -import { useKeybind } from "@tui/context/keybind" import { useDialog } from "../../ui/dialog" import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" @@ -90,6 +88,8 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { DialogGoUpsell } from "../../component/dialog-go-upsell" import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" +import { useCommandPalette } from "../../context/command-palette" +import { useBindings, useCommandShortcut } from "../../keymap" addDefaultParsers(parsers.parsers) @@ -124,6 +124,9 @@ export function Session() { const event = useEvent() const project = useProject() const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig const kv = useKV() const { theme } = useTheme() const promptRef = usePromptRef() @@ -250,7 +253,7 @@ export function Session() { seeded = true r.set(route.prompt) } - const keybind = useKeybind() + const command = useCommandPalette() const dialog = useDialog() const renderer = useRenderer() @@ -271,7 +274,6 @@ export function Session() { }) }) - // Allow exit when in child session (prompt is hidden) const exit = useExit() createEffect(() => { @@ -293,13 +295,6 @@ export function Session() { ) }) - useKeyboard((evt) => { - if (!session()?.parentID) return - if (keybind.match("app_exit", evt)) { - void exit() - } - }) - // Helper: Find next visible message boundary in direction const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { const children = scroll.getChildren() @@ -382,26 +377,24 @@ export function Session() { } } - function childSessionHandler(func: (dialog: DialogContext) => void) { - return (dialog: DialogContext) => { + function childSessionHandler(func: () => void) { + return () => { if (!session()?.parentID || dialog.stack.length > 0) return - func(dialog) + func() } } - const command = useCommandDialog() - command.register(() => [ + const sessionCommandList = createMemo(() => [ { title: session()?.share?.url ? "Copy share link" : "Share session", value: "session.share", suggested: route.type === "session", - keybind: "session_share", category: "Session", enabled: sync.data.config.share !== "disabled", slash: { name: "share", }, - onSelect: async (dialog) => { + run: async () => { const copy = (url: string) => Clipboard.copy(url) .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) @@ -434,24 +427,22 @@ export function Session() { { title: "Rename session", value: "session.rename", - keybind: "session_rename", category: "Session", slash: { name: "rename", }, - onSelect: (dialog) => { + run: () => { dialog.replace(() => ) }, }, { title: "Jump to message", value: "session.timeline", - keybind: "session_timeline", category: "Session", slash: { name: "timeline", }, - onSelect: (dialog) => { + run: () => { dialog.replace(() => ( { @@ -469,12 +460,11 @@ export function Session() { { title: "Fork session", value: "session.fork", - keybind: "session_fork", category: "Session", slash: { name: "fork", }, - onSelect: (dialog) => { + run: () => { dialog.replace(() => ( { @@ -492,13 +482,12 @@ export function Session() { { title: "Compact session", value: "session.compact", - keybind: "session_compact", category: "Session", slash: { name: "compact", aliases: ["summarize"], }, - onSelect: (dialog) => { + run: () => { const selectedModel = local.model.current() if (!selectedModel) { toast.show({ @@ -519,13 +508,12 @@ export function Session() { { title: "Unshare session", value: "session.unshare", - keybind: "session_unshare", category: "Session", enabled: !!session()?.share?.url, slash: { name: "unshare", }, - onSelect: async (dialog) => { + run: async () => { await sdk.client.session .unshare({ sessionID: route.sessionID, @@ -543,12 +531,11 @@ export function Session() { { title: "Undo previous message", value: "session.undo", - keybind: "messages_undo", category: "Session", slash: { name: "undo", }, - onSelect: async (dialog) => { + run: async () => { const status = sync.data.session_status?.[route.sessionID] if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) const revert = session()?.revert?.messageID @@ -581,13 +568,12 @@ export function Session() { { title: "Redo", value: "session.redo", - keybind: "messages_redo", category: "Session", enabled: !!session()?.revert?.messageID, slash: { name: "redo", }, - onSelect: (dialog) => { + run: () => { dialog.clear() const messageID = session()?.revert?.messageID if (!messageID) return @@ -608,9 +594,8 @@ export function Session() { { title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", value: "session.sidebar.toggle", - keybind: "sidebar_toggle", category: "Session", - onSelect: (dialog) => { + run: () => { batch(() => { const isVisible = sidebarVisible() setSidebar(() => (isVisible ? "hide" : "auto")) @@ -622,9 +607,8 @@ export function Session() { { title: conceal() ? "Disable code concealment" : "Enable code concealment", value: "session.toggle.conceal", - keybind: "messages_toggle_conceal", category: "Session", - onSelect: (dialog) => { + run: () => { setConceal((prev) => !prev) dialog.clear() }, @@ -637,7 +621,7 @@ export function Session() { name: "timestamps", aliases: ["toggle-timestamps"], }, - onSelect: (dialog) => { + run: () => { setTimestamps((prev) => (prev === "show" ? "hide" : "show")) dialog.clear() }, @@ -645,13 +629,12 @@ export function Session() { { title: showThinking() ? "Hide thinking" : "Show thinking", value: "session.toggle.thinking", - keybind: "display_thinking", category: "Session", slash: { name: "thinking", aliases: ["toggle-thinking"], }, - onSelect: (dialog) => { + run: () => { setShowThinking((prev) => !prev) dialog.clear() }, @@ -659,9 +642,8 @@ export function Session() { { title: showDetails() ? "Hide tool details" : "Show tool details", value: "session.toggle.actions", - keybind: "tool_details", category: "Session", - onSelect: (dialog) => { + run: () => { setShowDetails((prev) => !prev) dialog.clear() }, @@ -669,9 +651,8 @@ export function Session() { { title: "Toggle session scrollbar", value: "session.toggle.scrollbar", - keybind: "scrollbar_toggle", category: "Session", - onSelect: (dialog) => { + run: () => { setShowScrollbar((prev) => !prev) dialog.clear() }, @@ -680,7 +661,7 @@ export function Session() { title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output", value: "session.toggle.generic_tool_output", category: "Session", - onSelect: (dialog) => { + run: () => { setShowGenericToolOutput((prev) => !prev) dialog.clear() }, @@ -688,10 +669,9 @@ export function Session() { { title: "Page up", value: "session.page.up", - keybind: "messages_page_up", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollBy(-scroll.height / 2) dialog.clear() }, @@ -699,10 +679,9 @@ export function Session() { { title: "Page down", value: "session.page.down", - keybind: "messages_page_down", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollBy(scroll.height / 2) dialog.clear() }, @@ -710,10 +689,9 @@ export function Session() { { title: "Line up", value: "session.line.up", - keybind: "messages_line_up", category: "Session", - disabled: true, - onSelect: (dialog) => { + enabled: false, + run: () => { scroll.scrollBy(-1) dialog.clear() }, @@ -721,10 +699,9 @@ export function Session() { { title: "Line down", value: "session.line.down", - keybind: "messages_line_down", category: "Session", - disabled: true, - onSelect: (dialog) => { + enabled: false, + run: () => { scroll.scrollBy(1) dialog.clear() }, @@ -732,10 +709,9 @@ export function Session() { { title: "Half page up", value: "session.half.page.up", - keybind: "messages_half_page_up", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollBy(-scroll.height / 4) dialog.clear() }, @@ -743,10 +719,9 @@ export function Session() { { title: "Half page down", value: "session.half.page.down", - keybind: "messages_half_page_down", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollBy(scroll.height / 4) dialog.clear() }, @@ -754,10 +729,9 @@ export function Session() { { title: "First message", value: "session.first", - keybind: "messages_first", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollTo(0) dialog.clear() }, @@ -765,10 +739,9 @@ export function Session() { { title: "Last message", value: "session.last", - keybind: "messages_last", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollTo(scroll.scrollHeight) dialog.clear() }, @@ -776,10 +749,9 @@ export function Session() { { title: "Jump to last user message", value: "session.messages_last_user", - keybind: "messages_last_user", category: "Session", hidden: true, - onSelect: () => { + run: () => { const messages = sync.data.message[route.sessionID] if (!messages || !messages.length) return @@ -808,25 +780,22 @@ export function Session() { { title: "Next message", value: "session.message.next", - keybind: "messages_next", category: "Session", hidden: true, - onSelect: (dialog) => scrollToMessage("next", dialog), + run: () => scrollToMessage("next", dialog), }, { title: "Previous message", value: "session.message.previous", - keybind: "messages_previous", category: "Session", hidden: true, - onSelect: (dialog) => scrollToMessage("prev", dialog), + run: () => scrollToMessage("prev", dialog), }, { title: "Copy last assistant message", value: "messages.copy", - keybind: "messages_copy", category: "Session", - onSelect: (dialog) => { + run: () => { const revertID = session()?.revert?.messageID const lastAssistantMessage = messages().findLast( (msg) => msg.role === "assistant" && (!revertID || msg.id < revertID), @@ -871,7 +840,7 @@ export function Session() { slash: { name: "copy", }, - onSelect: async (dialog) => { + run: async () => { try { const sessionData = session() if (!sessionData) return @@ -897,12 +866,11 @@ export function Session() { { title: "Export session transcript", value: "session.export", - keybind: "session_export", category: "Session", slash: { name: "export", }, - onSelect: async (dialog) => { + run: async () => { try { const sessionData = session() if (!sessionData) return @@ -959,10 +927,9 @@ export function Session() { { title: "Go to child session", value: "session.child.first", - keybind: "session_child_first", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { moveFirstChild() dialog.clear() }, @@ -970,11 +937,10 @@ export function Session() { { title: "Go to parent session", value: "session.parent", - keybind: "session_parent", category: "Session", hidden: true, enabled: !!session()?.parentID, - onSelect: childSessionHandler((dialog) => { + run: childSessionHandler(() => { const parentID = session()?.parentID if (parentID) { navigate({ @@ -988,11 +954,10 @@ export function Session() { { title: "Next child session", value: "session.child.next", - keybind: "session_child_cycle", category: "Session", hidden: true, enabled: !!session()?.parentID, - onSelect: childSessionHandler((dialog) => { + run: childSessionHandler(() => { moveChild(1) dialog.clear() }), @@ -1000,17 +965,36 @@ export function Session() { { title: "Previous child session", value: "session.child.previous", - keybind: "session_child_cycle_reverse", category: "Session", hidden: true, enabled: !!session()?.parentID, - onSelect: childSessionHandler((dialog) => { + run: childSessionHandler(() => { moveChild(-1) dialog.clear() }), }, ]) + const sessionCommands = createMemo(() => + sessionCommandList().map((command) => ({ + namespace: "palette", + name: command.value, + desc: "description" in command ? command.description : undefined, + slashName: "slash" in command ? command.slash?.name : undefined, + slashAliases: "slash" in command ? command.slash?.aliases : undefined, + ...command, + })), + ) + + useBindings(() => ({ + commands: sessionCommands(), + })) + + useBindings(() => ({ + enabled: command.matcher, + bindings: sections.session, + })) + const revertInfo = createMemo(() => session()?.revert) const revertMessageID = createMemo(() => revertInfo()?.messageID) @@ -1082,7 +1066,8 @@ export function Session() { {(function () { - const command = useCommandDialog() + const command = useCommandPalette() + const redoShortcut = useCommandShortcut("session.redo") const [hover, setHover] = createSignal(false) const dialog = useDialog() @@ -1093,7 +1078,7 @@ export function Session() { "Are you sure you want to restore the reverted messages?", ) if (confirmed) { - command.trigger("session.redo") + command.run("session.redo") } } @@ -1116,7 +1101,7 @@ export function Session() { > {revert()!.reverted.length} message reverted - {keybind.print("messages_redo")} or /redo to + {redoShortcut()} or /redo to restore @@ -1370,7 +1355,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las return props.message.time.completed - user.time.created }) - const keybind = useKeybind() + const childShortcut = useCommandShortcut("session.child.first") return ( <> @@ -1392,7 +1377,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las x.type === "tool" && x.tool === "task")}> - {keybind.print("session_child_first")} + {childShortcut()} view subagents diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index e7e4c7cea303..5e7e80b66aea 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -1,24 +1,22 @@ import { createStore } from "solid-js/store" -import { createMemo, For, Match, Show, Switch } from "solid-js" -import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js" +import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" -import { useKeybind } from "../../context/keybind" import { useTheme, selectedForeground } from "../../context/theme" import type { PermissionRequest } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" import { useSync } from "../../context/sync" -import { useTextareaKeybindings } from "../../component/textarea-keybindings" import { useProject } from "../../context/project" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" -import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { Global } from "@opencode-ai/core/global" import { ShellID } from "@/tool/shell/id" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" +import { useBindings, useCommandShortcut } from "../../keymap" type PermissionStage = "permission" | "always" | "reject" @@ -463,25 +461,27 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) { let input: TextareaRenderable const { theme } = useTheme() - const keybind = useKeybind() - const textareaKeybindings = useTextareaKeybindings() + const tuiConfig = useTuiConfig() + const keymapConfig = tuiConfig.keymap const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() - - useKeyboard((evt) => { - if (dialog.stack.length > 0) return - - if (evt.name === "escape" || keybind.match("app_exit", evt)) { - evt.preventDefault() - props.onCancel() - return - } - if (evt.name === "return") { - evt.preventDefault() - props.onConfirm(input.plainText) - } - }) + useBindings(() => ({ + enabled: dialog.stack.length === 0, + commands: [ + { + name: "permission.reject.cancel", + run() { + props.onCancel() + }, + }, + ], + bindings: [ + { key: "escape", cmd: () => props.onCancel() }, + ...keymapConfig.pick("permission", ["permission.reject.cancel"]), + { key: "return", cmd: () => props.onConfirm(input.plainText) }, + ], + })) return ( void; onCancel: ( textColor={theme.text} focusedTextColor={theme.text} cursorColor={theme.primary} - keyBindings={textareaKeybindings()} /> @@ -545,50 +544,75 @@ function Prompt>(props: { onSelect: (option: keyof T) => void }) { const { theme } = useTheme() - const keybind = useKeybind() + const tuiConfig = useTuiConfig() + const keymapConfig = tuiConfig.keymap const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ selected: keys[0], expanded: false, }) - const diffKey = Keybind.parse("ctrl+f")[0] const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() - - useKeyboard((evt) => { - if (dialog.stack.length > 0) return - - if (evt.name === "left" || evt.name == "h") { - evt.preventDefault() - const idx = keys.indexOf(store.selected) - const next = keys[(idx - 1 + keys.length) % keys.length] - setStore("selected", next) - } - - if (evt.name === "right" || evt.name == "l") { - evt.preventDefault() - const idx = keys.indexOf(store.selected) - const next = keys[(idx + 1) % keys.length] - setStore("selected", next) - } - - if (evt.name === "return") { - evt.preventDefault() - props.onSelect(store.selected) - } - - if (props.escapeKey && (evt.name === "escape" || keybind.match("app_exit", evt))) { - evt.preventDefault() - props.onSelect(props.escapeKey) - } - - if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) { - evt.preventDefault() - evt.stopPropagation() - setStore("expanded", (v) => !v) - } - }) + const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen") + + useBindings(() => ({ + enabled: dialog.stack.length === 0, + commands: [ + { + name: "permission.prompt.escape", + run() { + if (!props.escapeKey) return + props.onSelect(props.escapeKey) + }, + }, + { + name: "permission.prompt.fullscreen", + run() { + if (!props.fullscreen) return + setStore("expanded", (v) => !v) + }, + }, + ], + bindings: [ + { + key: "left", + cmd: () => { + const idx = keys.indexOf(store.selected) + const next = keys[(idx - 1 + keys.length) % keys.length] + setStore("selected", next) + }, + }, + { + key: "h", + cmd: () => { + const idx = keys.indexOf(store.selected) + const next = keys[(idx - 1 + keys.length) % keys.length] + setStore("selected", next) + }, + }, + { + key: "right", + cmd: () => { + const idx = keys.indexOf(store.selected) + const next = keys[(idx + 1) % keys.length] + setStore("selected", next) + }, + }, + { + key: "l", + cmd: () => { + const idx = keys.indexOf(store.selected) + const next = keys[(idx + 1) % keys.length] + setStore("selected", next) + }, + }, + { key: "return", cmd: () => props.onSelect(store.selected) }, + ...(props.escapeKey ? [{ key: "escape", cmd: () => props.onSelect(props.escapeKey!) }] : []), + ...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []), + ...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []), + ], + })) const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen")) useRenderer() @@ -661,7 +685,7 @@ function Prompt>(props: { - {"ctrl+f"} {hint()} + {fullscreenHint()} {hint()} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 3ff95b4bb885..617ede6395b4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -1,20 +1,22 @@ import { createStore } from "solid-js/store" import { createMemo, createSignal, For, Show } from "solid-js" -import { useKeyboard } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" -import { useKeybind } from "../../context/keybind" import { selectedForeground, tint, useTheme } from "../../context/theme" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" -import { useTextareaKeybindings } from "../../component/textarea-keybindings" import { useDialog } from "../../ui/dialog" +import { useTuiConfig } from "../../context/tui-config" +import { useBindings } from "../../keymap" export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() const { theme } = useTheme() - const keybind = useKeybind() - const bindings = useTextareaKeybindings() + const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig + const keymapConfig = tuiConfig.keymap const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -122,131 +124,124 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const dialog = useDialog() - useKeyboard((evt) => { - // Skip processing if a dialog (e.g., command palette) is open - if (dialog.stack.length > 0) return - - // When editing custom answer textarea - if (store.editing && !confirm()) { - if (evt.name === "escape") { - evt.preventDefault() - setStore("editing", false) - return - } - if (keybind.match("input_clear", evt)) { - evt.preventDefault() - const text = textarea?.plainText ?? "" - if (!text) { + useBindings(() => ({ + enabled: store.editing && !confirm(), + commands: [ + { + name: "question.edit.clear", + run() { + const text = textarea?.plainText ?? "" + if (!text) { + setStore("editing", false) + return + } + textarea?.setText("") + }, + }, + ], + bindings: [ + { + key: "escape", + cmd: () => { setStore("editing", false) - return - } - textarea?.setText("") - return - } - if (evt.name === "return") { - evt.preventDefault() - const text = textarea?.plainText?.trim() ?? "" - const prev = store.custom[store.tab] + }, + }, + ...keymapConfig.pick("question", ["question.edit.clear"]), + { + key: "return", + cmd: () => { + const text = textarea?.plainText?.trim() ?? "" + const prev = store.custom[store.tab] + + if (!text) { + if (prev) { + const inputs = [...store.custom] + inputs[store.tab] = "" + setStore("custom", inputs) + + const answers = [...store.answers] + answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev) + setStore("answers", answers) + } + setStore("editing", false) + return + } - if (!text) { - if (prev) { + if (multi()) { const inputs = [...store.custom] - inputs[store.tab] = "" + inputs[store.tab] = text setStore("custom", inputs) + const existing = store.answers[store.tab] ?? [] + const next = [...existing] + if (prev) { + const index = next.indexOf(prev) + if (index !== -1) next.splice(index, 1) + } + if (!next.includes(text)) next.push(text) const answers = [...store.answers] - answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev) + answers[store.tab] = next setStore("answers", answers) + setStore("editing", false) + return } - setStore("editing", false) - return - } - - if (multi()) { - const inputs = [...store.custom] - inputs[store.tab] = text - setStore("custom", inputs) - const existing = store.answers[store.tab] ?? [] - const next = [...existing] - if (prev) { - const index = next.indexOf(prev) - if (index !== -1) next.splice(index, 1) - } - if (!next.includes(text)) next.push(text) - const answers = [...store.answers] - answers[store.tab] = next - setStore("answers", answers) + pick(text, true) setStore("editing", false) - return - } - - pick(text, true) - setStore("editing", false) - return - } - // Let textarea handle all other keys - return - } - - if (evt.name === "left" || evt.name === "h") { - evt.preventDefault() - selectTab((store.tab - 1 + tabs()) % tabs()) - } - - if (evt.name === "right" || evt.name === "l") { - evt.preventDefault() - selectTab((store.tab + 1) % tabs()) - } - - if (evt.name === "tab") { - evt.preventDefault() - const direction = evt.shift ? -1 : 1 - selectTab((store.tab + direction + tabs()) % tabs()) - } - - if (confirm()) { - if (evt.name === "return") { - evt.preventDefault() - submit() - } - if (evt.name === "escape" || keybind.match("app_exit", evt)) { - evt.preventDefault() - reject() - } - } else { - const opts = options() - const total = opts.length + (custom() ? 1 : 0) - const max = Math.min(total, 9) - const digit = Number(evt.name) - - if (!Number.isNaN(digit) && digit >= 1 && digit <= max) { - evt.preventDefault() - const index = digit - 1 - moveTo(index) - selectOption() - return - } - - if (evt.name === "up" || evt.name === "k") { - evt.preventDefault() - moveTo((store.selected - 1 + total) % total) - } - - if (evt.name === "down" || evt.name === "j") { - evt.preventDefault() - moveTo((store.selected + 1) % total) - } - - if (evt.name === "return") { - evt.preventDefault() - selectOption() - } - - if (evt.name === "escape" || keybind.match("app_exit", evt)) { - evt.preventDefault() - reject() - } + }, + }, + ], + })) + + useBindings(() => { + const opts = options() + const total = opts.length + (custom() ? 1 : 0) + const max = Math.min(total, 9) + + return { + enabled: dialog.stack.length === 0 && !store.editing, + commands: [ + { + name: "question.reject", + run() { + reject() + }, + }, + ], + bindings: [ + { key: "left", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) }, + { key: "h", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) }, + { key: "right", cmd: () => selectTab((store.tab + 1) % tabs()) }, + { key: "l", cmd: () => selectTab((store.tab + 1) % tabs()) }, + { + key: "tab", + cmd: ({ event }: { event: { shift: boolean } }) => { + selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs()) + }, + }, + ...(confirm() + ? [ + { key: "return", cmd: () => submit() }, + { key: "escape", cmd: () => reject() }, + ...sections.question, + ] + : [ + ...Array.from({ length: max }, (_, index) => ({ + key: String(index + 1), + cmd: () => { + moveTo(index) + selectOption() + }, + })), + { key: "up", cmd: () => moveTo((store.selected - 1 + total) % total) }, + { key: "k", cmd: () => moveTo((store.selected - 1 + total) % total) }, + { key: "down", cmd: () => moveTo((store.selected + 1) % total) }, + { key: "j", cmd: () => moveTo((store.selected + 1) % total) }, + { key: "return", cmd: () => selectOption() }, + { key: "escape", cmd: () => reject() }, + ...sections.question, + ]), + ], } }) @@ -394,7 +389,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { textColor={theme.text} focusedTextColor={theme.text} cursorColor={theme.primary} - keyBindings={bindings()} /> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx index c857937d4acb..2a6813ffbedd 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx @@ -4,10 +4,10 @@ import { useSync } from "@tui/context/sync" import { useTheme } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" import type { AssistantMessage } from "@opencode-ai/sdk/v2" -import { useCommandDialog } from "@tui/component/dialog-command" -import { useKeybind } from "../../context/keybind" import { Locale } from "@/util/locale" import { useTerminalDimensions } from "@opentui/solid" +import { useCommandPalette } from "../../context/command-palette" +import { useCommandShortcut } from "../../keymap" export function SubagentFooter() { const route = useRouteData("session") @@ -56,8 +56,10 @@ export function SubagentFooter() { }) const { theme } = useTheme() - const keybind = useKeybind() - const command = useCommandDialog() + const command = useCommandPalette() + const parentShortcut = useCommandShortcut("session.parent") + const previousShortcut = useCommandShortcut("session.child.previous") + const nextShortcut = useCommandShortcut("session.child.next") const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) useTerminalDimensions() @@ -96,31 +98,31 @@ export function SubagentFooter() { setHover("parent")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.parent")} + onMouseUp={() => command.run("session.parent")} backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} > - Parent {keybind.print("session_parent")} + Parent {parentShortcut()} setHover("prev")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.previous")} + onMouseUp={() => command.run("session.child.previous")} backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} > - Prev {keybind.print("session_child_cycle_reverse")} + Prev {previousShortcut()} setHover("next")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.next")} + onMouseUp={() => command.run("session.child.next")} backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} > - Next {keybind.print("session_child_cycle")} + Next {nextShortcut()} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx index fb159115dc51..965c80f362d5 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -1,7 +1,7 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" -import { useKeyboard } from "@opentui/solid" +import { useBindings } from "../keymap" export type DialogAlertProps = { title: string @@ -13,14 +13,17 @@ export function DialogAlert(props: DialogAlertProps) { const dialog = useDialog() const { theme } = useTheme() - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - props.onConfirm?.() - dialog.clear() - } - }) + useBindings(() => ({ + bindings: [ + { + key: "return", + cmd: () => { + props.onConfirm?.() + dialog.clear() + }, + }, + ], + })) return ( diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx index 3870cf816cbb..0a1ce0b34493 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -3,8 +3,8 @@ import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" -import { useKeyboard } from "@opentui/solid" import { Locale } from "@/util/locale" +import { useBindings } from "../keymap" export type DialogConfirmProps = { title: string @@ -23,19 +23,30 @@ export function DialogConfirm(props: DialogConfirmProps) { active: "confirm" as "confirm" | "cancel", }) - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - if (store.active === "confirm") props.onConfirm?.() - if (store.active === "cancel") props.onCancel?.() - dialog.clear() - } - - if (evt.name === "left" || evt.name === "right") { - setStore("active", store.active === "confirm" ? "cancel" : "confirm") - } - }) + useBindings(() => ({ + bindings: [ + { + key: "return", + cmd: () => { + if (store.active === "confirm") props.onConfirm?.() + if (store.active === "cancel") props.onCancel?.() + dialog.clear() + }, + }, + { + key: "left", + cmd: () => { + setStore("active", store.active === "confirm" ? "cancel" : "confirm") + }, + }, + { + key: "right", + cmd: () => { + setStore("active", store.active === "confirm" ? "cancel" : "confirm") + }, + }, + ], + })) return ( @@ -56,7 +67,7 @@ export function DialogConfirm(props: DialogConfirmProps) { paddingLeft={1} paddingRight={1} backgroundColor={key === store.active ? theme.primary : undefined} - onMouseUp={(_evt) => { + onMouseUp={() => { if (key === "confirm") props.onConfirm?.() if (key === "cancel") props.onCancel?.() dialog.clear() diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index b9362db46b28..35d9dec4b04e 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -3,7 +3,7 @@ import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { onMount, Show } from "solid-js" -import { useKeyboard } from "@opentui/solid" +import { useBindings } from "../keymap" export type DialogExportOptionsProps = { defaultFilename: string @@ -33,39 +33,40 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving", }) - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - props.onConfirm?.({ - filename: textarea.plainText, - thinking: store.thinking, - toolDetails: store.toolDetails, - assistantMetadata: store.assistantMetadata, - openWithoutSaving: store.openWithoutSaving, - }) - } - if (evt.name === "tab") { - const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [ - "filename", - "thinking", - "toolDetails", - "assistantMetadata", - "openWithoutSaving", - ] - const currentIndex = order.indexOf(store.active) - const nextIndex = (currentIndex + 1) % order.length - setStore("active", order[nextIndex]) - evt.preventDefault() - } - if (evt.name === "space" || evt.name === " ") { - if (store.active === "thinking") setStore("thinking", !store.thinking) - if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) - if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) - if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving) - evt.preventDefault() - } - }) + useBindings(() => ({ + bindings: [ + { + key: "tab", + cmd: () => { + const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [ + "filename", + "thinking", + "toolDetails", + "assistantMetadata", + "openWithoutSaving", + ] + const currentIndex = order.indexOf(store.active) + const nextIndex = (currentIndex + 1) % order.length + setStore("active", order[nextIndex]) + }, + }, + ], + })) + + useBindings(() => ({ + enabled: store.active !== "filename", + bindings: [ + { + key: "space", + cmd: () => { + if (store.active === "thinking") setStore("thinking", !store.thinking) + if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) + if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) + if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving) + }, + }, + ], + })) onMount(() => { dialog.setSize("medium") @@ -101,7 +102,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { }) }} height={3} - keyBindings={[{ name: "return", action: "submit" }]} ref={(val: TextareaRenderable) => { textarea = val val.traits = { status: "FILENAME" } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx index 24b93b96a77d..b6a394d2def0 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -1,21 +1,19 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "@tui/context/theme" import { useDialog } from "./dialog" -import { useKeyboard } from "@opentui/solid" -import { useKeybind } from "@tui/context/keybind" +import { useBindings, useCommandShortcut } from "../keymap" export function DialogHelp() { const dialog = useDialog() const { theme } = useTheme() - const keybind = useKeybind() + const commandShortcut = useCommandShortcut("command.palette.show") - useKeyboard((evt) => { - if (evt.name === "return" || evt.name === "escape") { - evt.preventDefault() - evt.stopPropagation() - dialog.clear() - } - }) + useBindings(() => ({ + bindings: [ + { key: "return", cmd: () => dialog.clear() }, + { key: "escape", cmd: () => dialog.clear() }, + ], + })) return ( @@ -29,7 +27,7 @@ export function DialogHelp() { - Press {keybind.print("command_list")} to see all available actions and commands in any context. + Press {commandShortcut()} to see all available actions and commands in any context. diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index 92d6d277d0eb..34ab9161f6a8 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -2,7 +2,6 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { Show, createEffect, onMount, type JSX } from "solid-js" -import { useKeyboard } from "@opentui/solid" import { Spinner } from "../component/spinner" export type DialogPromptProps = { @@ -21,20 +20,6 @@ export function DialogPrompt(props: DialogPromptProps) { const { theme } = useTheme() let textarea: TextareaRenderable - useKeyboard((evt) => { - if (props.busy) { - if (evt.name === "escape") return - evt.preventDefault() - evt.stopPropagation() - return - } - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - props.onConfirm?.(textarea.plainText) - } - }) - onMount(() => { dialog.setSize("medium") setTimeout(() => { @@ -79,7 +64,6 @@ export function DialogPrompt(props: DialogPromptProps) { props.onConfirm?.(textarea.plainText) }} height={3} - keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]} ref={(val: TextareaRenderable) => { textarea = val }} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index ef7d4bd3bbd6..cbf5d2dbfcde 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -1,17 +1,24 @@ -import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" +import { + InputRenderable, + RGBA, + ScrollBoxRenderable, + TextAttributes, + type KeyEvent, + type Renderable, +} from "@opentui/core" +import type { Binding } from "@opentui/keymap" import { useTheme, selectedForeground } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js" import { createStore } from "solid-js/store" -import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useTerminalDimensions } from "@opentui/solid" import * as fuzzysort from "fuzzysort" import { isDeepEqual } from "remeda" import { useDialog, type DialogContext } from "@tui/ui/dialog" -import { useKeybind } from "@tui/context/keybind" -import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { getScrollAcceleration } from "../util/scroll" import { useTuiConfig } from "../context/tui-config" +import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap" export interface DialogSelectProps { title: string @@ -24,13 +31,14 @@ export interface DialogSelectProps { onSelect?: (option: DialogSelectOption) => void skipFilter?: boolean renderFilter?: boolean - keybind?: { - keybind?: Keybind.Info + actions?: { + command: string title: string side?: "left" | "right" disabled?: boolean onTrigger: (option: DialogSelectOption) => void }[] + bindings?: readonly Binding[] current?: T } @@ -57,6 +65,9 @@ export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const { theme } = useTheme() const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const [store, setStore] = createStore({ @@ -81,6 +92,25 @@ export function DialogSelect(props: DialogSelectProps) { let input: InputRenderable + const actions = createMemo(() => props.actions ?? []) + const actionBindings = useKeymapSelector((keymap) => + keymap.getCommandBindings({ + visibility: "registered", + commands: actions().map((item) => item.command), + }), + ) + + const actionLabels = createMemo(() => { + const labels = new Map() + + for (const action of actions()) { + const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig) + if (label) labels.set(action.command, label) + } + + return labels + }) + const filtered = createMemo(() => { if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true) const needle = store.filter.toLowerCase() @@ -171,7 +201,7 @@ export function DialogSelect(props: DialogSelectProps) { const option = selected() if (option) props.onMove?.(option) if (!scroll) return - const target = scroll.getChildren().find((child) => { + const target = scroll.getChildren().find((child: { id?: string }) => { return child.id === JSON.stringify(selected()?.value) }) if (!target) return @@ -192,36 +222,86 @@ export function DialogSelect(props: DialogSelectProps) { } } - const keybind = useKeybind() - useKeyboard((evt) => { + function submit() { setStore("input", "keyboard") + const option = selected() + if (!option) return + option.onSelect?.(dialog) + props.onSelect?.(option) + } - if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) - if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) - if (evt.name === "pageup") move(-10) - if (evt.name === "pagedown") move(10) - if (evt.name === "home") moveTo(0) - if (evt.name === "end") moveTo(flat().length - 1) - - if (evt.name === "return") { - const option = selected() - if (option) { - evt.preventDefault() - evt.stopPropagation() - if (option.onSelect) option.onSelect(dialog) - props.onSelect?.(option) - } - } + useBindings(() => { + const enabledActions = actions().filter((item) => !item.disabled) - for (const item of props.keybind ?? []) { - if (item.disabled || !item.keybind) continue - if (Keybind.match(item.keybind, keybind.parse(evt))) { - const s = selected() - if (s) { - evt.preventDefault() - item.onTrigger(s) - } - } + return { + commands: [ + { + name: "dialog.select.prev", + run() { + setStore("input", "keyboard") + move(-1) + }, + }, + { + name: "dialog.select.next", + run() { + setStore("input", "keyboard") + move(1) + }, + }, + { + name: "dialog.select.page_up", + run() { + setStore("input", "keyboard") + move(-10) + }, + }, + { + name: "dialog.select.page_down", + run() { + setStore("input", "keyboard") + move(10) + }, + }, + { + name: "dialog.select.home", + run() { + setStore("input", "keyboard") + moveTo(0) + }, + }, + { + name: "dialog.select.end", + run() { + setStore("input", "keyboard") + moveTo(flat().length - 1) + }, + }, + { + name: "dialog.select.submit", + run: submit, + }, + ...enabledActions.map((item) => ({ + name: item.command, + run() { + setStore("input", "keyboard") + const option = selected() + if (!option) return + item.onTrigger(option) + }, + })), + ], + bindings: [ + ...sections.dialog_select, + ...tuiConfig.keymap.pick( + "dialog_actions", + enabledActions.map((item) => item.command), + ), + ...(props.bindings ?? []).filter((binding) => { + if (typeof binding.cmd !== "string") return true + return enabledActions.some((item) => item.command === binding.cmd) + }), + ], } }) @@ -236,9 +316,13 @@ export function DialogSelect(props: DialogSelectProps) { } props.ref?.(ref) - const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? []) - const left = createMemo(() => keybinds().filter((item) => item.side !== "right")) - const right = createMemo(() => keybinds().filter((item) => item.side === "right")) + const visibleActions = createMemo(() => + actions() + .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" })) + .filter((item) => !item.disabled && item.label), + ) + const left = createMemo(() => visibleActions().filter((item) => item.side !== "right")) + const right = createMemo(() => visibleActions().filter((item) => item.side === "right")) return ( @@ -365,7 +449,7 @@ export function DialogSelect(props: DialogSelectProps) { - }> + }> (props: DialogSelectProps) { {item.title}{" "} - {Keybind.toString(item.keybind)} + {item.label} )} @@ -393,7 +477,7 @@ export function DialogSelect(props: DialogSelectProps) { {item.title}{" "} - {Keybind.toString(item.keybind)} + {item.label} )} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index a5da735f6556..0dff8b543360 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -1,4 +1,4 @@ -import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js" import { useTheme } from "@tui/context/theme" import { MouseButton, Renderable, RGBA } from "@opentui/core" @@ -6,6 +6,7 @@ import { createStore } from "solid-js/store" import { useToast } from "./toast" import { Flag } from "@opencode-ai/core/flag/flag" import * as Selection from "@tui/util/selection" +import { useBindings } from "../keymap" export function Dialog( props: ParentProps<{ @@ -47,7 +48,7 @@ export function Dialog( backgroundColor={RGBA.fromInts(0, 0, 0, 150)} > { + onMouseUp={(e: { stopPropagation(): void }) => { dismiss = false e.stopPropagation() }} @@ -73,23 +74,6 @@ function init() { const renderer = useRenderer() - useKeyboard((evt) => { - if (store.stack.length === 0) return - if (evt.defaultPrevented) return - if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return - if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { - if (renderer.getSelection()) { - renderer.clearSelection() - } - const current = store.stack.at(-1)! - current.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - evt.preventDefault() - evt.stopPropagation() - refocus() - } - }) - let focus: Renderable | null function refocus() { setTimeout(() => { @@ -108,6 +92,36 @@ function init() { }, 1) } + useBindings(() => ({ + enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(), + bindings: [ + { + key: "escape", + cmd: () => { + if (renderer.getSelection()) { + renderer.clearSelection() + } + const current = store.stack.at(-1) + current?.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + refocus() + }, + }, + { + key: "ctrl+c", + cmd: () => { + if (renderer.getSelection()) { + renderer.clearSelection() + } + const current = store.stack.at(-1) + current?.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + refocus() + }, + }, + ], + })) + return { clear() { for (const item of store.stack) { @@ -155,13 +169,14 @@ export function DialogProvider(props: ParentProps) { const value = init() const renderer = useRenderer() const toast = useToast() + return ( {props.children} { + onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => { if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (evt.button !== MouseButton.RIGHT) return diff --git a/packages/opencode/src/cli/cmd/tui/util/scroll.ts b/packages/opencode/src/cli/cmd/tui/util/scroll.ts index 30d006963942..715a8480cf13 100644 --- a/packages/opencode/src/cli/cmd/tui/util/scroll.ts +++ b/packages/opencode/src/cli/cmd/tui/util/scroll.ts @@ -11,7 +11,9 @@ export class CustomSpeedScroll implements ScrollAcceleration { reset(): void {} } -export function getScrollAcceleration(tuiConfig?: TuiConfig.Info): ScrollAcceleration { +export function getScrollAcceleration( + tuiConfig?: Pick, +): ScrollAcceleration { if (tuiConfig?.scroll_acceleration?.enabled) { return new MacOSScrollAccel() } diff --git a/packages/opencode/src/cli/cmd/tui/util/selection.ts b/packages/opencode/src/cli/cmd/tui/util/selection.ts index 0e0c47874e4f..bb2f658cc2f2 100644 --- a/packages/opencode/src/cli/cmd/tui/util/selection.ts +++ b/packages/opencode/src/cli/cmd/tui/util/selection.ts @@ -5,9 +5,21 @@ type Toast = { error: (err: unknown) => void } +type FocusableSelectionTarget = { + hasSelection: () => boolean +} + type Renderer = { - getSelection: () => { getSelectedText: () => string } | null + getSelection: () => { getSelectedText: () => string; selectedRenderables: FocusableSelectionTarget[] } | null clearSelection: () => void + currentFocusedRenderable?: FocusableSelectionTarget | null +} + +type SelectionKeyEvent = { + ctrl?: boolean + name: string + preventDefault: () => void + stopPropagation: () => void } export function copy(renderer: Renderer, toast: Toast): boolean { @@ -22,4 +34,32 @@ export function copy(renderer: Renderer, toast: Toast): boolean { return true } +export function handleSelectionKey(renderer: Renderer, toast: Toast, event: SelectionKeyEvent) { + const selection = renderer.getSelection() + if (!selection) return + + if (event.ctrl && event.name === "c") { + if (!copy(renderer, toast)) { + renderer.clearSelection() + return + } + + event.preventDefault() + event.stopPropagation() + return + } + + if (event.name === "escape") { + renderer.clearSelection() + event.preventDefault() + event.stopPropagation() + return + } + + const focus = renderer.currentFocusedRenderable + if (focus?.hasSelection() && selection.selectedRenderables.includes(focus)) return + + renderer.clearSelection() +} + export * as Selection from "./selection" diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts index a84fc0b37d58..d9a397f516ec 100644 --- a/packages/opencode/src/config/keybinds.ts +++ b/packages/opencode/src/config/keybinds.ts @@ -21,7 +21,6 @@ const KeybindsSchema = Schema.Struct({ theme_list: keybind("t", "List available themes"), sidebar_toggle: keybind("b", "Toggle sidebar"), scrollbar_toggle: keybind("none", "Toggle session scrollbar"), - username_toggle: keybind("none", "Toggle username visibility"), status_view: keybind("s", "View status"), session_export: keybind("x", "Export session to editor"), session_new: keybind("n", "Create a new session"), @@ -59,6 +58,22 @@ const KeybindsSchema = Schema.Struct({ model_cycle_favorite: keybind("none", "Next favorite model"), model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), command_list: keybind("ctrl+p", "List available commands"), + "dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"), + "dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"), + "dialog.select.page_up": keybind("pageup", "Move up one page in dialog"), + "dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"), + "dialog.select.home": keybind("home", "Move to first dialog item"), + "dialog.select.end": keybind("end", "Move to last dialog item"), + "dialog.select.submit": keybind("return", "Submit selected dialog item"), + "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), + "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), + "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), + "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"), + "prompt.autocomplete.select": keybind("return", "Select autocomplete item"), + "prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"), + "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"), + "plugins.toggle": keybind("space", "Toggle plugin"), + "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), agent_list: keybind("a", "List agents"), agent_cycle: keybind("tab", "Next agent"), agent_cycle_reverse: keybind("shift+tab", "Previous agent"), @@ -101,6 +116,7 @@ const KeybindsSchema = Schema.Struct({ input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"), input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"), input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"), + input_select_all: keybind("super+a", "Select all in input"), history_previous: keybind("up", "Previous history item"), history_next: keybind("down", "Next history item"), session_child_first: keybind("down", "Go to first child session"), diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts deleted file mode 100644 index e3c9b2bc02bf..000000000000 --- a/packages/opencode/src/util/keybind.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { isDeepEqual } from "remeda" -import type { ParsedKey } from "@opentui/core" - -/** - * Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field. - * This ensures type compatibility and catches missing fields at compile time. - */ -export type Info = Pick & { - leader: boolean // our custom field -} - -export function match(a: Info | undefined, b: Info): boolean { - if (!a) return false - const normalizedA = { ...a, super: a.super ?? false } - const normalizedB = { ...b, super: b.super ?? false } - return isDeepEqual(normalizedA, normalizedB) -} - -/** - * Convert OpenTUI's ParsedKey to our Keybind.Info format. - * This helper ensures all required fields are present and avoids manual object creation. - */ -export function fromParsedKey(key: ParsedKey, leader = false): Info { - return { - name: key.name === " " ? "space" : key.name, - ctrl: key.ctrl, - meta: key.meta, - shift: key.shift, - super: key.super ?? false, - leader, - } -} - -export function toString(info: Info | undefined): string { - if (!info) return "" - const parts: string[] = [] - - if (info.ctrl) parts.push("ctrl") - if (info.meta) parts.push("alt") - if (info.super) parts.push("super") - if (info.shift) parts.push("shift") - if (info.name) { - if (info.name === "delete") parts.push("del") - else parts.push(info.name) - } - - let result = parts.join("+") - - if (info.leader) { - result = result ? ` ${result}` : `` - } - - return result -} - -export function parse(key: string): Info[] { - if (key === "none") return [] - - return key.split(",").map((combo) => { - // Handle syntax by replacing with leader+ - const normalized = combo.replace(//g, "leader+") - const parts = normalized.toLowerCase().split("+") - const info: Info = { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "", - } - - for (const part of parts) { - switch (part) { - case "ctrl": - info.ctrl = true - break - case "alt": - case "meta": - case "option": - info.meta = true - break - case "super": - info.super = true - break - case "shift": - info.shift = true - break - case "leader": - info.leader = true - break - case "esc": - info.name = "escape" - break - default: - info.name = part - break - } - } - - return info - }) -} - -export * as Keybind from "./keybind" diff --git a/packages/opencode/test/cli/tui/keybind-plugin.test.ts b/packages/opencode/test/cli/tui/keybind-plugin.test.ts deleted file mode 100644 index 7cd4c87a73f0..000000000000 --- a/packages/opencode/test/cli/tui/keybind-plugin.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { ParsedKey } from "@opentui/core" -import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/plugin-keybinds" - -describe("createPluginKeybind", () => { - const defaults = { - open: "ctrl+o", - close: "escape", - } - - test("uses defaults when overrides are missing", () => { - const api = { - match: () => false, - print: (key: string) => key, - } - const bind = createPluginKeybind(api, defaults) - - expect(bind.all).toEqual(defaults) - expect(bind.get("open")).toBe("ctrl+o") - expect(bind.get("close")).toBe("escape") - }) - - test("applies valid overrides", () => { - const api = { - match: () => false, - print: (key: string) => key, - } - const bind = createPluginKeybind(api, defaults, { - open: "ctrl+alt+o", - close: "q", - }) - - expect(bind.all).toEqual({ - open: "ctrl+alt+o", - close: "q", - }) - }) - - test("ignores invalid overrides", () => { - const api = { - match: () => false, - print: (key: string) => key, - } - const bind = createPluginKeybind(api, defaults, { - open: " ", - close: 1, - extra: "ctrl+x", - }) - - expect(bind.all).toEqual(defaults) - expect(bind.get("extra")).toBe("extra") - }) - - test("resolves names for match", () => { - const list: string[] = [] - const api = { - match: (key: string) => { - list.push(key) - return true - }, - print: (key: string) => key, - } - const bind = createPluginKeybind(api, defaults, { - open: "ctrl+shift+o", - }) - - bind.match("open", { name: "x" } as ParsedKey) - bind.match("ctrl+k", { name: "x" } as ParsedKey) - - expect(list).toEqual(["ctrl+shift+o", "ctrl+k"]) - }) - - test("resolves names for print", () => { - const list: string[] = [] - const api = { - match: () => false, - print: (key: string) => { - list.push(key) - return `print:${key}` - }, - } - const bind = createPluginKeybind(api, defaults, { - close: "q", - }) - - expect(bind.print("close")).toBe("print:q") - expect(bind.print("ctrl+p")).toBe("print:ctrl+p") - expect(list).toEqual(["q", "ctrl+p"]) - }) -}) diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts index 972da0f50f54..c54dbaacaa2f 100644 --- a/packages/opencode/test/cli/tui/plugin-add.test.ts +++ b/packages/opencode/test/cli/tui/plugin-add.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -31,10 +32,9 @@ test("adds tui plugin at runtime from spec", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [], - plugin_origins: undefined, - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -74,10 +74,9 @@ test("retries runtime add for file plugins after dependency wait", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [], - plugin_origins: undefined, - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => { await Bun.write( path.join(tmp.extra.mod, "index.ts"), diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts index ca7e8fcd216d..50ca4dbad2da 100644 --- a/packages/opencode/test/cli/tui/plugin-install.test.ts +++ b/packages/opencode/test/cli/tui/plugin-install.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -50,10 +51,9 @@ test("installs plugin without loading it", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [], - plugin_origins: undefined, - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi({ diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts index 66858e2d0d98..35df997e8b9a 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { Npm } from "@opencode-ai/core/npm" @@ -44,7 +45,7 @@ test("loads npm tui plugin from package ./tui export", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -53,7 +54,7 @@ test("loads npm tui plugin from package ./tui export", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -105,7 +106,7 @@ test("does not use npm package exports dot for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -114,7 +115,7 @@ test("does not use npm package exports dot for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -167,7 +168,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -176,7 +177,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -229,7 +230,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -238,7 +239,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -287,7 +288,7 @@ test("does not use npm package main for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -296,7 +297,7 @@ test("does not use npm package main for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -352,7 +353,7 @@ test("does not use directory package main for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -361,7 +362,7 @@ test("does not use directory package main for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -399,7 +400,7 @@ test("uses directory index fallback for tui when package.json is missing", async }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -408,7 +409,7 @@ test("uses directory index fallback for tui when package.json is missing", async source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -456,7 +457,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -465,7 +466,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) diff --git a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts index ba7a4b3959e6..fb4a3bb57d00 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -37,7 +38,7 @@ test("skips external tui plugins in pure mode", async () => { process.env.OPENCODE_PURE = "1" process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -46,7 +47,7 @@ test("skips external tui plugins in pure mode", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 4266906a24a9..170210123371 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -2,8 +2,10 @@ import { beforeAll, describe, expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" +import { createTestKeymap } from "@opentui/keymap/testing" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { Global } from "@opencode-ai/core/global" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { Filesystem } from "@/util/filesystem" @@ -79,7 +81,10 @@ async function load(): Promise { await Bun.write( localPluginPath, - `export const ignored = async (_input, options) => { + `import { resolveBindingSections } from "@opentui/keymap/extras" +import { useBindings } from "@opentui/keymap/solid" + +export const ignored = async (_input, options) => { if (!options?.fn_marker) return await Bun.write(options.fn_marker, "called") } @@ -93,10 +98,21 @@ export default { const cfg_speed = api.tuiConfig.scroll_speed const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled const cfg_submit = api.tuiConfig.keybinds?.input_submit - const key = api.keybind.create( - { modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" }, - options.keybinds, - ) + const has_keys = typeof api.keys.formatBindings === "function" + const keymap = resolveBindingSections(options.keymap?.sections ?? { + main: { + "plugin.loader.local": "ctrl+shift+m", + "plugin.loader.close": "escape", + }, + }, { sections: ["main"] }).sections + const key_modal = keymap.main.find((item) => item.cmd === "plugin.loader.local")?.key + const key_close = keymap.main.find((item) => item.cmd === "plugin.loader.close")?.key + const key_unknown = "ctrl+k" + const off = api.keymap.registerLayer({ + commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }], + bindings: keymap.main, + }) + off() const kv_before = api.kv.get(options.kv_key, "missing") api.kv.set(options.kv_key, "stored") const kv_after = api.kv.get(options.kv_key, "missing") @@ -132,10 +148,13 @@ export default { set_installed, selected: api.theme.selected, same: first === second, - key_modal: key.get("modal"), - key_close: key.get("close"), - key_unknown: key.get("ctrl+k"), - key_print: key.print("modal"), + key_modal, + key_close, + key_unknown, + has_keys, + has_keymap: typeof api.keymap.registerLayer === "function", + has_resolve_binding_sections: typeof resolveBindingSections === "function", + has_keymap_solid: typeof useBindings === "function", kv_before, kv_after, kv_ready: api.kv.ready, @@ -337,7 +356,14 @@ export default { theme_name: tmp.extra.localThemeName, kv_key: "plugin_state_key", session_id: "ses_test", - keybinds: { modal: "ctrl+alt+m", close: "q" }, + keymap: { + sections: { + main: { + "plugin.loader.local": "ctrl+alt+m", + "plugin.loader.close": "q", + }, + }, + }, } const invalidOpts = { marker: tmp.extra.invalidMarker, @@ -356,7 +382,7 @@ export default { theme_name: tmp.extra.globalThemeName, } - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [ [tmp.extra.localSpec, localOpts], [tmp.extra.invalidSpec, invalidOpts], @@ -373,7 +399,7 @@ export default { source: path.join(Global.Path.config, "tui.json"), }, ], - } + }) await TuiPluginRuntime.init({ api: createTuiPluginApi({ @@ -386,9 +412,6 @@ export default { input_submit: "ctrl+enter", }, }, - keybind: { - print: (key) => `print:${key}`, - }, state: { session: { diff(sessionID) { @@ -507,7 +530,7 @@ test("continues loading when a plugin is missing config metadata", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [ [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }], [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }], @@ -525,7 +548,7 @@ test("continues loading when a plugin is missing config metadata", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -606,13 +629,13 @@ export default { const b = path.join(tmp.path, "order-b.ts") const aSpec = pathToFileURL(a).href const bSpec = pathToFileURL(b).href - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [aSpec, bSpec], plugin_origins: [ { spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, { spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, ], - } + }) await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n") expect(lines).toEqual(["a-start", "a-end", "b"]) @@ -645,7 +668,10 @@ describe("tui.plugin.loader", () => { expect(data.local.key_modal).toBe("ctrl+alt+m") expect(data.local.key_close).toBe("q") expect(data.local.key_unknown).toBe("ctrl+k") - expect(data.local.key_print).toBe("print:ctrl+alt+m") + expect(data.local.has_keys).toBe(true) + expect(data.local.has_keymap).toBe(true) + expect(data.local.has_resolve_binding_sections).toBe(true) + expect(data.local.has_keymap_solid).toBe(true) expect(data.local.kv_before).toBe("missing") expect(data.local.kv_after).toBe("stored") expect(data.local.kv_ready).toBe(true) @@ -703,6 +729,227 @@ describe("tui.plugin.loader", () => { }) }) +test("auto-disposes plugin keymap layers", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "keymap-cleanup-plugin.ts") + const spec = pathToFileURL(file).href + + await Bun.write( + file, + `export default { + id: "demo.keymap.cleanup", + tui: async (api) => { + api.keymap.registerLayer({ + commands: [{ name: "demo.keymap.cleanup", run() {} }], + bindings: [{ key: "ctrl+g", cmd: "demo.keymap.cleanup" }], + }) + }, +} +`, + ) + + return { spec } + }, + }) + + let command_add = 0 + let command_drop = 0 + const keymap = { + registerLayer(layer: { commands?: Array<{ name: string }> }) { + const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup") ?? false + if (tracked) command_add += 1 + return () => { + if (!tracked) return + command_drop += 1 + } + }, + } as NonNullable[0]>["keymap"] + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ keymap }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + + expect(command_add).toBe(1) + expect(command_drop).toBe(0) + } finally { + await TuiPluginRuntime.dispose() + expect(command_drop).toBe(1) + cwd.mockRestore() + wait.mockRestore() + } +}) + +test("plugin keymap proxy preserves real keymap receiver", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "keymap-receiver-plugin.ts") + const spec = pathToFileURL(file).href + const marker = path.join(dir, "keymap-receiver.txt") + + await Bun.write( + file, + `export default { + id: "demo.keymap.receiver", + tui: async (api) => { + api.keymap.setData("demo.receiver", "ok") + await Bun.write(${JSON.stringify(marker)}, String(api.keymap.getData("demo.receiver"))) + }, +} +`, + ) + + return { spec, marker } + }, + }) + + const harness = createTestKeymap({ defaultKeys: true }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ + keymap: harness.keymap as unknown as NonNullable[0]>["keymap"], + }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + + await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("ok") + expect(harness.keymap.getData("demo.receiver")).toBe("ok") + } finally { + await TuiPluginRuntime.dispose() + harness.cleanup() + cwd.mockRestore() + wait.mockRestore() + } +}) + +test("auto-disposes plugin keymap transformers", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "keymap-transformer-cleanup-plugin.ts") + const spec = pathToFileURL(file).href + + await Bun.write( + file, + `export default { + id: "demo.keymap.transformer.cleanup", + tui: async (api) => { + api.keymap.prependLayerBindingsTransformer((bindings) => bindings) + api.keymap.appendLayerBindingsTransformer((bindings) => bindings) + api.keymap.prependCommandTransformer(() => {}) + api.keymap.appendCommandTransformer(() => {}) + }, +} +`, + ) + + return { spec } + }, + }) + + let add = 0 + let drop = 0 + const track = () => { + add += 1 + return () => { + drop += 1 + } + } + const keymap = { + registerLayer: () => () => {}, + prependLayerBindingsTransformer: track, + appendLayerBindingsTransformer: track, + prependCommandTransformer: track, + appendCommandTransformer: track, + } as unknown as NonNullable[0]>["keymap"] + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ keymap }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + + expect(add).toBe(4) + expect(drop).toBe(0) + } finally { + await TuiPluginRuntime.dispose() + expect(drop).toBe(4) + cwd.mockRestore() + wait.mockRestore() + } +}) + +test("manual onDispose for plugin keymap layers stays idempotent", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "keymap-cleanup-manual-plugin.ts") + const spec = pathToFileURL(file).href + + await Bun.write( + file, + `export default { + id: "demo.keymap.cleanup.manual", + tui: async (api) => { + const off = api.keymap.registerLayer({ + commands: [{ name: "demo.keymap.cleanup.manual", run() {} }], + bindings: [{ key: "ctrl+h", cmd: "demo.keymap.cleanup.manual" }], + }) + api.lifecycle.onDispose(off) + }, +} +`, + ) + + return { spec } + }, + }) + + let command_drop = 0 + const keymap = { + registerLayer(layer: { commands?: Array<{ name: string }> }) { + const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup.manual") ?? false + return () => { + if (!tracked) return + command_drop += 1 + } + }, + } as NonNullable[0]>["keymap"] + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ keymap }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + } finally { + await TuiPluginRuntime.dispose() + expect(command_drop).toBe(1) + cwd.mockRestore() + wait.mockRestore() + } +}) + test("updates installed theme when plugin metadata changes", async () => { await using tmp = await tmpdir<{ spec: string @@ -766,16 +1013,17 @@ test("updates installed theme when plugin metadata changes", async () => { }, }) - const mkConfig = (): TuiConfig.Info => ({ - plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]], - plugin_origins: [ - { - spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }], - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - ], - }) + const mkConfig = () => + createTuiResolvedConfig({ + plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]], + plugin_origins: [ + { + spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }], + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], + }) try { await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() }) diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index 11fdf5ce4626..4dde1add4d20 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -39,7 +40,7 @@ test("toggles plugin runtime state by exported id", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_enabled: { "demo.toggle": false, @@ -51,7 +52,7 @@ test("toggles plugin runtime state by exported id", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi() @@ -116,7 +117,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_enabled: { "demo.startup": false, @@ -128,7 +129,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi() diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 5053a7e1f794..5acc3d84fa3b 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -30,6 +30,19 @@ const getTuiConfig = async (directory: string) => ), ) +async function withPlatform(platform: typeof process.platform, fn: () => Promise) { + const original = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { + ...original, + value: platform, + }) + try { + return await fn() + } finally { + if (original) Object.defineProperty(process, "platform", original) + } +} + afterEach(async () => { delete process.env.OPENCODE_CONFIG delete process.env.OPENCODE_TUI_CONFIG @@ -389,6 +402,98 @@ test("merges keybind overrides across precedence layers", async () => { expect(config.keybinds?.theme_list).toBe("ctrl+k") }) +test("resolves semantic keymap sections", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + keybinds: { command_list: "ctrl+z" }, + keymap: { + sections: { + global: { "command.palette.show": "alt+p" }, + prompt: { "prompt.editor": "ctrl+e" }, + autocomplete: { "prompt.autocomplete.next": "ctrl+j" }, + dialog_actions: { "dialog.action.toggle": "ctrl+t" }, + model: { "model.dialog.favorite": "ctrl+f" }, + plugins: { "plugin.dialog.install": "shift+i" }, + }, + }, + }), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") + expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("n") + expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") + expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe("ctrl+j") + expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe("ctrl+t") + expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") + expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") + expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ + "plugin.dialog.install", + ]) + expect( + (config.keymap.pick("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group, + ).toBe("Plugins") + expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([]) +}) + +test("legacy keybinds transform into semantic keymap sections", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + keybinds: { + command_list: "alt+p", + editor_open: "ctrl+e", + "prompt.autocomplete.next": "ctrl+j", + "dialog.mcp.toggle": "ctrl+t", + "dialog.plugins.install": "shift+i", + plugin_manager: "ctrl+shift+p", + }, + }), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(Object.keys(config.keymap.sections)).toEqual([ + "global", + "session", + "prompt", + "autocomplete", + "input", + "dialog_select", + "dialog_actions", + "model", + "permission", + "question", + "plugins", + "home_tips", + ]) + expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") + expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") + expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe("ctrl+j") + expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe("ctrl+t") + expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.provider")?.key).toBe("ctrl+a") + expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") + expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") + expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugins.list")?.key).toBe("ctrl+shift+p") + expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ + "plugin.dialog.install", + ]) + expect( + (config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group, + ).toBe("Plugins") + expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ + "plugins.list", + ]) +}) + wintest("defaults Ctrl+Z to input undo on Windows", async () => { await using tmp = await tmpdir() const config = await getTuiConfig(tmp.path) @@ -419,6 +524,62 @@ wintest("ignores terminal suspend bindings on Windows", async () => { expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") }) +test("applies Windows keymap defaults", async () => { + await withPlatform("win32", async () => { + await using tmp = await tmpdir() + + const config = await getTuiConfig(tmp.path) + expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")).toBeUndefined() + expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe( + "ctrl+z,ctrl+-,super+z", + ) + }) +}) + +test("keeps explicit configured keymap terminal suspend binding on Windows", async () => { + await withPlatform("win32", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + keymap: { + sections: { + global: { "terminal.suspend": "alt+z" }, + }, + }, + }), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")?.key).toBe("alt+z") + }) +}) + +test("keeps explicit configured keymap input undo on Windows", async () => { + await withPlatform("win32", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + keymap: { + sections: { + input: { "input.undo": "ctrl+y" }, + }, + }, + }), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe("ctrl+y") + }) +}) + test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 26913222e894..a4a5aaad6087 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -1,7 +1,9 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { RGBA, type CliRenderer } from "@opentui/core" -import { createPluginKeybind } from "../../src/cli/cmd/tui/context/plugin-keybinds" import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots" +import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform" +import { ConfigKeybinds } from "../../src/config/keybinds" +import { createTuiResolvedKeymap } from "./tui-runtime" type Count = { event_add: number @@ -84,8 +86,8 @@ type Opts = { client?: HostPluginApi["client"] | (() => HostPluginApi["client"]) renderer?: HostPluginApi["renderer"] count?: Count - keybind?: Partial - tuiConfig?: HostPluginApi["tuiConfig"] + keymap?: HostPluginApi["keymap"] + tuiConfig?: Partial app?: Partial state?: { ready?: HostPluginApi["state"]["ready"] @@ -109,6 +111,15 @@ type Opts = { } } +function tuiConfig(input?: Partial): HostPluginApi["tuiConfig"] { + const keybinds = ConfigKeybinds.Keybinds.parse(input?.keybinds ?? {}) + return { + ...input, + keybinds, + keymap: input?.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input?.keybinds ?? {})), + } +} + export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { const kv: Record = {} const count = opts.count @@ -128,10 +139,6 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { let size: "medium" | "large" | "xlarge" = "medium" const has = opts.theme?.has ?? (() => false) let selected = opts.theme?.selected ?? "opencode" - const key = { - match: opts.keybind?.match ?? (() => false), - print: opts.keybind?.print ?? ((name: string) => name), - } const set = opts.theme?.set ?? ((name: string) => { @@ -145,6 +152,26 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { return this }, } + const keymap = + opts.keymap ?? + ({ + acquireResource(_key: symbol, setup: () => () => void) { + const dispose = setup() + return () => { + dispose() + } + }, + registerLayer() { + if (count) count.command_add += 1 + return () => { + if (!count) return + count.command_drop += 1 + } + }, + runCommand() { + return { ok: true } as const + }, + } as unknown as HostPluginApi["keymap"]) function kvGet(name: string): unknown function kvGet(name: string, fallback: Value): Value @@ -160,6 +187,10 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { return opts.app?.version ?? "0.0.0-test" }, }, + keys: { + formatSequence: () => "", + formatBindings: () => undefined, + }, get client() { return client() }, @@ -192,17 +223,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { return () => {} }, }, - command: { - register: () => { - if (count) count.command_add += 1 - return () => { - if (!count) return - count.command_drop += 1 - } - }, - trigger: () => {}, - show: () => {}, - }, + keymap, route: { register: () => { if (count) count.route_add += 1 @@ -247,15 +268,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { }, }, }, - keybind: { - ...key, - create: - opts.keybind?.create ?? - ((defaults, over) => { - return createPluginKeybind(key, defaults, over) - }), - }, - tuiConfig: opts.tuiConfig ?? {}, + tuiConfig: tuiConfig(opts.tuiConfig), kv: { get: kvGet, set(name, value) { diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index ba8099fcdd6b..d1e4c744b0ae 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -1,8 +1,47 @@ import { spyOn } from "bun:test" import path from "path" +import type { KeyEvent, Renderable } from "@opentui/core" +import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" +import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform" +import { ConfigKeybinds } from "../../src/config/keybinds" +import { + KeymapConfig, + KeymapSectionNames, + keymapBindingDefaults, + type KeymapConfigInput, + type KeymapSection, +} from "../../src/cli/cmd/tui/config/tui-schema" type PluginSpec = string | [string, Record] +type ResolvedInput = Omit & { + keybinds?: TuiConfig.Resolved["keybinds"] + keymap?: TuiConfig.Resolved["keymap"] +} + +export function createTuiResolvedKeymap(input: KeymapConfigInput): TuiConfig.Resolved["keymap"] { + const config = KeymapConfig.parse(input) + return { + leader: !config.leader || config.leader === "none" ? "ctrl+x" : config.leader, + leader_timeout: config.leader_timeout, + ...resolveBindingSections, KeymapSection>( + config.sections, + { + sections: KeymapSectionNames, + bindingDefaults: keymapBindingDefaults, + }, + ), + } +} + +export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved { + const keybinds = input.keybinds ?? ConfigKeybinds.Keybinds.parse({}) + return { + ...input, + keybinds, + keymap: input.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input.keybinds ?? {})), + } +} export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record }) { process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json") @@ -14,11 +53,11 @@ export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugi const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => dir) - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin, plugin_origins, ...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }), - } + }) return { config, diff --git a/packages/opencode/test/keybind.test.ts b/packages/opencode/test/keybind.test.ts deleted file mode 100644 index 09df5199259a..000000000000 --- a/packages/opencode/test/keybind.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { describe, test, expect } from "bun:test" -import { Keybind } from "@/util/keybind" - -describe("Keybind.toString", () => { - test("should convert simple key to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" } - expect(Keybind.toString(info)).toBe("f") - }) - - test("should convert ctrl modifier to string", () => { - const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - expect(Keybind.toString(info)).toBe("ctrl+x") - }) - - test("should convert leader key to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" } - expect(Keybind.toString(info)).toBe(" f") - }) - - test("should convert multiple modifiers to string", () => { - const info: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" } - expect(Keybind.toString(info)).toBe("ctrl+alt+g") - }) - - test("should convert all modifiers to string", () => { - const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "h" } - expect(Keybind.toString(info)).toBe(" ctrl+alt+shift+h") - }) - - test("should convert shift modifier to string", () => { - const info: Keybind.Info = { - ctrl: false, - meta: false, - shift: true, - leader: false, - name: "return", - } - expect(Keybind.toString(info)).toBe("shift+return") - }) - - test("should convert function key to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f2" } - expect(Keybind.toString(info)).toBe("f2") - }) - - test("should convert special key to string", () => { - const info: Keybind.Info = { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "pgup", - } - expect(Keybind.toString(info)).toBe("pgup") - }) - - test("should handle empty name", () => { - const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "" } - expect(Keybind.toString(info)).toBe("ctrl") - }) - - test("should handle only modifiers", () => { - const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "" } - expect(Keybind.toString(info)).toBe(" ctrl+alt+shift") - }) - - test("should handle only leader with no other parts", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "" } - expect(Keybind.toString(info)).toBe("") - }) - - test("should convert super modifier to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - expect(Keybind.toString(info)).toBe("super+z") - }) - - test("should convert super+shift modifier to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } - expect(Keybind.toString(info)).toBe("super+shift+z") - }) - - test("should handle super with ctrl modifier", () => { - const info: Keybind.Info = { ctrl: true, meta: false, shift: false, super: true, leader: false, name: "a" } - expect(Keybind.toString(info)).toBe("ctrl+super+a") - }) - - test("should handle super with all modifiers", () => { - const info: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "x" } - expect(Keybind.toString(info)).toBe("ctrl+alt+super+shift+x") - }) - - test("should handle undefined super field (omitted)", () => { - const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" } - expect(Keybind.toString(info)).toBe("ctrl+c") - }) -}) - -describe("Keybind.match", () => { - test("should match identical keybinds", () => { - const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match different key names", () => { - const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "y" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should not match different modifiers", () => { - const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "x" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should match leader keybinds", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match leader vs non-leader", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should match complex keybinds", () => { - const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" } - const b: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match with one modifier different", () => { - const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" } - const b: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: false, name: "g" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should match simple key without modifiers", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should match super modifier keybinds", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match super vs non-super", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: false, leader: false, name: "z" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should match undefined super with false super", () => { - const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" } - const b: Keybind.Info = { ctrl: true, meta: false, shift: false, super: false, leader: false, name: "c" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should match super+shift combination", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match when only super differs", () => { - const a: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "a" } - const b: Keybind.Info = { ctrl: true, meta: true, shift: true, super: false, leader: false, name: "a" } - expect(Keybind.match(a, b)).toBe(false) - }) -}) - -describe("Keybind.parse", () => { - test("should parse simple key", () => { - const result = Keybind.parse("f") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "f", - }, - ]) - }) - - test("should parse leader key syntax", () => { - const result = Keybind.parse("f") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: true, - name: "f", - }, - ]) - }) - - test("should parse ctrl modifier", () => { - const result = Keybind.parse("ctrl+x") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "x", - }, - ]) - }) - - test("should parse multiple modifiers", () => { - const result = Keybind.parse("ctrl+alt+u") - expect(result).toEqual([ - { - ctrl: true, - meta: true, - shift: false, - leader: false, - name: "u", - }, - ]) - }) - - test("should parse shift modifier", () => { - const result = Keybind.parse("shift+f2") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: true, - leader: false, - name: "f2", - }, - ]) - }) - - test("should parse meta/alt modifier", () => { - const result = Keybind.parse("meta+g") - expect(result).toEqual([ - { - ctrl: false, - meta: true, - shift: false, - leader: false, - name: "g", - }, - ]) - }) - - test("should parse leader with modifier", () => { - const result = Keybind.parse("h") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: true, - name: "h", - }, - ]) - }) - - test("should parse multiple keybinds separated by comma", () => { - const result = Keybind.parse("ctrl+c,q") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "c", - }, - { - ctrl: false, - meta: false, - shift: false, - leader: true, - name: "q", - }, - ]) - }) - - test("should parse shift+return combination", () => { - const result = Keybind.parse("shift+return") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: true, - leader: false, - name: "return", - }, - ]) - }) - - test("should parse ctrl+j combination", () => { - const result = Keybind.parse("ctrl+j") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "j", - }, - ]) - }) - - test("should handle 'none' value", () => { - const result = Keybind.parse("none") - expect(result).toEqual([]) - }) - - test("should handle special keys", () => { - const result = Keybind.parse("pgup") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "pgup", - }, - ]) - }) - - test("should handle function keys", () => { - const result = Keybind.parse("f2") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "f2", - }, - ]) - }) - - test("should handle complex multi-modifier combination", () => { - const result = Keybind.parse("ctrl+alt+g") - expect(result).toEqual([ - { - ctrl: true, - meta: true, - shift: false, - leader: false, - name: "g", - }, - ]) - }) - - test("should be case insensitive", () => { - const result = Keybind.parse("CTRL+X") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "x", - }, - ]) - }) - - test("should parse super modifier", () => { - const result = Keybind.parse("super+z") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - super: true, - leader: false, - name: "z", - }, - ]) - }) - - test("should parse super with shift modifier", () => { - const result = Keybind.parse("super+shift+z") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: true, - super: true, - leader: false, - name: "z", - }, - ]) - }) - - test("should parse multiple keybinds with super", () => { - const result = Keybind.parse("ctrl+-,super+z") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "-", - }, - { - ctrl: false, - meta: false, - shift: false, - super: true, - leader: false, - name: "z", - }, - ]) - }) -}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 861208770c33..b433818ead60 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,19 +22,24 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.2", - "@opentui/solid": ">=0.2.2" + "@opentui/core": ">=0.2.4", + "@opentui/keymap": ">=0.2.4", + "@opentui/solid": ">=0.2.4" }, "peerDependenciesMeta": { "@opentui/core": { "optional": true }, + "@opentui/keymap": { + "optional": true + }, "@opentui/solid": { "optional": true } }, "devDependencies": { "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 1c57a71ab315..86175c389168 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -15,11 +15,39 @@ import type { TextPart, Config as SdkConfig, } from "@opencode-ai/sdk/v2" -import type { CliRenderer, ParsedKey, RGBA, SlotMode } from "@opentui/core" +import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core" +import type { Binding, Keymap } from "@opentui/keymap" +import { + resolveBindingSections as resolveKeymapBindingSections, + type BindingSectionsConfig, + type KeySequenceFormatPart, + type SequenceBindingLike, +} from "@opentui/keymap/extras" import type { JSX, SolidPlugin } from "@opentui/solid" import type { Config as PluginConfig, PluginOptions } from "./index.js" -export type { CliRenderer, SlotMode } from "@opentui/core" +export type { CliRenderer, KeyEvent, Renderable, SlotMode } from "@opentui/core" +export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap" +export type { Binding, KeyLike, KeySequencePart, KeyStringifyInput, StringifyOptions } from "@opentui/keymap" +export { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras" +export type { + BindingSectionsConfig, + BindingValue, + FormatCommandBindingsOptions, + FormatKeySequenceOptions, + KeySequenceFormatPart, + SequenceBindingLike, +} from "@opentui/keymap/extras" + +export function resolveBindingSections
( + config: BindingSectionsConfig | undefined, + options: { sections: readonly Section[] }, +) { + return resolveKeymapBindingSections, Section>( + config ?? {}, + options, + ) +} export type TuiRouteCurrent = | { @@ -42,39 +70,12 @@ export type TuiRouteDefinition = { render: (input: { params?: Record }) => JSX.Element } -export type TuiCommand = { - title: string - value: string - description?: string - category?: string - keybind?: string - suggested?: boolean - hidden?: boolean - enabled?: boolean - slash?: { - name: string - aliases?: string[] - } - onSelect?: () => void -} - -export type TuiKeybind = { - name: string - ctrl: boolean - meta: boolean - shift: boolean - super?: boolean - leader: boolean +export type TuiKeys = { + formatSequence: (parts: readonly KeySequenceFormatPart[] | undefined) => string + formatBindings: (bindings: readonly SequenceBindingLike[] | undefined) => string | undefined } -export type TuiKeybindMap = Record - -export type TuiKeybindSet = { - readonly all: TuiKeybindMap - get: (name: string) => string - match: (name: string, evt: ParsedKey) => boolean - print: (name: string) => string -} +export type TuiKeymap = Keymap export type TuiDialogProps = { size?: "medium" | "large" | "xlarge" @@ -288,6 +289,14 @@ export type TuiState = { type TuiConfigView = Pick & NonNullable & { plugin_enabled?: Record + keymap: { + leader: string + leader_timeout: number + sections: Record>> + get: (section: string, cmd: string) => ReadonlyArray> | undefined + pick: (section: string, commands: readonly string[]) => Binding[] + omit: (section: string, commands: readonly string[]) => Binding[] + } } export type TuiApp = { @@ -448,11 +457,8 @@ export type TuiWorkspace = { export type TuiPluginApi = { app: TuiApp - command: { - register: (cb: () => TuiCommand[]) => () => void - trigger: (value: string) => void - show: () => void - } + keys: TuiKeys + keymap: TuiKeymap route: { register: (routes: TuiRouteDefinition[]) => () => void navigate: (name: string, params?: Record) => void @@ -469,11 +475,6 @@ export type TuiPluginApi = { toast: (input: TuiToast) => void dialog: TuiDialogStack } - keybind: { - match: (key: string, evt: ParsedKey) => boolean - print: (key: string) => string - create: (defaults: TuiKeybindMap, overrides?: Record) => TuiKeybindSet - } readonly tuiConfig: Frozen kv: TuiKV state: TuiState diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 8568ffbb9e08..39c9974c56c5 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -525,17 +525,27 @@ You can also define commands using markdown files in `~/.config/opencode/command --- -### Keybinds +### Keymap -Customize keybinds in `tui.json`. +Customize TUI keyboard shortcuts in `tui.json` with `keymap`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keybinds": {} + "keymap": { + "sections": { + "global": { + "command.palette.show": "ctrl+p" + } + } + } } ``` +`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. + +The older `keybinds` field is deprecated and only applies when `keymap` is not present. + [Learn more here](/docs/keybinds). --- diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 86970638c71e..a137aef37f8f 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -1,144 +1,317 @@ --- title: Keybinds -description: Customize your keybinds. +description: Customize your keyboard shortcuts. --- -OpenCode has a list of keybinds that you can customize through `tui.json`. +OpenCode customizes TUI keyboard shortcuts with `keymap` in `tui.json`. + +The older `keybinds` field is still accepted as a migration fallback, but it is deprecated and will be removed in OpenCode v2.0. If `keymap` is present, OpenCode ignores `keybinds` for shortcut resolution. + +`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. + +--- + +## Leader key + +OpenCode uses a `leader` key for many shortcuts. This avoids conflicts in your terminal. + +By default, `ctrl+x` is the leader key and leader shortcuts require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`. + +You do not need to use a leader key, but we recommend doing so. + +--- + +## Minimal example ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keybinds": { + "keymap": { "leader": "ctrl+x", - "app_exit": "ctrl+c,ctrl+d,q", - "editor_open": "e", - "theme_list": "t", - "sidebar_toggle": "b", - "scrollbar_toggle": "none", - "username_toggle": "none", - "status_view": "s", - "tool_details": "none", - "session_export": "x", - "session_new": "n", - "session_list": "l", - "session_timeline": "g", - "session_fork": "none", - "session_rename": "ctrl+r", - "session_share": "none", - "session_unshare": "none", - "session_interrupt": "escape", - "session_compact": "c", - "session_child_first": "down", - "session_child_cycle": "right", - "session_child_cycle_reverse": "left", - "session_parent": "up", - "messages_page_up": "pageup,ctrl+alt+b", - "messages_page_down": "pagedown,ctrl+alt+f", - "messages_line_up": "ctrl+alt+y", - "messages_line_down": "ctrl+alt+e", - "messages_half_page_up": "ctrl+alt+u", - "messages_half_page_down": "ctrl+alt+d", - "messages_first": "ctrl+g,home", - "messages_last": "ctrl+alt+g,end", - "messages_next": "none", - "messages_previous": "none", - "messages_copy": "y", - "messages_undo": "u", - "messages_redo": "r", - "messages_last_user": "none", - "messages_toggle_conceal": "h", - "model_list": "m", - "model_cycle_recent": "f2", - "model_cycle_recent_reverse": "shift+f2", - "model_cycle_favorite": "none", - "model_cycle_favorite_reverse": "none", - "variant_cycle": "ctrl+t", - "variant_list": "none", - "command_list": "ctrl+p", - "agent_list": "a", - "agent_cycle": "tab", - "agent_cycle_reverse": "shift+tab", - "input_clear": "ctrl+c", - "input_paste": "ctrl+v", - "input_submit": "return", - "input_newline": "shift+return,ctrl+return,alt+return,ctrl+j", - "input_move_left": "left,ctrl+b", - "input_move_right": "right,ctrl+f", - "input_move_up": "up", - "input_move_down": "down", - "input_select_left": "shift+left", - "input_select_right": "shift+right", - "input_select_up": "shift+up", - "input_select_down": "shift+down", - "input_line_home": "ctrl+a", - "input_line_end": "ctrl+e", - "input_select_line_home": "ctrl+shift+a", - "input_select_line_end": "ctrl+shift+e", - "input_visual_line_home": "alt+a", - "input_visual_line_end": "alt+e", - "input_select_visual_line_home": "alt+shift+a", - "input_select_visual_line_end": "alt+shift+e", - "input_buffer_home": "home", - "input_buffer_end": "end", - "input_select_buffer_home": "shift+home", - "input_select_buffer_end": "shift+end", - "input_delete_line": "ctrl+shift+d", - "input_delete_to_line_end": "ctrl+k", - "input_delete_to_line_start": "ctrl+u", - "input_backspace": "backspace,shift+backspace", - "input_delete": "ctrl+d,delete,shift+delete", - "input_undo": "ctrl+-,super+z", - "input_redo": "ctrl+.,super+shift+z", - "input_word_forward": "alt+f,alt+right,ctrl+right", - "input_word_backward": "alt+b,alt+left,ctrl+left", - "input_select_word_forward": "alt+shift+f,alt+shift+right", - "input_select_word_backward": "alt+shift+b,alt+shift+left", - "input_delete_word_forward": "alt+d,alt+delete,ctrl+delete", - "input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace", - "history_previous": "up", - "history_next": "down", - "terminal_suspend": "ctrl+z", - "terminal_title_toggle": "none", - "tips_toggle": "h", - "display_thinking": "none" + "leader_timeout": 2000, + "sections": { + "global": { + "command.palette.show": "ctrl+p", + "session.new": "n", + "session.list": "l" + }, + "session": { + "session.compact": "c", + "session.undo": "u", + "session.redo": "r" + }, + "input": { + "input.submit": "return", + "input.newline": ["shift+return", "ctrl+return", "alt+return", "ctrl+j"] + } + } } } ``` -:::note -On Windows, the defaults for `input_undo` and `terminal_suspend` are different: +--- + +## Keymap structure + +`keymap.sections` is grouped by semantic area. Each section contains command names and the key sequence that triggers them. -- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). -- `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend. - ::: +| Field | Description | +| ----- | ----------- | +| `leader` | The key used by `` sequences. Defaults to `ctrl+x`. | +| `leader_timeout` | How long OpenCode waits for the next key after the leader key, in milliseconds. Defaults to `2000`. | +| `sections` | A map of TUI areas to command bindings. | --- -## Leader key +## Binding values + +A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts, or `"none"`/`false` to disable a command. + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keymap": { + "sections": { + "session": { + "session.compact": "none", + "session.export": "x,ctrl+shift+x", + "session.copy": ["y", "ctrl+shift+c"] + } + } + } +} +``` -OpenCode uses a `leader` key for most keybinds. This avoids conflicts in your terminal. +For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`. -By default, `ctrl+x` is the leader key and most actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`. +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keymap": { + "sections": { + "prompt": { + "prompt.paste": { + "key": "ctrl+v", + "preventDefault": false + } + } + } + } +} +``` + +--- -You don't need to use a leader key for your keybinds but we recommend doing so. +## Complete keymap reference -Some navigation keybinds intentionally do not use the leader key by default. For subagent sessions, the defaults are `session_child_first` = `\down`, `session_child_cycle` = `right`, `session_child_cycle_reverse` = `left`, and `session_parent` = `up`. +This example lists the built-in sections, command names, and default fallback bindings. Commands set to `"none"` are available to bind but disabled by default. + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keymap": { + "leader": "ctrl+x", + "leader_timeout": 2000, + "sections": { + "global": { + "command.palette.show": "ctrl+p", + "session.list": "l", + "session.new": "n", + "model.list": "m", + "model.cycle_recent": "f2", + "model.cycle_recent_reverse": "shift+f2", + "model.cycle_favorite": "none", + "model.cycle_favorite_reverse": "none", + "agent.list": "a", + "mcp.list": "none", + "agent.cycle": "tab", + "agent.cycle.reverse": "shift+tab", + "variant.cycle": "ctrl+t", + "variant.list": "none", + "provider.connect": "none", + "console.org.switch": "none", + "opencode.status": "s", + "theme.switch": "t", + "theme.switch_mode": "none", + "theme.mode.lock": "none", + "help.show": "none", + "docs.open": "none", + "app.exit": "ctrl+c,ctrl+d,q", + "app.debug": "none", + "app.console": "none", + "app.heap_snapshot": "none", + "app.toggle.animations": "none", + "app.toggle.file_context": "none", + "app.toggle.diffwrap": "none", + "app.toggle.paste_summary": "none", + "app.toggle.session_directory_filter": "none", + "terminal.suspend": "ctrl+z", + "terminal.title.toggle": "none" + }, + "session": { + "session.share": "none", + "session.rename": "ctrl+r", + "session.timeline": "g", + "session.fork": "none", + "session.compact": "c", + "session.unshare": "none", + "session.undo": "u", + "session.redo": "r", + "session.sidebar.toggle": "b", + "session.toggle.conceal": "h", + "session.toggle.timestamps": "none", + "session.toggle.thinking": "none", + "session.toggle.actions": "none", + "session.toggle.scrollbar": "none", + "session.toggle.generic_tool_output": "none", + "session.page.up": "pageup,ctrl+alt+b", + "session.page.down": "pagedown,ctrl+alt+f", + "session.line.up": "ctrl+alt+y", + "session.line.down": "ctrl+alt+e", + "session.half.page.up": "ctrl+alt+u", + "session.half.page.down": "ctrl+alt+d", + "session.first": "ctrl+g,home", + "session.last": "ctrl+alt+g,end", + "session.messages_last_user": "none", + "session.message.next": "none", + "session.message.previous": "none", + "messages.copy": "y", + "session.copy": "none", + "session.export": "x", + "session.child.first": "down", + "session.parent": "up", + "session.child.next": "right", + "session.child.previous": "left" + }, + "prompt": { + "prompt.submit": "none", + "prompt.editor": "e", + "prompt.editor_context.clear": "none", + "prompt.skills": "none", + "prompt.stash": "none", + "prompt.stash.pop": "none", + "prompt.stash.list": "none", + "workspace.set": "none", + "session.interrupt": "escape", + "prompt.clear": "ctrl+c", + "prompt.paste": { + "key": "ctrl+v", + "preventDefault": false + }, + "prompt.history.previous": "up", + "prompt.history.next": "down" + }, + "autocomplete": { + "prompt.autocomplete.prev": "up,ctrl+p", + "prompt.autocomplete.next": "down,ctrl+n", + "prompt.autocomplete.hide": "escape", + "prompt.autocomplete.select": "return", + "prompt.autocomplete.complete": "tab" + }, + "input": { + "input.submit": "return", + "input.newline": "shift+return,ctrl+return,alt+return,ctrl+j", + "input.move.left": "left,ctrl+b", + "input.move.right": "right,ctrl+f", + "input.move.up": "up", + "input.move.down": "down", + "input.select.left": "shift+left", + "input.select.right": "shift+right", + "input.select.up": "shift+up", + "input.select.down": "shift+down", + "input.line.home": "ctrl+a", + "input.line.end": "ctrl+e", + "input.select.line.home": "ctrl+shift+a", + "input.select.line.end": "ctrl+shift+e", + "input.visual.line.home": "alt+a", + "input.visual.line.end": "alt+e", + "input.select.visual.line.home": "alt+shift+a", + "input.select.visual.line.end": "alt+shift+e", + "input.buffer.home": "home", + "input.buffer.end": "end", + "input.select.buffer.home": "shift+home", + "input.select.buffer.end": "shift+end", + "input.delete.line": "ctrl+shift+d", + "input.delete.to.line.end": "ctrl+k", + "input.delete.to.line.start": "ctrl+u", + "input.backspace": "backspace,shift+backspace", + "input.delete": "ctrl+d,delete,shift+delete", + "input.undo": "ctrl+-,super+z", + "input.redo": "ctrl+.,super+shift+z", + "input.word.forward": "alt+f,alt+right,ctrl+right", + "input.word.backward": "alt+b,alt+left,ctrl+left", + "input.select.word.forward": "alt+shift+f,alt+shift+right", + "input.select.word.backward": "alt+shift+b,alt+shift+left", + "input.delete.word.forward": "alt+d,alt+delete,ctrl+delete", + "input.delete.word.backward": "ctrl+w,ctrl+backspace,alt+backspace", + "input.select.all": "super+a" + }, + "dialog_select": { + "dialog.select.prev": "up,ctrl+p", + "dialog.select.next": "down,ctrl+n", + "dialog.select.page_up": "pageup", + "dialog.select.page_down": "pagedown", + "dialog.select.home": "home", + "dialog.select.end": "end", + "dialog.select.submit": "return" + }, + "dialog_actions": { + "dialog.action.toggle": "space", + "dialog.action.delete": "ctrl+d", + "dialog.action.rename": "ctrl+r" + }, + "model": { + "model.dialog.provider": "ctrl+a", + "model.dialog.favorite": "ctrl+f" + }, + "permission": { + "permission.reject.cancel": "ctrl+c,ctrl+d,q", + "permission.prompt.escape": "ctrl+c,ctrl+d,q", + "permission.prompt.fullscreen": "ctrl+f" + }, + "question": { + "question.reject": "ctrl+c,ctrl+d,q", + "question.edit.clear": "ctrl+c" + }, + "plugins": { + "plugins.list": "none", + "plugins.install": "none", + "plugin.dialog.install": "shift+i" + }, + "home_tips": { + "tips.toggle": "h" + } + } + } +} +``` --- -## Disable keybind +## Legacy keybinds -You can disable a keybind by adding the key to `tui.json` with a value of "none". +`keybinds` is deprecated. It is kept so existing configs continue to work while users migrate to `keymap`. + +Only use `keybinds` when `keymap` is not present. If both fields are set, `keymap` wins and `keybinds` are ignored for shortcut resolution. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", "keybinds": { - "session_compact": "none" + "command_list": "ctrl+p", + "session_new": "n", + "session_compact": "c" } } ``` +:::note +On native Windows, the defaults for undo and terminal suspend are different for both `keymap` and legacy `keybinds`: + +- `input.undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). +- `terminal.suspend` is disabled because native Windows terminals do not support POSIX suspend. +::: + --- ## Desktop prompt shortcuts diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 73ecce93b578..99e9aa752bc9 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -63,7 +63,7 @@ When using the OpenCode TUI, you can type `/` followed by a command name to quic /help ``` -Most commands also have keybind using `ctrl+x` as the leader key, where `ctrl+x` is the default leader key. [Learn more](/docs/keybinds). +Most commands also have keyboard shortcuts using `ctrl+x` as the default leader key. [Learn more](/docs/keybinds). Here are all available slash commands: @@ -353,8 +353,14 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). { "$schema": "https://opencode.ai/tui.json", "theme": "opencode", - "keybinds": { - "leader": "ctrl+x" + "keymap": { + "leader": "ctrl+x", + "leader_timeout": 2000, + "sections": { + "global": { + "command.palette.show": "ctrl+p" + } + } }, "scroll_speed": 3, "scroll_acceleration": { @@ -367,10 +373,13 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). This is separate from `opencode.json`, which configures server/runtime behavior. +`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. + ### Options - `theme` - Sets your UI theme. [Learn more](/docs/themes). -- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). +- `keymap` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). +- `keybinds` - Deprecated legacy shortcut config. This only applies when `keymap` is not present. - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.** - `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. diff --git a/packages/opencode/script/upgrade-opentui.ts b/script/upgrade-opentui.ts similarity index 63% rename from packages/opencode/script/upgrade-opentui.ts rename to script/upgrade-opentui.ts index 615a407745be..3fc194e16766 100644 --- a/packages/opencode/script/upgrade-opentui.ts +++ b/script/upgrade-opentui.ts @@ -9,29 +9,30 @@ if (!raw) { } const ver = raw.replace(/^v/, "") -const root = path.resolve(import.meta.dir, "../../..") +const root = path.resolve(import.meta.dir, "..") const skip = new Set([".git", ".opencode", ".turbo", "dist", "node_modules"]) -const keys = ["@opentui/core", "@opentui/solid"] as const +const keys = ["@opentui/core", "@opentui/keymap", "@opentui/solid"] as const const files = (await Array.fromAsync(new Bun.Glob("**/package.json").scan({ cwd: root }))).filter( (file) => !file.split("/").some((part) => skip.has(part)), ) -const set = (cur: string) => { +const setVersion = (cur: string) => { + if (cur === "catalog:" || cur.startsWith("workspace:")) return cur if (cur.startsWith(">=")) return `>=${ver}` if (cur.startsWith("^")) return `^${ver}` if (cur.startsWith("~")) return `~${ver}` return ver } -const edit = (obj: unknown) => { +const editDeps = (obj: unknown) => { if (!obj || typeof obj !== "object") return false const map = obj as Record return keys .map((key) => { const cur = map[key] if (typeof cur !== "string") return false - const next = set(cur) + const next = setVersion(cur) if (next === cur) return false map[key] = next return true @@ -39,13 +40,31 @@ const edit = (obj: unknown) => { .some(Boolean) } +const editCatalog = (obj: unknown) => { + if (!obj || typeof obj !== "object") return false + const map = obj as Record + return keys + .map((key) => { + const cur = map[key] + if (typeof cur !== "string" || cur === ver) return false + map[key] = ver + return true + }) + .some(Boolean) +} + const out = ( await Promise.all( files.map(async (rel) => { const file = path.join(root, rel) const txt = await Bun.file(file).text() const json = JSON.parse(txt) - const hit = [json.dependencies, json.devDependencies, json.peerDependencies].map(edit).some(Boolean) + const hit = [ + editCatalog(json.workspaces?.catalog), + editDeps(json.dependencies), + editDeps(json.devDependencies), + editDeps(json.peerDependencies), + ].some(Boolean) if (!hit) return null await Bun.write(file, `${JSON.stringify(json, null, 2)}\n`) return rel From 2c17e3a4db61769da539c06e27522beb2f2946d0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 18:36:42 +0000 Subject: [PATCH 0033/1034] chore: generate --- .../cli/cmd/tui/component/dialog-provider.tsx | 3 +- .../cli/cmd/tui/component/prompt/index.tsx | 15 +++++----- .../cmd/tui/config/legacy-keymap-transform.ts | 14 ++++++++-- .../src/cli/cmd/tui/config/tui-schema.ts | 7 ++++- .../opencode/src/cli/cmd/tui/config/tui.ts | 7 +---- .../src/cli/cmd/tui/routes/session/index.tsx | 3 +- .../cli/cmd/tui/routes/session/question.tsx | 6 +--- packages/opencode/test/config/tui.test.ts | 28 ++++++++++++------- packages/web/src/content/docs/keybinds.mdx | 10 +++---- 9 files changed, 54 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index db7cf1bb0a53..a03ac7cac2dd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -244,7 +244,8 @@ function AutoMethod(props: AutoMethodProps) { { key: "c", cmd: () => { - const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url + const code = + props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url Clipboard.copy(code) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 71fb256a7f83..898d14e97997 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -60,12 +60,7 @@ import { useArgs } from "@tui/context/args" import { Flag } from "@opencode-ai/core/flag/flag" import { type WorkspaceStatus } from "../workspace-label" import { useCommandPalette } from "../../context/command-palette" -import { - useBindings, - useCommandShortcut, - useLeaderActive, - useOpencodeKeymap, -} from "../../keymap" +import { useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" import { useTuiConfig } from "../../context/tui-config" export type PromptProps = { @@ -890,7 +885,13 @@ export function Prompt(props: PromptProps) { target: inputTarget, enabled: (() => { cursorVersion() - return inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0 + return ( + inputTarget() !== undefined && + !props.disabled && + store.mode === "normal" && + !auto()?.visible && + input?.visualCursor.offset === 0 + ) })(), bindings: [ { diff --git a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts index c0c621862e76..4b266a4ecc63 100644 --- a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts +++ b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts @@ -46,7 +46,12 @@ const inputCommands = { input_select_all: "input.select.all", } as const satisfies Partial> -function add(config: SectionsConfig, section: KeymapSection, command: string, binding: BindingValue | undefined) { +function add( + config: SectionsConfig, + section: KeymapSection, + command: string, + binding: BindingValue | undefined, +) { if (binding === undefined) return config[section] ??= {} config[section][command] = binding @@ -154,7 +159,12 @@ export function create(keybinds: LegacyKeybinds): KeymapConfigInput { add(config, "dialog_select", "dialog.select.submit", keybinds["dialog.select.submit"]) add(config, "dialog_actions", "dialog.action.delete", combineBindings(keybinds.stash_delete, keybinds.session_delete)) add(config, "dialog_actions", "dialog.action.rename", keybinds.session_rename) - add(config, "dialog_actions", "dialog.action.toggle", combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"])) + add( + config, + "dialog_actions", + "dialog.action.toggle", + combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"]), + ) add(config, "model", "model.dialog.provider", keybinds.model_provider_list) add(config, "model", "model.dialog.favorite", keybinds.model_favorite_toggle) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 400eb3852846..74e1b696f8b0 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -293,7 +293,12 @@ export function keymapBindingDefaults(input: { section: string; binding: Readonl export const KeymapConfig = z .object({ leader: z.string().prefault("ctrl+x"), - leader_timeout: z.number().int().positive().prefault(KeymapLeaderTimeoutDefault).describe("Leader key timeout in milliseconds"), + leader_timeout: z + .number() + .int() + .positive() + .prefault(KeymapLeaderTimeoutDefault) + .describe("Leader key timeout in milliseconds"), sections: KeymapSections, }) .strict() diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 095bc2c882ca..429d7e5c1c15 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -23,12 +23,7 @@ import * as Log from "@opencode-ai/core/util/log" import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" import { LegacyKeymapTransform } from "./legacy-keymap-transform" -import { - KeymapSectionNames, - keymapBindingDefaults, - type KeymapInfo, - type KeymapSection, -} from "./tui-schema" +import { KeymapSectionNames, keymapBindingDefaults, type KeymapInfo, type KeymapSection } from "./tui-schema" const log = Log.create({ service: "tui.config" }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 81df91805989..9ba300ea14da 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1101,8 +1101,7 @@ export function Session() { > {revert()!.reverted.length} message reverted - {redoShortcut()} or /redo to - restore + {redoShortcut()} or /redo to restore diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 617ede6395b4..811db7e82fef 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -220,11 +220,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { }, }, ...(confirm() - ? [ - { key: "return", cmd: () => submit() }, - { key: "escape", cmd: () => reject() }, - ...sections.question, - ] + ? [{ key: "return", cmd: () => submit() }, { key: "escape", cmd: () => reject() }, ...sections.question] : [ ...Array.from({ length: max }, (_, index) => ({ key: String(index + 1), diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 5acc3d84fa3b..5adff22422d8 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -428,16 +428,20 @@ test("resolves semantic keymap sections", async () => { expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("n") expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") - expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe("ctrl+j") - expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe("ctrl+t") + expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe( + "ctrl+j", + ) + expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe( + "ctrl+t", + ) expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ "plugin.dialog.install", ]) - expect( - (config.keymap.pick("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group, - ).toBe("Plugins") + expect((config.keymap.pick("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe( + "Plugins", + ) expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([]) }) @@ -477,8 +481,12 @@ test("legacy keybinds transform into semantic keymap sections", async () => { ]) expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") - expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe("ctrl+j") - expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe("ctrl+t") + expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe( + "ctrl+j", + ) + expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe( + "ctrl+t", + ) expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.provider")?.key).toBe("ctrl+a") expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") @@ -486,9 +494,9 @@ test("legacy keybinds transform into semantic keymap sections", async () => { expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ "plugin.dialog.install", ]) - expect( - (config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group, - ).toBe("Plugins") + expect((config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe( + "Plugins", + ) expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ "plugins.list", ]) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index a137aef37f8f..599945428e45 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -55,11 +55,11 @@ You do not need to use a leader key, but we recommend doing so. `keymap.sections` is grouped by semantic area. Each section contains command names and the key sequence that triggers them. -| Field | Description | -| ----- | ----------- | -| `leader` | The key used by `` sequences. Defaults to `ctrl+x`. | +| Field | Description | +| ---------------- | --------------------------------------------------------------------------------------------------- | +| `leader` | The key used by `` sequences. Defaults to `ctrl+x`. | | `leader_timeout` | How long OpenCode waits for the next key after the leader key, in milliseconds. Defaults to `2000`. | -| `sections` | A map of TUI areas to command bindings. | +| `sections` | A map of TUI areas to command bindings. | --- @@ -310,7 +310,7 @@ On native Windows, the defaults for undo and terminal suspend are different for - `input.undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). - `terminal.suspend` is disabled because native Windows terminals do not support POSIX suspend. -::: + ::: --- From cc6dd5321c393beca7dc97c957bb207898d41dd8 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 20:36:19 +0200 Subject: [PATCH 0034/1034] chore: improve variant parsing for zen --- .../src/routes/zen/go/v1/chat/completions.ts | 3 ++- .../app/src/routes/zen/go/v1/messages.ts | 3 ++- .../app/src/routes/zen/util/variant.ts | 21 +++++++++++++++++++ .../app/src/routes/zen/v1/chat/completions.ts | 3 ++- .../console/app/src/routes/zen/v1/messages.ts | 3 ++- .../app/src/routes/zen/v1/models/[model].ts | 3 ++- .../app/src/routes/zen/v1/responses.ts | 3 ++- 7 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 packages/console/app/src/routes/zen/util/variant.ts diff --git a/packages/console/app/src/routes/zen/go/v1/chat/completions.ts b/packages/console/app/src/routes/zen/go/v1/chat/completions.ts index a5cf41f8f0a5..71fb8f2e6dbf 100644 --- a/packages/console/app/src/routes/zen/go/v1/chat/completions.ts +++ b/packages/console/app/src/routes/zen/go/v1/chat/completions.ts @@ -1,5 +1,6 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" +import { parseOpenAiVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { @@ -7,7 +8,7 @@ export function POST(input: APIEvent) { modelList: "lite", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => body.reasoningEffort ?? body.reasoning_effort, + parseVariant: (url: string, body: any) => parseOpenAiVariant(body), parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/go/v1/messages.ts b/packages/console/app/src/routes/zen/go/v1/messages.ts index e66f3658c660..d356b0bf53fd 100644 --- a/packages/console/app/src/routes/zen/go/v1/messages.ts +++ b/packages/console/app/src/routes/zen/go/v1/messages.ts @@ -1,5 +1,6 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" +import { parseAnthropicVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { @@ -7,7 +8,7 @@ export function POST(input: APIEvent) { modelList: "lite", parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => body.effort, + parseVariant: (url: string, body: any) => parseAnthropicVariant(body), parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/util/variant.ts b/packages/console/app/src/routes/zen/util/variant.ts new file mode 100644 index 000000000000..63464397f9c9 --- /dev/null +++ b/packages/console/app/src/routes/zen/util/variant.ts @@ -0,0 +1,21 @@ +export function parseAnthropicVariant(body: any) { + const effort = body.effort ?? body.output_config?.effort ?? body.outputConfig?.effort ?? body.thinking?.effort + if (effort) return effort + + const budget = body.thinking?.budget_tokens ?? body.thinking?.budgetTokens + if (body.thinking?.type !== "enabled" || typeof budget !== "number") return undefined + return budget > 16_000 ? "max" : "high" +} + +export function parseGoogleVariant(body: any) { + const thinkingConfig = body.generationConfig?.thinkingConfig ?? body.thinkingConfig + if (thinkingConfig?.thinkingLevel) return thinkingConfig.thinkingLevel + + const budget = thinkingConfig?.thinkingBudget ?? thinkingConfig?.thinking_budget + if (typeof budget !== "number" || budget <= 0) return undefined + return budget > 16_000 ? "max" : "high" +} + +export function parseOpenAiVariant(body: any) { + return body.reasoningEffort ?? body.reasoning_effort ?? body.reasoning?.effort +} diff --git a/packages/console/app/src/routes/zen/v1/chat/completions.ts b/packages/console/app/src/routes/zen/v1/chat/completions.ts index a55d74356589..745e0c2182d4 100644 --- a/packages/console/app/src/routes/zen/v1/chat/completions.ts +++ b/packages/console/app/src/routes/zen/v1/chat/completions.ts @@ -1,5 +1,6 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" +import { parseOpenAiVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { @@ -7,7 +8,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => body.reasoningEffort ?? body.reasoning_effort, + parseVariant: (url: string, body: any) => parseOpenAiVariant(body), parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/v1/messages.ts b/packages/console/app/src/routes/zen/v1/messages.ts index f023d4f9ac20..876a16029ec1 100644 --- a/packages/console/app/src/routes/zen/v1/messages.ts +++ b/packages/console/app/src/routes/zen/v1/messages.ts @@ -1,5 +1,6 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" +import { parseAnthropicVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { @@ -7,7 +8,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => body.effort, + parseVariant: (url: string, body: any) => parseAnthropicVariant(body), parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/v1/models/[model].ts b/packages/console/app/src/routes/zen/v1/models/[model].ts index bfe6e8654099..372f66676114 100644 --- a/packages/console/app/src/routes/zen/v1/models/[model].ts +++ b/packages/console/app/src/routes/zen/v1/models/[model].ts @@ -1,5 +1,6 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" +import { parseGoogleVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { @@ -7,7 +8,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined, parseModel: (url: string, _body: any) => url.split("/").pop()?.split(":")?.[0] ?? "", - parseVariant: (url: string, body: any) => body.thinkingLevel, + parseVariant: (url: string, body: any) => parseGoogleVariant(body), parseIsStream: (url: string, _body: any) => // ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse' url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false, diff --git a/packages/console/app/src/routes/zen/v1/responses.ts b/packages/console/app/src/routes/zen/v1/responses.ts index 539b2fdad059..b82735817f9c 100644 --- a/packages/console/app/src/routes/zen/v1/responses.ts +++ b/packages/console/app/src/routes/zen/v1/responses.ts @@ -1,5 +1,6 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" +import { parseOpenAiVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { @@ -7,7 +8,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => body.reasoning?.effort, + parseVariant: (url: string, body: any) => parseOpenAiVariant(body), parseIsStream: (url: string, body: any) => !!body.stream, }) } From e691e8f2746f9b66e70a22092c5c69381d7e3238 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 18:55:24 +0000 Subject: [PATCH 0035/1034] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 078b600d052e..3db79cde1d8e 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-MHeO1KTmjYa+V4ZBYrQq93cYpjnkGfO9e3MOWwkzjVY=", - "aarch64-linux": "sha256-EqTRG7DrdKKT7CEvnaNk5VhjTRhlZ9juP9/Nnr3dJ+g=", - "aarch64-darwin": "sha256-c8dWd8Pgp5uIAOdYbHIeGKqWfkF/l4Ze7ArYUMvTNkE=", - "x86_64-darwin": "sha256-61NpSO0AZ4iZG19RQ6zg0SJec+VQE46WJKOdRrNofT0=" + "x86_64-linux": "sha256-qzsDP+XJJtBv1Ta/RLhrgEk5Tm8qjN04Bj+KxLU2TAs=", + "aarch64-linux": "sha256-Kntdz/NtpE3H1oe1eAC6Yr2L2hSzFr+e9CaiDaE5NHw=", + "aarch64-darwin": "sha256-5+9JW70lwXuUPWjvKlQZ4ZRPNylBfGk2DX7J6Ya0Sgc=", + "x86_64-darwin": "sha256-iCY9VrpdEpYw/Hw8eY+CWHnJTWNpQYgkbMxx9BD4fTY=" } } From 2a1305f2315d8d0625a75b4627cc6c8292abb486 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 21:07:19 +0200 Subject: [PATCH 0036/1034] chore: increase alerting threshold --- infra/monitoring.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index aad090aa8029..313e6c1dd4ae 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -100,7 +100,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { queryJson: modelHttpErrorsQuery("go"), alertType: "on_change", frequency: 300, - thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -119,7 +119,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { queryJson: modelHttpErrorsQuery("zen"), alertType: "on_change", frequency: 300, - thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -138,7 +138,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", { queryJson: providerHttpErrorsQuery("go"), alertType: "on_change", frequency: 600, - thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -157,7 +157,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { queryJson: providerHttpErrorsQuery("zen"), alertType: "on_change", frequency: 600, - thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, From 22e64cac67f58c3371a93a8af64c337138f73684 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 7 May 2026 15:27:36 -0400 Subject: [PATCH 0037/1034] sync: cleanup --- packages/console/app/src/routes/debug/index.ts | 13 ------------- packages/console/core/sst-env.d.ts | 4 ++-- packages/console/function/sst-env.d.ts | 4 ++-- packages/console/resource/sst-env.d.ts | 4 ++-- packages/enterprise/sst-env.d.ts | 4 ++-- packages/function/sst-env.d.ts | 4 ++-- sst-env.d.ts | 4 ++-- 7 files changed, 12 insertions(+), 25 deletions(-) delete mode 100644 packages/console/app/src/routes/debug/index.ts diff --git a/packages/console/app/src/routes/debug/index.ts b/packages/console/app/src/routes/debug/index.ts deleted file mode 100644 index 4bfb63394485..000000000000 --- a/packages/console/app/src/routes/debug/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { APIEvent } from "@solidjs/start/server" -import { json } from "@solidjs/router" -import { Database } from "@opencode-ai/console-core/drizzle/index.js" -import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" - -export async function GET(_evt: APIEvent) { - return json({ - data: await Database.use(async (tx) => { - const result = await tx.$count(UserTable) - return result - }), - }) -} diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index 9680a53aab1e..bc56bd789dbc 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "HoneycombWebhookSecret": { - "type": "random.index/randomPassword.RandomPassword" + "INCIDENT_WEBHOOK_SIGNING_SECRET": { + "type": "sst.sst.Secret" "value": string } "R2AccessKey": { diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index 9680a53aab1e..bc56bd789dbc 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "HoneycombWebhookSecret": { - "type": "random.index/randomPassword.RandomPassword" + "INCIDENT_WEBHOOK_SIGNING_SECRET": { + "type": "sst.sst.Secret" "value": string } "R2AccessKey": { diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index 9680a53aab1e..bc56bd789dbc 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "HoneycombWebhookSecret": { - "type": "random.index/randomPassword.RandomPassword" + "INCIDENT_WEBHOOK_SIGNING_SECRET": { + "type": "sst.sst.Secret" "value": string } "R2AccessKey": { diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index 9680a53aab1e..bc56bd789dbc 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "HoneycombWebhookSecret": { - "type": "random.index/randomPassword.RandomPassword" + "INCIDENT_WEBHOOK_SIGNING_SECRET": { + "type": "sst.sst.Secret" "value": string } "R2AccessKey": { diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index 9680a53aab1e..bc56bd789dbc 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "HoneycombWebhookSecret": { - "type": "random.index/randomPassword.RandomPassword" + "INCIDENT_WEBHOOK_SIGNING_SECRET": { + "type": "sst.sst.Secret" "value": string } "R2AccessKey": { diff --git a/sst-env.d.ts b/sst-env.d.ts index e75c54d05658..52702acd7cf4 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -114,8 +114,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "HoneycombWebhookSecret": { - "type": "random.index/randomPassword.RandomPassword" + "INCIDENT_WEBHOOK_SIGNING_SECRET": { + "type": "sst.sst.Secret" "value": string } "LogProcessor": { From f5d0371efefcc05cd296b0d65b088123dee860dd Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 7 May 2026 16:53:24 -0500 Subject: [PATCH 0038/1034] tui: go plan payg msg (#26248) --- ...-go-upsell.tsx => dialog-retry-action.tsx} | 94 +++++++------- .../src/cli/cmd/tui/routes/session/index.tsx | 6 +- packages/opencode/src/cli/cmd/tui/ui/link.tsx | 4 + packages/opencode/src/session/processor.ts | 1 + packages/opencode/src/session/retry.ts | 117 ++++++++++++++---- packages/opencode/src/session/status.ts | 8 ++ packages/opencode/test/session/retry.test.ts | 80 ++++++++++-- .../test/session/schema-decoding.test.ts | 15 ++- packages/sdk/js/src/v2/gen/types.gen.ts | 6 + script/zen-limit-server.ts | 37 ++++++ 10 files changed, 285 insertions(+), 83 deletions(-) rename packages/opencode/src/cli/cmd/tui/component/{dialog-go-upsell.tsx => dialog-retry-action.tsx} (58%) create mode 100644 script/zen-limit-server.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx similarity index 58% rename from packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx rename to packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx index 3a1fd97b2cc4..9dad1b45613a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx @@ -8,32 +8,36 @@ import { GoLogo } from "./logo" import { BgPulse, type BgPulseMask } from "./bg-pulse" import { useBindings } from "../keymap" -const GO_URL = "https://opencode.ai/go" const PAD_X = 3 const PAD_TOP_OUTER = 1 -export type DialogGoUpsellProps = { +export type DialogRetryActionProps = { + title: string + message: string + label: string + link?: string onClose?: (dontShowAgain?: boolean) => void } -function subscribe(props: DialogGoUpsellProps, dialog: ReturnType) { - open(GO_URL).catch(() => {}) +function runAction(props: DialogRetryActionProps, dialog: ReturnType) { + if (props.link) open(props.link).catch(() => {}) props.onClose?.() dialog.clear() } -function dismiss(props: DialogGoUpsellProps, dialog: ReturnType) { +function dismiss(props: DialogRetryActionProps, dialog: ReturnType) { props.onClose?.(true) dialog.clear() } -export function DialogGoUpsell(props: DialogGoUpsellProps) { +export function DialogRetryAction(props: DialogRetryActionProps) { const dialog = useDialog() const { theme } = useTheme() const fg = selectedForeground(theme) - const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe") + const [selected, setSelected] = createSignal<"dismiss" | "action">("action") const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>() const [masks, setMasks] = createSignal([]) + const showGoTreatment = () => props.link === "https://opencode.ai/go" let content: BoxRenderable | undefined let logoBox: BoxRenderable | undefined let headingBox: BoxRenderable | undefined @@ -41,11 +45,13 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { let buttonsBox: BoxRenderable | undefined const sync = () => { - if (!content || !logoBox) return - setCenter({ - x: logoBox.x - content.x + logoBox.width / 2, - y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER, - }) + if (!content) return + if (logoBox) { + setCenter({ + x: logoBox.x - content.x + logoBox.width / 2, + y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER, + }) + } const next: BgPulseMask[] = [] const baseY = PAD_TOP_OUTER for (const b of [headingBox, descBox, buttonsBox]) { @@ -75,20 +81,20 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { bindings: [ { key: "left", - cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { key: "right", - cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { key: "tab", - cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { key: "return", cmd: () => { - if (selected() === "subscribe") subscribe(props, dialog) + if (selected() === "action") runAction(props, dialog) else dismiss(props, dialog) }, }, @@ -97,33 +103,34 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { return ( (content = item)}> - - - - + {showGoTreatment() ? ( + + + + ) : null} + (headingBox = item)} flexDirection="row" justifyContent="space-between"> - Free limit reached + {props.title} dialog.clear()}> esc (descBox = item)} gap={0}> - - Subscribe to - - OpenCode Go - - for reliable access to the - - best open-source models, starting at $5/month. + {props.message} - - (logoBox = item)}> - - - + + {showGoTreatment() ? ( + (logoBox = item)} alignItems="center"> + + + ) : null} + {props.link ? ( + + + + ) : null} (buttonsBox = item)} flexDirection="row" justifyContent="space-between"> setSelected("subscribe")} - onMouseUp={() => subscribe(props, dialog)} + backgroundColor={selected() === "action" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)} + onMouseOver={() => setSelected("action")} + onMouseUp={() => runAction(props, dialog)} > - subscribe + {props.label} @@ -160,10 +167,13 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { ) } -DialogGoUpsell.show = (dialog: DialogContext) => { +DialogRetryAction.show = ( + dialog: DialogContext, + props: Pick, +) => { return new Promise((resolve) => { dialog.replace( - () => resolve(dontShow ?? false)} />, + () => resolve(dontShow ?? false)} />, () => resolve(false), ) }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 9ba300ea14da..d2b50c32f837 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -85,7 +85,7 @@ import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" import { getScrollAcceleration } from "../../util/scroll" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" -import { DialogGoUpsell } from "../../component/dialog-go-upsell" +import { DialogRetryAction } from "../../component/dialog-retry-action" import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" import { useCommandPalette } from "../../context/command-palette" @@ -260,7 +260,7 @@ export function Session() { event.on("session.status", (evt) => { if (evt.properties.sessionID !== route.sessionID) return if (evt.properties.status.type !== "retry") return - if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return + if (!evt.properties.status.action) return if (dialog.stack.length > 0) return const seen = kv.get(GO_UPSELL_LAST_SEEN_AT) @@ -268,7 +268,7 @@ export function Session() { if (kv.get(GO_UPSELL_DONT_SHOW)) return - void DialogGoUpsell.show(dialog).then((dontShowAgain) => { + void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => { if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true) kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now()) }) diff --git a/packages/opencode/src/cli/cmd/tui/ui/link.tsx b/packages/opencode/src/cli/cmd/tui/ui/link.tsx index 3b328e478d61..01c4b6e7136d 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/link.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/link.tsx @@ -6,6 +6,8 @@ export interface LinkProps { href: string children?: JSX.Element | string fg?: RGBA + width?: number | "auto" | `${number}%` + wrapMode?: "word" | "none" } /** @@ -18,6 +20,8 @@ export function Link(props: LinkProps) { return ( { open(props.href).catch(() => {}) }} diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f22da92927d2..66a2d4797569 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -717,6 +717,7 @@ export const layer: Layer.Layer< type: "retry", attempt: info.attempt, message: info.message, + action: info.action, next: info.next, }) }, diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index e81e1973751f..6a14dfc35b96 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -5,9 +5,19 @@ import { iife } from "@/util/iife" export type Err = ReturnType -// This exported message is shared with the TUI upsell detector. Matching on a -// literal error string kind of sucks, but it is the simplest for now. -export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go" +export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" +export const PAYG_UPSELL_MESSAGE = "Go usage exceeded, enable PAYG" +export const GO_UPSELL_URL = "https://opencode.ai/go" + +export type Retryable = { + message: string + action?: { + title: string + message: string + label: string + link?: string + } +} export const RETRY_INITIAL_DELAY = 2000 export const RETRY_BACKOFF_FACTOR = 2 @@ -59,8 +69,49 @@ export function retryable(error: Err) { // 5xx errors are transient server failures and should always be retried, // even when the provider SDK doesn't explicitly mark them as retryable. if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined - if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE - return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message + if (error.data.responseBody?.includes("FreeUsageLimitError")) { + return { + message: GO_UPSELL_MESSAGE, + action: { + title: "Free limit reached", + message: + "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", + label: "subscribe", + link: GO_UPSELL_URL, + }, + } + } + if (error.data.responseBody?.includes("GoUsageLimitError")) { + const body = parseJSON(error.data.responseBody) + const workspace = str(body?.metadata?.workspace) + const limit = str(body?.metadata?.limit) + const resetAt = num(body?.metadata?.resetAt) + const resetIn = iife(() => { + if (resetAt === undefined) return "" + const seconds = Math.max(0, Math.ceil(resetAt)) + const days = Math.floor(seconds / 86_400) + const hours = Math.floor((seconds % 86_400) / 3_600) + const minutes = Math.ceil((seconds % 3_600) / 60) + const unit = (value: number, name: string) => `${value} ${name}${value === 1 ? "" : "s"}` + + if (days > 0) return hours > 0 ? `${unit(days, "day")} ${unit(hours, "hour")}` : unit(days, "day") + if (hours > 0) return minutes > 0 ? `${unit(hours, "hour")} ${unit(minutes, "minute")}` : unit(hours, "hour") + return minutes > 0 ? unit(minutes, "minute") : "less than a minute" + }) + return { + message: PAYG_UPSELL_MESSAGE, + action: { + title: "Go limit reached", + message: + limit && resetIn + ? `You hit your ${limit} limit. It will reset in ${resetIn}. You can also enable pay-as-you-go.` + : "Enable pay-as-you-go to keep using Go models after your subscription quota is used.", + label: "enable PAYG", + ...(workspace ? { link: `https://opencode.ai/workspace/${workspace}/go` } : {}), + }, + } + } + return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } } // Check for rate limit patterns in plain text error messages @@ -72,50 +123,66 @@ export function retryable(error: Err) { lower.includes("rate limit") || lower.includes("too many requests") ) { - return msg + return { message: msg } } } - const json = iife(() => { - try { - if (typeof error.data?.message === "string") { - const parsed = JSON.parse(error.data.message) - return parsed - } - - return JSON.parse(error.data.message) - } catch { - return undefined - } - }) + const json = parseJSON(error.data?.message) if (!json || typeof json !== "object") return undefined const code = typeof json.code === "string" ? json.code : "" if (json.type === "error" && json.error?.type === "too_many_requests") { - return "Too Many Requests" + return { message: "Too Many Requests" } } if (code.includes("exhausted") || code.includes("unavailable")) { - return "Provider is overloaded" + return { message: "Provider is overloaded" } } if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) { - return "Rate Limited" + return { message: "Rate Limited" } } return undefined } +function str(value: unknown) { + if (value === undefined || value === null) return "" + return String(value) +} + +function num(value: unknown) { + const parsed = Number.parseFloat(str(value)) + if (Number.isNaN(parsed)) return undefined + return parsed +} + +function parseJSON(value: unknown) { + return iife(() => { + try { + if (typeof value !== "string") return undefined + return JSON.parse(value) + } catch { + return undefined + } + }) +} + export function policy(opts: { parse: (error: unknown) => Err - set: (input: { attempt: number; message: string; next: number }) => Effect.Effect + set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect }) { return Schedule.fromStepWithMetadata( Effect.succeed((meta: Schedule.InputMetadata) => { const error = opts.parse(meta.input) - const message = retryable(error) - if (!message) return Cause.done(meta.attempt) + const retry = retryable(error) + if (!retry) return Cause.done(meta.attempt) return Effect.gen(function* () { const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined) const now = yield* Clock.currentTimeMillis - yield* opts.set({ attempt: meta.attempt, message, next: now + wait }) + yield* opts.set({ + attempt: meta.attempt, + message: retry.message, + action: retry.action, + next: now + wait, + }) return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration] }) }), diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index a0e57afc22bd..1d6e96d93567 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -15,6 +15,14 @@ export const Info = Schema.Union([ type: Schema.Literal("retry"), attempt: NonNegativeInt, message: Schema.String, + action: Schema.optional( + Schema.Struct({ + title: Schema.String, + message: Schema.String, + label: Schema.String, + link: Schema.optional(Schema.String), + }), + ), next: NonNegativeInt, }), Schema.Struct({ diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 105c772d9735..f65c403e68b5 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -118,12 +118,12 @@ describe("session.retry.delay", () => { describe("session.retry.retryable", () => { test("maps too_many_requests json messages", () => { const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } })) - expect(SessionRetry.retryable(error)).toBe("Too Many Requests") + expect(SessionRetry.retryable(error)).toEqual({ message: "Too Many Requests" }) }) test("maps overloaded provider codes", () => { const error = wrap(JSON.stringify({ code: "resource_exhausted" })) - expect(SessionRetry.retryable(error)).toBe("Provider is overloaded") + expect(SessionRetry.retryable(error)).toEqual({ message: "Provider is overloaded" }) }) test("does not retry unknown json messages", () => { @@ -146,19 +146,19 @@ describe("session.retry.retryable", () => { const msg = "Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time." const error = wrap(msg) - expect(SessionRetry.retryable(error)).toBe(msg) + expect(SessionRetry.retryable(error)).toEqual({ message: msg }) }) test("retries plain text rate limit errors", () => { const msg = "Rate limit exceeded, please try again later" const error = wrap(msg) - expect(SessionRetry.retryable(error)).toBe(msg) + expect(SessionRetry.retryable(error)).toEqual({ message: msg }) }) test("retries too many requests in plain text", () => { const msg = "Too many requests, please slow down" const error = wrap(msg) - expect(SessionRetry.retryable(error)).toBe(msg) + expect(SessionRetry.retryable(error)).toEqual({ message: msg }) }) test("does not retry context overflow errors", () => { @@ -180,7 +180,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toBe("Internal server error") + expect(SessionRetry.retryable(error)).toEqual({ message: "Internal server error" }) }) test("retries 502 bad gateway errors", () => { @@ -192,7 +192,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toBe("Bad gateway") + expect(SessionRetry.retryable(error)).toEqual({ message: "Bad gateway" }) }) test("retries 503 service unavailable errors", () => { @@ -204,7 +204,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toBe("Service unavailable") + expect(SessionRetry.retryable(error)).toEqual({ message: "Service unavailable" }) }) test("does not retry 4xx errors when isRetryable is false", () => { @@ -230,7 +230,65 @@ describe("session.retry.retryable", () => { const retryable = SessionRetry.retryable(error) expect(retryable).toBeDefined() - expect(retryable).toBe("Response decompression failed") + expect(retryable).toEqual({ message: "Response decompression failed" }) + }) + + test("maps free limits to Go upsell action", () => { + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Free usage exceeded", + isRetryable: true, + statusCode: 429, + responseBody: JSON.stringify({ + type: "error", + error: { type: "FreeUsageLimitError", message: "Free usage exceeded" }, + }), + }).toObject(), + ) + + expect(SessionRetry.retryable(error)).toEqual({ + message: SessionRetry.GO_UPSELL_MESSAGE, + action: { + title: "Free limit reached", + message: + "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", + label: "subscribe", + link: SessionRetry.GO_UPSELL_URL, + }, + }) + }) + + test("maps Go subscription limits to workspace PAYG upsell", () => { + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Subscription quota exceeded. You can continue using free models.", + isRetryable: true, + statusCode: 429, + responseBody: JSON.stringify({ + type: "error", + error: { + type: "GoUsageLimitError", + message: "Subscription quota exceeded. You can continue using free models.", + }, + metadata: { + workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", + limit: "5 hour", + resetAt: 19_380, + }, + }), + }).toObject(), + ) + + expect(SessionRetry.retryable(error)).toEqual({ + message: SessionRetry.PAYG_UPSELL_MESSAGE, + action: { + title: "Go limit reached", + message: + "You hit your 5 hour limit. It will reset in 5 hours 23 minutes. You can also enable pay-as-you-go.", + label: "enable PAYG", + link: "https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", + }, + }) }) }) @@ -283,7 +341,7 @@ describe("session.message-v2.fromError", () => { const retryable = SessionRetry.retryable(error) expect(retryable).toBeDefined() - expect(retryable).toBe("Connection reset by server") + expect(retryable).toEqual({ message: "Connection reset by server" }) }) test("marks OpenAI 404 status codes as retryable", () => { @@ -321,6 +379,6 @@ describe("session.message-v2.fromError", () => { expect(MessageV2.APIError.isInstance(result)).toBe(true) if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") expect(result.data.isRetryable).toBe(true) - expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.") + expect(SessionRetry.retryable(result)).toEqual({ message: "An error occurred while processing your request." }) }) }) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index abe99dddc72f..8bb94bdd8c60 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -230,8 +230,19 @@ describe("SessionStatus.Info", () => { expect(SessionStatus.Info.zod.parse({ type: "idle" })).toEqual({ type: "idle" }) }) - test("retry carries attempt/message/next", () => { - const input = { type: "retry" as const, attempt: 1, message: "transient", next: 500 } + test("retry carries attempt/message/action/next", () => { + const input = { + type: "retry" as const, + attempt: 1, + message: "transient", + action: { + title: "Free limit reached", + message: "Subscribe to OpenCode Go.", + label: "subscribe", + link: "https://opencode.ai/go", + }, + next: 500, + } expect(decode(input)).toEqual(input) expect(SessionStatus.Info.zod.parse(input)).toEqual(input) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 175fe69e6611..5a330c37b642 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -266,6 +266,12 @@ export type SessionStatus = type: "retry" attempt: number message: string + action?: { + title: string + message: string + label: string + link?: string + } next: number } | { diff --git a/script/zen-limit-server.ts b/script/zen-limit-server.ts new file mode 100644 index 000000000000..3be1b5e111d8 --- /dev/null +++ b/script/zen-limit-server.ts @@ -0,0 +1,37 @@ +const retryAfterSeconds = 15 * 60 + +// const response = { +// type: "error", +// error: { +// type: "FreeUsageLimitError", +// message: "Free usage exceeded, subscribe to Go https://opencode.ai/go", +// }, +// metadata: {}, +// } + +const response = { + type: "error", + error: { + type: "GoUsageLimitError", + message: "Subscription quota exceeded. You can continue using free models.", + }, + metadata: { + workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", + limit: "5 hour", + resetAt: retryAfterSeconds, + }, +} + +Bun.serve({ + port: 4141, + fetch() { + return Response.json(response, { + status: 429, + headers: { + "retry-after": String(retryAfterSeconds), + }, + }) + }, +}) + +console.log("Zen limit repro server listening on http://localhost:4141") From 7ded0ec9e91f62e3f0fa9c0d058ac6e86cdb4cd8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 21:54:31 +0000 Subject: [PATCH 0039/1034] chore: generate --- packages/opencode/src/session/retry.ts | 3 +-- packages/opencode/test/session/retry.test.ts | 6 ++---- packages/sdk/openapi.json | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 6a14dfc35b96..a4ef5b7a8fa2 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -74,8 +74,7 @@ export function retryable(error: Err) { message: GO_UPSELL_MESSAGE, action: { title: "Free limit reached", - message: - "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", + message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", label: "subscribe", link: GO_UPSELL_URL, }, diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index f65c403e68b5..8a4d6d6af02a 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -250,8 +250,7 @@ describe("session.retry.retryable", () => { message: SessionRetry.GO_UPSELL_MESSAGE, action: { title: "Free limit reached", - message: - "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", + message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", label: "subscribe", link: SessionRetry.GO_UPSELL_URL, }, @@ -283,8 +282,7 @@ describe("session.retry.retryable", () => { message: SessionRetry.PAYG_UPSELL_MESSAGE, action: { title: "Go limit reached", - message: - "You hit your 5 hour limit. It will reset in 5 hours 23 minutes. You can also enable pay-as-you-go.", + message: "You hit your 5 hour limit. It will reset in 5 hours 23 minutes. You can also enable pay-as-you-go.", label: "enable PAYG", link: "https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", }, diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 04c34e2dc100..fcd7a8547e0c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9401,6 +9401,25 @@ "message": { "type": "string" }, + "action": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "label": { + "type": "string" + }, + "link": { + "type": "string" + } + }, + "required": ["title", "message", "label"], + "additionalProperties": false + }, "next": { "type": "integer", "minimum": 0 From b8799be3c802693e431e7c48737d481290d7d385 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 8 May 2026 09:58:16 +1000 Subject: [PATCH 0040/1034] feat(desktop): allow silent install and only user-wide scope (#26253) --- packages/desktop/electron-builder.config.ts | 4 ++-- packages/desktop/src/main/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index da734dc81def..986008c4f4f1 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -66,8 +66,8 @@ const getBase = (): Configuration => ({ verifyUpdateCodeSignature: false, }, nsis: { - oneClick: false, - allowToChangeInstallationDirectory: true, + oneClick: true, + perMachine: false, installerIcon: `resources/icons/icon.ico`, installerHeaderIcon: `resources/icons/icon.ico`, }, diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index f75cd719a292..52e45a702cd6 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -444,7 +444,7 @@ async function installUpdate() { version: downloadedUpdateVersion, }) await killSidecar() - autoUpdater.quitAndInstall() + autoUpdater.quitAndInstall(true, true) } async function checkForUpdates(alertOnFail: boolean) { From e8ce5df414070c174239fbc90e1c4e8a6be5ee0f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 7 May 2026 22:08:29 -0400 Subject: [PATCH 0041/1034] fix(tui): retain cleared prompt drafts (#26258) --- .../cli/cmd/tui/component/prompt/index.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 898d14e97997..e165f75ac086 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -94,6 +94,8 @@ const money = new Intl.NumberFormat("en-US", { currency: "USD", }) +const DRAFT_RETENTION_MIN_CHARS = 20 + function randomIndex(count: number) { if (count <= 0) return 0 return Math.floor(Math.random() * count) @@ -412,13 +414,7 @@ export function Prompt(props: PromptProps) { category: "Prompt", hidden: true, run: () => { - input.clear() - input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) + clearPrompt() dialog.clear() }, }, @@ -1356,6 +1352,22 @@ export function Prompt(props: PromptProps) { return } + function clearPrompt() { + if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) { + history.append({ + ...store.prompt, + mode: store.mode, + }) + } + input.clear() + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) + } + const highlight = createMemo(() => { if (leader()) return theme.border if (store.mode === "shell") return theme.primary From 5c401673b2486bd3743fb27ab172051e0f336758 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 8 May 2026 04:39:42 +0200 Subject: [PATCH 0042/1034] improve go sub animation perf (#26251) --- .../cli/cmd/tui/component/bg-pulse-render.ts | 429 ++++++++++++++++++ .../src/cli/cmd/tui/component/bg-pulse.tsx | 189 ++++---- .../cmd/tui/component/dialog-retry-action.tsx | 98 ++-- packages/opencode/src/cli/cmd/tui/ui/link.tsx | 2 + 4 files changed, 542 insertions(+), 176 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts b/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts new file mode 100644 index 000000000000..25b728f1e5ae --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts @@ -0,0 +1,429 @@ +import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core" +import { go } from "@/cli/logo" + +const PERIOD = 4600 +const RINGS = 3 +const WIDTH = 3.8 +const TAIL = 9.5 +const AMP = 0.55 +const TAIL_AMP = 0.16 +const BREATH_AMP = 0.05 +const BREATH_SPEED = 0.0008 +// Offset so the bg ring emits from the estimated GO center when the logo shimmer peaks. +const PHASE_OFFSET = 0.29 +const LOGO_GAP = 1 +const LOGO_TOP_BIAS = -1 +const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0 +const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index]) +const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0 +const LOGO_HEIGHT = LOGO_LINES.length +const SPACE = " ".codePointAt(0)! +const TOP_HALF = "▀".codePointAt(0)! +const FULL_BLOCK = "█".codePointAt(0)! +const RING_SCALE = 1 / RINGS +const TAIL_SCALE = 1 / TAIL +const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3 + +const enum LogoCellKind { + Background, + Top, + ShadowTop, + Solid, + Char, +} + +type LogoTemplateCell = { + x: number + y: number + kind: LogoCellKind + charCode: number + attributes: number + topDist: number + bottomDist: number +} + +const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) => + Array.from(line) + .map((char, x) => { + if (char === " ") return + const kind = + char === "_" + ? LogoCellKind.Background + : char === "^" + ? LogoCellKind.Top + : char === "~" + ? LogoCellKind.ShadowTop + : char === "█" + ? LogoCellKind.Solid + : LogoCellKind.Char + return { + x, + y, + kind, + charCode: char.codePointAt(0) ?? SPACE, + attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0, + topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT), + bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT), + } + }) + .filter((cell): cell is LogoTemplateCell => !!cell), +) + +export type Rgb = [number, number, number] + +export type GoUpsellArtRenderOptions = { + deltaTime?: number + rgb?: boolean + cache?: boolean +} + +const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30)) +const CACHE_FRAMES_PER_RENDER = 1 + +export function toRgb(color: RGBA): Rgb { + const [r, g, b] = color.toInts() + return [r, g, b] +} + +function clamp(n: number) { + return Math.max(0, Math.min(1, n)) +} + +function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) { + buffer[offset] = r + buffer[offset + 1] = g + buffer[offset + 2] = b + buffer[offset + 3] = a +} + +function mixChannel(base: number, overlay: number, alpha: number) { + return Math.round(base + (overlay - base) * clamp(alpha)) +} + +function writeLogoTint(buffer: Uint16Array, offset: number, base: Rgb, primary: Rgb, primaryMix: number, peakMix: number) { + const p = clamp(primaryMix) + const q = clamp(peakMix) + const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q) + const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q) + const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q) + writeRgb(buffer, offset, r, g, b) +} + +function sameRgb(a: Rgb, b: Rgb) { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] +} + +export class GoUpsellArtPainter { + private panelRgb: Rgb = [0, 0, 0] + private primaryRgb: Rgb = [255, 255, 255] + private logoBaseRgb: Rgb = [180, 180, 180] + private elapsed = 0 + private distances = new Float32Array(0) + private edgeFalloff = new Float32Array(0) + private geometryWidth = 0 + private geometryHeight = 0 + private reach = 1 + private logoX = 0 + private logoY = 0 + private logoIndexes = new Int32Array(0) + private logoRgb: boolean | undefined + private pulsePeak = 0 + private pulsePrimary = 0 + private cacheDirty = true + private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = [] + private cacheBuildIndex = 0 + + setBackgroundPanel(value: RGBA | Rgb | undefined) { + if (!value) return false + const next = value instanceof RGBA ? toRgb(value) : value + if (sameRgb(this.panelRgb, next)) return false + this.panelRgb = next + this.invalidateCache() + return true + } + + setLogoBase(value: RGBA | Rgb | undefined) { + if (!value) return false + const next = value instanceof RGBA ? toRgb(value) : value + if (sameRgb(this.logoBaseRgb, next)) return false + this.logoBaseRgb = next + this.invalidateCache() + return true + } + + setPrimary(value: RGBA | Rgb | undefined) { + if (!value) return false + const next = value instanceof RGBA ? toRgb(value) : value + if (sameRgb(this.primaryRgb, next)) return false + this.primaryRgb = next + this.invalidateCache() + return true + } + + render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) { + const rgb = options.rgb === true + this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD + this.rebuildGeometry(frameBuffer, rgb) + if (options.cache !== false) { + this.drawCached(frameBuffer, rgb) + return + } + this.drawBackground(frameBuffer, this.elapsed) + this.drawLogo(frameBuffer, this.elapsed, rgb) + } + + private invalidateCache() { + this.cacheDirty = true + this.cacheBuildIndex = 0 + this.frameCache = [] + } + + private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) { + const width = frameBuffer.width + const height = frameBuffer.height + const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight + const logoTemplateChanged = this.logoRgb !== rgb + if (!geometryChanged && !logoTemplateChanged) return + + if (geometryChanged) { + this.geometryWidth = width + this.geometryHeight = height + this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2)) + this.logoY = Math.max( + 0, + Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS), + ) + + const centerX = this.logoX + LOGO_WIDTH / 2 + const centerY = this.logoY + LOGO_HEIGHT / 2 + this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL + this.distances = new Float32Array(width * height) + this.edgeFalloff = new Float32Array(width * height) + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = y * width + x + const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2) + this.distances[index] = dist + this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2) + } + } + } + + this.logoRgb = rgb + this.invalidateCache() + this.rebuildCellTemplate(frameBuffer, rgb) + } + + private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) { + if (this.cacheDirty) this.startFrameCache(frameBuffer, rgb) + if (this.cacheBuildIndex < CACHE_FRAME_COUNT) { + this.buildFrameCache(frameBuffer, rgb) + this.drawBackground(frameBuffer, this.elapsed) + this.drawLogo(frameBuffer, this.elapsed, rgb) + return + } + + const frame = this.frameCache[Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT) % CACHE_FRAME_COUNT] + if (frame) { + frameBuffer.buffers.fg.set(frame.fg) + frameBuffer.buffers.bg.set(frame.bg) + } + } + + private startFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) { + this.frameCache = [] + this.cacheBuildIndex = 0 + this.rebuildCellTemplate(frameBuffer, rgb) + this.cacheDirty = false + } + + private buildFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) { + const end = Math.min(CACHE_FRAME_COUNT, this.cacheBuildIndex + CACHE_FRAMES_PER_RENDER) + for (; this.cacheBuildIndex < end; this.cacheBuildIndex++) { + const t = (this.cacheBuildIndex / CACHE_FRAME_COUNT) * PERIOD + this.drawBackground(frameBuffer, t) + this.drawLogo(frameBuffer, t, rgb) + this.frameCache.push({ + fg: new Uint16Array(frameBuffer.buffers.fg), + bg: new Uint16Array(frameBuffer.buffers.bg), + }) + } + } + + private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) { + const buffers = frameBuffer.buffers + buffers.char.fill(SPACE) + buffers.attributes.fill(0) + + if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) { + this.logoIndexes = new Int32Array(0) + return + } + + this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length) + for (let i = 0; i < LOGO_TEMPLATE.length; i++) { + const cell = LOGO_TEMPLATE[i]! + const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x + this.logoIndexes[i] = index + buffers.attributes[index] = cell.attributes + buffers.char[index] = + cell.kind === LogoCellKind.Background + ? SPACE + : cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop + ? TOP_HALF + : cell.kind === LogoCellKind.Solid + ? rgb + ? TOP_HALF + : FULL_BLOCK + : cell.charCode + } + } + + private drawBackground(frameBuffer: OptimizedBuffer, t: number) { + const buffers = frameBuffer.buffers + const fg = buffers.fg + const bg = buffers.bg + const distances = this.distances + const edgeFalloff = this.edgeFalloff + const baseR = this.panelRgb[0] + const baseG = this.panelRgb[1] + const baseB = this.panelRgb[2] + const deltaR = this.primaryRgb[0] - baseR + const deltaG = this.primaryRgb[1] - baseG + const deltaB = this.primaryRgb[2] - baseB + const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP + + const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1 + const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1 + const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1 + const envelope0 = Math.sin(phase0 * Math.PI) + const envelope1 = Math.sin(phase1 * Math.PI) + const envelope2 = Math.sin(phase2 * Math.PI) + const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0) + const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1) + const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2) + const head0 = phase0 * this.reach + const head1 = phase1 * this.reach + const head2 = phase2 * this.reach + + for (let index = 0; index < distances.length; index++) { + const dist = distances[index] + const delta0 = dist - head0 + const abs0 = delta0 < 0 ? -delta0 : delta0 + const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0 + const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0 + + const delta1 = dist - head1 + const abs1 = delta1 < 0 ? -delta1 : delta1 + const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0 + const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0 + + const delta2 = dist - head2 + const abs2 = delta2 < 0 ? -delta2 : delta2 + const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0 + const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0 + + const level = + (crest0 * AMP + tail0 * TAIL_AMP) * eased0 + + (crest1 * AMP + tail1 * TAIL_AMP) * eased1 + + (crest2 * AMP + tail2 * TAIL_AMP) * eased2 + const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index] + const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7 + const offset = index * 4 + const r = Math.round(baseR + deltaR * strength) + const g = Math.round(baseG + deltaG * strength) + const b = Math.round(baseB + deltaB * strength) + bg[offset] = fg[offset] = r + bg[offset + 1] = fg[offset + 1] = g + bg[offset + 2] = fg[offset + 2] = b + bg[offset + 3] = fg[offset + 3] = 255 + } + } + + private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) { + let peak = 0.04 + let primary = 0 + + const delta0 = dist - head0 + const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8)) + const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6)) + const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0 + peak += core0 * 0.65 * eased0 + primary += (soft0 * 0.16 + tail0 * 0.22) * eased0 + + const delta1 = dist - head1 + const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8)) + const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6)) + const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0 + peak += core1 * 0.65 * eased1 + primary += (soft1 * 0.16 + tail1 * 0.22) * eased1 + + this.pulsePeak = peak > 1 ? 1 : peak + this.pulsePrimary = primary > 1 ? 1 : primary + } + + private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) { + if (this.logoIndexes.length === 0) return + + const buffers = frameBuffer.buffers + const fg = buffers.fg + const bg = buffers.bg + const shadow: Rgb = [ + mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25), + mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25), + mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25), + ] + const phase0 = (t / PERIOD) % 1 + const phase1 = (t / PERIOD + 0.5) % 1 + const envelope0 = Math.sin(phase0 * Math.PI) + const envelope1 = Math.sin(phase1 * Math.PI) + const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0) + const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1) + const head0 = phase0 * LOGO_REACH + const head1 = phase1 * LOGO_REACH + + for (let i = 0; i < LOGO_TEMPLATE.length; i++) { + const cell = LOGO_TEMPLATE[i]! + const index = this.logoIndexes[i]! + const offset = index * 4 + this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1) + const topPeak = this.pulsePeak + const topPrimary = this.pulsePrimary + this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1) + const bottomPeak = this.pulsePeak + const bottomPrimary = this.pulsePrimary + + if (cell.kind === LogoCellKind.Background) { + writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18) + continue + } + + if (cell.kind === LogoCellKind.Top) { + writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak) + writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18) + continue + } + + if (cell.kind === LogoCellKind.ShadowTop) { + writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18) + continue + } + + if (cell.kind === LogoCellKind.Solid && rgb) { + writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak) + writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak) + continue + } + + writeLogoTint( + fg, + offset, + this.logoBaseRgb, + this.primaryRgb, + (topPrimary + bottomPrimary) / 2, + (topPeak + bottomPeak) / 2, + ) + } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx index 541ecea4e141..0482adea3316 100644 --- a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx @@ -1,130 +1,93 @@ -import { BoxRenderable, RGBA } from "@opentui/core" -import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js" +import { FrameBufferRenderable, RGBA, type OptimizedBuffer, type RenderContext, type RenderableOptions } from "@opentui/core" +import { extend, useRenderer } from "@opentui/solid" +import { onCleanup, onMount } from "solid-js" import { tint, useTheme } from "@tui/context/theme" +import { GoUpsellArtPainter } from "./bg-pulse-render" -const PERIOD = 4600 -const RINGS = 3 -const WIDTH = 3.8 -const TAIL = 9.5 -const AMP = 0.55 -const TAIL_AMP = 0.16 -const BREATH_AMP = 0.05 -const BREATH_SPEED = 0.0008 -// Offset so bg ring emits from GO center at the moment the logo pulse peaks. -const PHASE_OFFSET = 0.29 - -export type BgPulseMask = { - x: number - y: number - width: number - height: number - pad?: number - strength?: number +type GoUpsellArtOptions = RenderableOptions & { + backgroundPanel?: RGBA + primary?: RGBA + logoBase?: RGBA } -export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) { - const { theme } = useTheme() - const [now, setNow] = createSignal(performance.now()) - const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 }) - let box: BoxRenderable | undefined +class GoUpsellArtRenderable extends FrameBufferRenderable { + private painter = new GoUpsellArtPainter() + + constructor(ctx: RenderContext, options: GoUpsellArtOptions = {}) { + const width = typeof options.width === "number" ? options.width : 1 + const height = typeof options.height === "number" ? options.height : 1 + super(ctx, { + ...options, + width, + height, + live: options.live ?? true, + respectAlpha: false, + }) + + if (options.width !== undefined && typeof options.width !== "number") this.width = options.width + if (options.height !== undefined && typeof options.height !== "number") this.height = options.height + this.painter.setBackgroundPanel(options.backgroundPanel) + this.painter.setPrimary(options.primary) + this.painter.setLogoBase(options.logoBase) + } + + set backgroundPanel(value: RGBA | undefined) { + if (this.painter.setBackgroundPanel(value)) this.requestRender() + } + + set logoBase(value: RGBA | undefined) { + if (this.painter.setLogoBase(value)) this.requestRender() + } + + set primary(value: RGBA | undefined) { + if (this.painter.setPrimary(value)) this.requestRender() + } - const timer = setInterval(() => setNow(performance.now()), 50) - onCleanup(() => clearInterval(timer)) + protected override renderSelf(buffer: OptimizedBuffer, deltaTime = 0): void { + if (!this.visible || this.isDestroyed) return - const sync = () => { - if (!box) return - setSize({ width: box.width, height: box.height }) + this.painter.render(this.frameBuffer, { + deltaTime, + rgb: this._ctx.capabilities?.rgb === true, + }) + super.renderSelf(buffer) + } +} + +declare module "@opentui/solid" { + interface OpenTUIComponents { + go_upsell_art: typeof GoUpsellArtRenderable } +} + +extend({ go_upsell_art: GoUpsellArtRenderable }) + +export function BgPulse() { + const { theme } = useTheme() + const renderer = useRenderer() + let targetFps = renderer.targetFps + let maxFps = renderer.maxFps onMount(() => { - sync() - box?.on("resize", sync) + targetFps = renderer.targetFps + maxFps = renderer.maxFps + renderer.targetFps = 30 + renderer.maxFps = 30 }) onCleanup(() => { - box?.off("resize", sync) - }) - - const grid = createMemo(() => { - const t = now() - const w = size().width - const h = size().height - if (w === 0 || h === 0) return [] as RGBA[][] - const cxv = props.centerX ?? w / 2 - const cyv = props.centerY ?? h / 2 - const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL - const ringStates = Array.from({ length: RINGS }, (_, i) => { - const offset = i / RINGS - const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1 - const envelope = Math.sin(phase * Math.PI) - const eased = envelope * envelope * (3 - 2 * envelope) - return { - head: phase * reach, - eased, - } - }) - const normalizedMasks = props.masks?.map((m) => { - const pad = m.pad ?? 2 - return { - left: m.x - pad, - right: m.x + m.width + pad, - top: m.y - pad, - bottom: m.y + m.height + pad, - pad, - strength: m.strength ?? 0.85, - } - }) - const rows = [] as RGBA[][] - for (let y = 0; y < h; y++) { - const row = [] as RGBA[] - for (let x = 0; x < w; x++) { - const dx = x + 0.5 - cxv - const dy = (y + 0.5 - cyv) * 2 - const dist = Math.hypot(dx, dy) - let level = 0 - for (const ring of ringStates) { - const delta = dist - ring.head - const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0 - const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0 - level += (crest * AMP + tail * TAIL_AMP) * ring.eased - } - const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2) - const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP - let maskAtten = 1 - if (normalizedMasks) { - for (const m of normalizedMasks) { - if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue - const inX = Math.min(x - m.left, m.right - x) - const inY = Math.min(y - m.top, m.bottom - y) - const edge = Math.min(inX / m.pad, inY / m.pad, 1) - const eased = edge * edge * (3 - 2 * edge) - const reduce = 1 - m.strength * eased - if (reduce < maskAtten) maskAtten = reduce - } - } - const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten) - row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7)) - } - rows.push(row) - } - return rows + renderer.targetFps = targetFps + renderer.maxFps = maxFps }) return ( - (box = item)} width="100%" height="100%"> - - {(row) => ( - - - {(color) => ( - - {" "} - - )} - - - )} - - + ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx index 9dad1b45613a..cbc8f0ef0860 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx @@ -1,15 +1,16 @@ -import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core" +import { RGBA, TextAttributes } from "@opentui/core" import open from "open" -import { createSignal, onCleanup, onMount } from "solid-js" +import { createSignal } from "solid-js" import { selectedForeground, useTheme } from "@tui/context/theme" import { useDialog, type DialogContext } from "@tui/ui/dialog" import { Link } from "@tui/ui/link" -import { GoLogo } from "./logo" -import { BgPulse, type BgPulseMask } from "./bg-pulse" +import { BgPulse } from "./bg-pulse" import { useBindings } from "../keymap" +const GO_URL = "https://opencode.ai/go" const PAD_X = 3 const PAD_TOP_OUTER = 1 +const FOREGROUND_ALPHA = 186 export type DialogRetryActionProps = { title: string @@ -30,52 +31,18 @@ function dismiss(props: DialogRetryActionProps, dialog: ReturnType props.link === GO_URL + const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined) const [selected, setSelected] = createSignal<"dismiss" | "action">("action") - const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>() - const [masks, setMasks] = createSignal([]) - const showGoTreatment = () => props.link === "https://opencode.ai/go" - let content: BoxRenderable | undefined - let logoBox: BoxRenderable | undefined - let headingBox: BoxRenderable | undefined - let descBox: BoxRenderable | undefined - let buttonsBox: BoxRenderable | undefined - - const sync = () => { - if (!content) return - if (logoBox) { - setCenter({ - x: logoBox.x - content.x + logoBox.width / 2, - y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER, - }) - } - const next: BgPulseMask[] = [] - const baseY = PAD_TOP_OUTER - for (const b of [headingBox, descBox, buttonsBox]) { - if (!b) continue - next.push({ - x: b.x - content.x, - y: b.y - content.y + baseY, - width: b.width, - height: b.height, - pad: 2, - strength: 0.78, - }) - } - setMasks(next) - } - - onMount(() => { - sync() - for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync) - }) - - onCleanup(() => { - for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync) - }) useBindings(() => ({ bindings: [ @@ -102,37 +69,40 @@ export function DialogRetryAction(props: DialogRetryActionProps) { })) return ( - (content = item)}> + {showGoTreatment() ? ( - + ) : null} - - (headingBox = item)} flexDirection="row" justifyContent="space-between"> - + + + {props.title} - dialog.clear()}> + dialog.clear()}> esc - (descBox = item)} gap={0}> - {props.message} + + + {props.message} + - - {showGoTreatment() ? ( - (logoBox = item)} alignItems="center"> - + {props.link ? ( + showGoTreatment() ? ( + + - ) : null} - {props.link ? ( - + ) : ( + - ) : null} - - (buttonsBox = item)} flexDirection="row" justifyContent="space-between"> + ) + ) : ( + + )} + don't show again @@ -156,6 +127,7 @@ export function DialogRetryAction(props: DialogRetryActionProps) { > {props.label} diff --git a/packages/opencode/src/cli/cmd/tui/ui/link.tsx b/packages/opencode/src/cli/cmd/tui/ui/link.tsx index 01c4b6e7136d..cfd78bc3335c 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/link.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/link.tsx @@ -6,6 +6,7 @@ export interface LinkProps { href: string children?: JSX.Element | string fg?: RGBA + bg?: RGBA width?: number | "auto" | `${number}%` wrapMode?: "word" | "none" } @@ -20,6 +21,7 @@ export function Link(props: LinkProps) { return ( { From 6ff833a22bed0f087103e7969bfbb94736e62cb9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 02:40:47 +0000 Subject: [PATCH 0043/1034] chore: generate --- .../src/cli/cmd/tui/component/bg-pulse-render.ts | 9 ++++++++- packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts b/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts index 25b728f1e5ae..09a50ebe4567 100644 --- a/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts @@ -100,7 +100,14 @@ function mixChannel(base: number, overlay: number, alpha: number) { return Math.round(base + (overlay - base) * clamp(alpha)) } -function writeLogoTint(buffer: Uint16Array, offset: number, base: Rgb, primary: Rgb, primaryMix: number, peakMix: number) { +function writeLogoTint( + buffer: Uint16Array, + offset: number, + base: Rgb, + primary: Rgb, + primaryMix: number, + peakMix: number, +) { const p = clamp(primaryMix) const q = clamp(peakMix) const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q) diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx index 0482adea3316..e7b02f8ee340 100644 --- a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx @@ -1,4 +1,10 @@ -import { FrameBufferRenderable, RGBA, type OptimizedBuffer, type RenderContext, type RenderableOptions } from "@opentui/core" +import { + FrameBufferRenderable, + RGBA, + type OptimizedBuffer, + type RenderContext, + type RenderableOptions, +} from "@opentui/core" import { extend, useRenderer } from "@opentui/solid" import { onCleanup, onMount } from "solid-js" import { tint, useTheme } from "@tui/context/theme" From db6a03882954f9f3a54cc14c20a8315c07e2a685 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 7 May 2026 22:55:48 -0400 Subject: [PATCH 0044/1034] sync --- packages/console/app/src/routes/zen/util/error.ts | 10 ++++++---- packages/console/app/src/routes/zen/util/handler.ts | 12 ++++++++---- packages/console/core/sst-env.d.ts | 4 ++-- packages/console/function/sst-env.d.ts | 4 ++-- packages/console/resource/sst-env.d.ts | 4 ++-- packages/enterprise/sst-env.d.ts | 4 ++-- packages/function/sst-env.d.ts | 4 ++-- sst-env.d.ts | 4 ++-- 8 files changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts index 216b6564e7ed..d17741ff70d6 100644 --- a/packages/console/app/src/routes/zen/util/error.ts +++ b/packages/console/app/src/routes/zen/util/error.ts @@ -13,13 +13,15 @@ class LimitError extends Error { } export class RateLimitError extends LimitError {} export class FreeUsageLimitError extends LimitError {} +export class BlackUsageLimitError extends LimitError {} -class SubscriptionUsageLimitError extends LimitError { +type LimitName = "5 hour" | "weekly" | "monthly" +export class GoUsageLimitError extends LimitError { workspace: string - constructor(message: string, workspace: string, retryAfter?: number) { + limitName: LimitName + constructor(message: string, workspace: string, limitName: LimitName, retryAfter?: number) { super(message, retryAfter) this.workspace = workspace + this.limitName = limitName } } -export class GoUsageLimitError extends SubscriptionUsageLimitError {} -export class BlackUsageLimitError extends SubscriptionUsageLimitError {} diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 7cee86b47e3d..4b6fe5feb81b 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -415,8 +415,11 @@ export async function handler( message: error.message, }, metadata: - error instanceof GoUsageLimitError || error instanceof BlackUsageLimitError - ? { workspace: error.workspace } + error instanceof GoUsageLimitError + ? { + workspace: error.workspace, + limitName: error.limitName, + } : {}, }), { status: 429, headers }, @@ -710,7 +713,6 @@ export async function handler( t("zen.api.error.subscriptionQuotaExceeded", { retryIn: formatRetryTime(result.resetInSec), }), - authInfo.workspaceID, result.resetInSec, ) } @@ -729,7 +731,6 @@ export async function handler( t("zen.api.error.subscriptionQuotaExceeded", { retryIn: formatRetryTime(result.resetInSec), }), - authInfo.workspaceID, result.resetInSec, ) } @@ -757,6 +758,7 @@ export async function handler( throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), authInfo.workspaceID, + "weekly", result.resetInSec, ) } @@ -773,6 +775,7 @@ export async function handler( throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), authInfo.workspaceID, + "monthly", result.resetInSec, ) } @@ -789,6 +792,7 @@ export async function handler( throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), authInfo.workspaceID, + "5 hour", result.resetInSec, ) } diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index bc56bd789dbc..9680a53aab1e 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index bc56bd789dbc..9680a53aab1e 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index bc56bd789dbc..9680a53aab1e 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index bc56bd789dbc..9680a53aab1e 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index bc56bd789dbc..9680a53aab1e 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/sst-env.d.ts b/sst-env.d.ts index 52702acd7cf4..e75c54d05658 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -114,8 +114,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "LogProcessor": { From 1cf8123bc6e17d759d651ef3ca493145adb23741 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 7 May 2026 23:32:47 -0400 Subject: [PATCH 0045/1034] fix(provider): align GPT-5 reasoning variants (#26268) --- packages/opencode/src/provider/transform.ts | 92 ++++++++-- .../opencode/test/provider/transform.test.ts | 171 +++++++++++++++--- 2 files changed, 216 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index cd29e40822da..7c0eaced2606 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -500,6 +500,13 @@ export function topK(model: Provider.Model) { const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +const OPENAI_GPT5_1_EFFORTS = ["none", ...WIDELY_SUPPORTED_EFFORTS] +const OPENAI_GPT5_2_PLUS_EFFORTS = [...OPENAI_GPT5_1_EFFORTS, "xhigh"] +const OPENAI_GPT5_PRO_EFFORTS = ["high"] +const OPENAI_GPT5_PRO_2_PLUS_EFFORTS = ["medium", "high", "xhigh"] +const OPENAI_GPT5_CHAT_EFFORTS = ["medium"] +const OPENAI_GPT5_CODEX_XHIGH_EFFORTS = [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +const OPENAI_GPT5_CODEX_3_PLUS_EFFORTS = ["none", ...OPENAI_GPT5_CODEX_XHIGH_EFFORTS] // OpenAI rolled out the `none` reasoning_effort tier on this date (Responses API). // Models released before it 400 on `reasoning_effort: "none"`, so we only expose @@ -513,17 +520,49 @@ const OPENAI_XHIGH_EFFORT_RELEASE_DATE = "2025-12-04" // "gpt-5", "gpt-5-nano", "gpt-5.4", "openai/gpt-5.4-codex". // Anchored to start-of-string or "/" so it doesn't false-match "gpt-50" or "gpt-5o". const GPT5_FAMILY_RE = /(?:^|\/)gpt-5(?:[.-]|$)/ +const GPT5_VERSION_RE = /(?:^|\/)gpt-5[.-](\d+)(?:[.-]|$)/ +const GPT5_PRO_RE = /(?:^|\/)gpt-5[.-]?pro(?:[.-]|$)/ +const GPT5_VERSIONED_PRO_RE = /(?:^|\/)gpt-5[.-]\d+[.-]pro(?:[.-]|$)/ + +function gpt5Version(apiId: string) { + return Number(GPT5_VERSION_RE.exec(apiId)?.[1]) || undefined +} + +function versionedGpt5ReasoningEfforts(apiId: string) { + if (GPT5_VERSIONED_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_2_PLUS_EFFORTS + const version = gpt5Version(apiId) + if (version === undefined) return undefined + if (version === 1) return OPENAI_GPT5_1_EFFORTS + return OPENAI_GPT5_2_PLUS_EFFORTS +} + +function gpt5CodexReasoningEfforts(apiId: string) { + if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("codex")) return undefined + const version = gpt5Version(apiId) + if (version !== undefined && version >= 3) return OPENAI_GPT5_CODEX_3_PLUS_EFFORTS + if (apiId.includes("codex-max") || (version !== undefined && version >= 2)) return OPENAI_GPT5_CODEX_XHIGH_EFFORTS + return WIDELY_SUPPORTED_EFFORTS +} + +function gpt5ChatReasoningEfforts(apiId: string) { + if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("-chat")) return undefined + return gpt5Version(apiId) === undefined ? [] : OPENAI_GPT5_CHAT_EFFORTS +} // Computes the reasoning_effort tiers an OpenAI (or OpenAI-compatible upstream -// routed through it, e.g. cf-ai-gateway) model exposes. Returns null for models -// with no tunable effort knob (gpt-5-pro). Effort order: weakest to strongest. -function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | null { +// routed through it, e.g. cf-ai-gateway) model exposes. Effort order: weakest +// to strongest. +function openaiReasoningEfforts(apiId: string, releaseDate: string) { const id = apiId.toLowerCase() - if (id === "gpt-5-pro" || id === "openai/gpt-5-pro") return null - if (id.includes("codex")) { - if (id.includes("5.2") || id.includes("5.3")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] - return [...WIDELY_SUPPORTED_EFFORTS] - } + const chatEfforts = gpt5ChatReasoningEfforts(id) + if (chatEfforts) return chatEfforts + if (GPT5_PRO_RE.test(id)) return OPENAI_GPT5_PRO_EFFORTS + const codexEfforts = gpt5CodexReasoningEfforts(id) + if (codexEfforts) return codexEfforts + const versionedEfforts = versionedGpt5ReasoningEfforts(id) + // GPT-5.1 replaced GPT-5's `minimal` effort with `none`; GPT-5.2+ + // additionally accepts `xhigh`. Model pages list the supported subset. + if (versionedEfforts) return versionedEfforts const efforts = [...WIDELY_SUPPORTED_EFFORTS] if (GPT5_FAMILY_RE.test(id)) efforts.unshift("minimal") if (releaseDate >= OPENAI_NONE_EFFORT_RELEASE_DATE) efforts.unshift("none") @@ -531,6 +570,14 @@ function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | return efforts } +function openaiCompatibleReasoningEfforts(id: string) { + const apiId = id.toLowerCase() + const chatEfforts = gpt5ChatReasoningEfforts(apiId) + if (chatEfforts) return chatEfforts + if (GPT5_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_EFFORTS + return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS +} + function anthropicAdaptiveEfforts(apiId: string): string[] | null { if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) { return ["low", "medium", "high", "xhigh", "max"] @@ -577,8 +624,13 @@ export function variants(model: Provider.Model): Record [effort, { reasoning: { effort } }])) + if (!id.includes("gpt") && !id.includes("gemini-3") && !id.includes("claude")) return {} + return Object.fromEntries( + (id.includes("gpt") ? openaiCompatibleReasoningEfforts(id) : OPENAI_EFFORTS).map((effort) => [ + effort, + { reasoning: { effort } }, + ]), + ) case "ai-gateway-provider": { // Cloudflare AI Gateway routes every upstream through its OpenAI-compatible @@ -589,7 +641,6 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) } return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) @@ -652,7 +703,9 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) + return Object.fromEntries( + openaiCompatibleReasoningEfforts(model.api.id).map((effort) => [effort, { reasoningEffort: effort }]), + ) case "@ai-sdk/github-copilot": if (model.id.includes("gemini")) { @@ -700,12 +753,11 @@ export function variants(model: Provider.Model): Record [ + (GPT5_FAMILY_RE.test(id) && gpt5Version(id) === undefined + ? ["minimal", ...WIDELY_SUPPORTED_EFFORTS] + : WIDELY_SUPPORTED_EFFORTS + ).map((effort) => [ effort, { reasoningEffort: effort, @@ -717,7 +769,6 @@ export function variants(model: Provider.Model): Record [ effort, @@ -1102,6 +1153,11 @@ export function smallOptions(model: Provider.Model) { model.api.npm === "@ai-sdk/github-copilot" ) { if (model.api.id.includes("gpt-5")) { + if (model.api.id.includes("-chat")) { + if (gpt5Version(model.api.id) === undefined) return { store: false } + return { store: false, reasoningEffort: "medium" } + } + if (model.api.id.includes("search-api")) return { store: false } if (model.api.id.includes("5.") || model.api.id.includes("5-mini")) { return { store: false, reasoningEffort: "low" } } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c7a321d57199..064313ec518c 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2464,6 +2464,32 @@ describe("ProviderTransform.variants", () => { expect(result.high).toEqual({ reasoning: { effort: "high" } }) }) + for (const testCase of [ + { id: "openai/gpt-5.4", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-pro", efforts: ["high"] }, + { id: "openai/gpt-5.5-pro", efforts: ["medium", "high", "xhigh"] }, + { id: "openai/gpt-5.2-codex", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.3-codex", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.3-codex-max", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-chat-latest", efforts: [] }, + { id: "openai/gpt-5.2-chat-latest", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported OpenAI reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: testCase.id, + providerID: "openrouter", + api: { + id: testCase.id, + url: "https://openrouter.ai", + npm: "@openrouter/ai-sdk-provider", + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } + test("gemini-3 returns OPENAI_EFFORTS with reasoning", () => { const model = createMockModel({ id: "openrouter/gemini-3-5-pro", @@ -2651,6 +2677,32 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoningEffort: "low" }) expect(result.high).toEqual({ reasoningEffort: "high" }) }) + + for (const testCase of [ + { id: "openai/gpt-5-5", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-pro", efforts: ["high"] }, + { id: "openai/gpt-5-5-pro", efforts: ["medium", "high", "xhigh"] }, + { id: "openai/gpt-5-2-codex", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-3-codex", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-3-codex-max", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-chat-latest", efforts: [] }, + { id: "openai/gpt-5-2-chat-latest", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported OpenAI reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: testCase.id, + providerID: "gateway", + api: { + id: testCase.id, + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } }) describe("@ai-sdk/github-copilot", () => { @@ -2929,10 +2981,27 @@ describe("ProviderTransform.variants", () => { const result = ProviderTransform.variants(model) expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"]) }) + + for (const id of ["gpt-5-4", "gpt-5-5"]) { + test(`${id} does not add minimal effort`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id, + providerID: "azure", + api: { + id, + url: "https://azure.com", + npm: "@ai-sdk/azure", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + } }) describe("@ai-sdk/openai", () => { - test("gpt-5-pro returns empty object", () => { + test("gpt-5-pro returns only high effort", () => { const model = createMockModel({ id: "gpt-5-pro", providerID: "openai", @@ -2943,7 +3012,7 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(Object.keys(result)).toEqual(["high"]) }) test("standard openai models return custom efforts with reasoningSummary", () => { @@ -2983,10 +3052,10 @@ describe("ProviderTransform.variants", () => { test("models after 2025-12-04 include 'xhigh' effort", () => { const model = createMockModel({ - id: "openai/gpt-5-chat", + id: "openai/gpt-5-reasoning", providerID: "openai", api: { - id: "gpt-5-chat", + id: "gpt-5-reasoning", url: "https://api.openai.com", npm: "@ai-sdk/openai", }, @@ -2996,20 +3065,38 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) }) - test("dotted gpt-5.x ids include 'minimal' (regression: matcher used to miss gpt-5.4)", () => { - const model = createMockModel({ - id: "gpt-5.4", - providerID: "openai", - api: { - id: "gpt-5.4", - url: "https://api.openai.com", - npm: "@ai-sdk/openai", - }, - release_date: "2026-03-05", + for (const testCase of [ + { id: "gpt-5.1", releaseDate: "2025-11-13", efforts: ["none", "low", "medium", "high"] }, + { id: "gpt-5.4", releaseDate: "2026-03-05", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5.5", modelID: "gpt-5-5", releaseDate: "2026-04-23", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5.4-pro", releaseDate: "2026-03-05", efforts: ["medium", "high", "xhigh"] }, + { id: "gpt-5.5-pro", releaseDate: "2026-04-23", efforts: ["medium", "high", "xhigh"] }, + { id: "gpt-5-codex", releaseDate: "2025-09-23", efforts: ["low", "medium", "high"] }, + { id: "gpt-5.1-codex", releaseDate: "2025-11-13", efforts: ["low", "medium", "high"] }, + { id: "gpt-5.1-codex-max", releaseDate: "2025-11-13", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "gpt-5.2-codex", releaseDate: "2025-12-11", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "gpt-5.3-codex", releaseDate: "2026-01-22", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5.3-codex-max", releaseDate: "2026-01-22", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5-chat-latest", releaseDate: "2025-08-07", efforts: [] }, + { id: "gpt-5.1-chat-latest", releaseDate: "2025-11-13", efforts: ["medium"] }, + { id: "gpt-5.2-chat-latest", releaseDate: "2025-12-11", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: testCase.modelID ?? testCase.id, + providerID: "openai", + api: { + id: testCase.id, + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + release_date: testCase.releaseDate, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) - }) + } test("gpt-50 (lookalike) does not get gpt-5 family treatment", () => { const model = createMockModel({ @@ -3486,18 +3573,20 @@ describe("ProviderTransform.variants", () => { release_date: releaseDate, }) - test("openai gpt-5.4 includes xhigh effort (regression: variant=xhigh used to be silently ignored)", () => { - const result = ProviderTransform.variants(cfModel("openai/gpt-5.4", "2026-03-05")) - expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) - expect(result.high).toEqual({ reasoningEffort: "high" }) - expect(Object.keys(result)).toContain("minimal") - }) - - test("openai gpt-5.2-codex includes xhigh", () => { - const result = ProviderTransform.variants(cfModel("openai/gpt-5.2-codex", "2025-12-11")) - expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"]) - }) + for (const testCase of [ + { id: "openai/gpt-5.4", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.2-codex", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.3-codex", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-pro", efforts: ["high"] }, + { id: "openai/gpt-5.2-pro", efforts: ["medium", "high", "xhigh"] }, + { id: "openai/gpt-5-chat-latest", efforts: [] }, + { id: "openai/gpt-5.2-chat-latest", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported reasoning efforts`, () => { + const result = ProviderTransform.variants(cfModel(testCase.id, "2026-03-05")) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } test("openai gpt-4o (no reasoning) returns empty", () => { const model = cfModel("openai/gpt-4o") @@ -3517,6 +3606,30 @@ describe("ProviderTransform.variants", () => { }) }) +describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => { + const createModel = (apiId: string) => + ({ + id: `openai/${apiId}`, + providerID: "openai", + api: { + id: apiId, + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + }) as any + + for (const testCase of [ + { id: "gpt-5-chat-latest", options: { store: false } }, + { id: "gpt-5.1-chat-latest", options: { store: false, reasoningEffort: "medium" } }, + { id: "gpt-5.2-chat-latest", options: { store: false, reasoningEffort: "medium" } }, + { id: "gpt-5-search-api", options: { store: false } }, + ]) { + test(`${testCase.id} returns only supported small options`, () => { + expect(ProviderTransform.smallOptions(createModel(testCase.id))).toEqual(testCase.options) + }) + } +}) + describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { const createModel = (overrides: Partial = {}) => ({ From 114eeb21dc5af4649979463dfaa25471b3120468 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 03:33:55 +0000 Subject: [PATCH 0046/1034] chore: generate --- packages/opencode/test/provider/transform.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 064313ec518c..3fdc226375e8 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3068,7 +3068,12 @@ describe("ProviderTransform.variants", () => { for (const testCase of [ { id: "gpt-5.1", releaseDate: "2025-11-13", efforts: ["none", "low", "medium", "high"] }, { id: "gpt-5.4", releaseDate: "2026-03-05", efforts: ["none", "low", "medium", "high", "xhigh"] }, - { id: "gpt-5.5", modelID: "gpt-5-5", releaseDate: "2026-04-23", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { + id: "gpt-5.5", + modelID: "gpt-5-5", + releaseDate: "2026-04-23", + efforts: ["none", "low", "medium", "high", "xhigh"], + }, { id: "gpt-5.4-pro", releaseDate: "2026-03-05", efforts: ["medium", "high", "xhigh"] }, { id: "gpt-5.5-pro", releaseDate: "2026-04-23", efforts: ["medium", "high", "xhigh"] }, { id: "gpt-5-codex", releaseDate: "2025-09-23", efforts: ["low", "medium", "high"] }, From 2ba9aa21961697bf9ff5de3b18becaabe56aefd7 Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Thu, 7 May 2026 23:42:39 -0400 Subject: [PATCH 0047/1034] feat(desktop): working indicator on project sidebar (#26223) --- packages/app/src/pages/layout/sidebar-items.tsx | 12 +++++++++++- packages/app/src/pages/layout/sidebar-project.tsx | 10 +++++++++- .../cli/cmd/tui/component/dialog-session-list.tsx | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 296f035ce276..f27a9bb7a9ea 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -26,7 +26,12 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url return icon?.url } -export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { +export const ProjectIcon = (props: { + project: LocalProject + class?: string + notify?: boolean + working?: boolean +}): JSX.Element => { const globalSync = useGlobalSync() const notification = useNotification() const permission = usePermission() @@ -65,6 +70,11 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti }} /> + +
+ +
+
) } diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 2ba20092c585..58595c25b996 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -56,6 +56,7 @@ const ProjectTile = (props: { sidebarHovering: Accessor selected: Accessor active: Accessor + isWorking: Accessor overlay: Accessor suppressHover: Accessor dirs: Accessor @@ -143,7 +144,7 @@ const ProjectTile = (props: { }} onBlur={() => props.setOpen(false)} > - + @@ -301,6 +302,12 @@ export const SortableProject = (props: { } const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) + const isWorking = createMemo(() => + dirs().some((directory) => { + const [store] = globalSync.child(directory, { bootstrap: false }) + return Object.values(store.session_status).some((status) => status?.type === "busy" || status?.type === "retry") + }), + ) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) @@ -313,6 +320,7 @@ export const SortableProject = (props: { sidebarHovering={props.ctx.sidebarHovering} selected={selected} active={active} + isWorking={isWorking} overlay={overlay} suppressHover={() => state.suppressHover} dirs={dirs} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 6d3322151a57..542449f5df3b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -154,7 +154,7 @@ export function DialogSessionList() { } const isDeleting = toDelete() === x.id const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" + const isWorking = status?.type === "busy" || status?.type === "retry" return { title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title, bg: isDeleting ? theme.error : undefined, From 319498e2fd4e22eb6d38bc5810c1c089cf709162 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 7 May 2026 23:43:42 -0400 Subject: [PATCH 0048/1034] fix(provider): constrain OpenAI deep research efforts (#26273) --- packages/opencode/src/provider/transform.ts | 1 + packages/opencode/test/provider/transform.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 7c0eaced2606..69a0d484f42b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -554,6 +554,7 @@ function gpt5ChatReasoningEfforts(apiId: string) { // to strongest. function openaiReasoningEfforts(apiId: string, releaseDate: string) { const id = apiId.toLowerCase() + if (id.includes("deep-research")) return ["medium"] const chatEfforts = gpt5ChatReasoningEfforts(id) if (chatEfforts) return chatEfforts if (GPT5_PRO_RE.test(id)) return OPENAI_GPT5_PRO_EFFORTS diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 3fdc226375e8..25ed2aadc3b4 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3066,6 +3066,14 @@ describe("ProviderTransform.variants", () => { }) for (const testCase of [ + { id: "o1", releaseDate: "2024-12-17", efforts: ["low", "medium", "high"] }, + { id: "o1-pro", releaseDate: "2025-03-19", efforts: ["low", "medium", "high"] }, + { id: "o3", releaseDate: "2025-04-16", efforts: ["low", "medium", "high"] }, + { id: "o3-mini", releaseDate: "2025-01-31", efforts: ["low", "medium", "high"] }, + { id: "o3-pro", releaseDate: "2025-06-10", efforts: ["low", "medium", "high"] }, + { id: "o4-mini", releaseDate: "2025-04-16", efforts: ["low", "medium", "high"] }, + { id: "o3-deep-research", releaseDate: "2025-06-26", efforts: ["medium"] }, + { id: "o4-mini-deep-research", releaseDate: "2025-06-26", efforts: ["medium"] }, { id: "gpt-5.1", releaseDate: "2025-11-13", efforts: ["none", "low", "medium", "high"] }, { id: "gpt-5.4", releaseDate: "2026-03-05", efforts: ["none", "low", "medium", "high", "xhigh"] }, { From e0396b809a8b685c8f84a2f5f711b68846e17bb5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 00:06:07 -0400 Subject: [PATCH 0049/1034] fix(provider): align Anthropic Opus 4.5 efforts (#26275) --- packages/opencode/src/provider/transform.ts | 4 + .../opencode/test/provider/transform.test.ts | 91 +++++++++---------- 2 files changed, 48 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 69a0d484f42b..210c574d4fa0 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -811,6 +811,10 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v))) { + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { effort }])) + } + return { high: { thinking: { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 25ed2aadc3b4..c52a7bfa44be 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3128,53 +3128,50 @@ describe("ProviderTransform.variants", () => { }) describe("@ai-sdk/anthropic", () => { - test("sonnet 4.6 returns adaptive thinking options", () => { - const model = createMockModel({ - id: "anthropic/claude-sonnet-4-6", - providerID: "anthropic", - api: { - id: "claude-sonnet-4-6", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) - expect(result.high).toEqual({ - thinking: { - type: "adaptive", - }, - effort: "high", - }) - }) - - test("opus 4.7 returns adaptive thinking options with xhigh", () => { - const model = createMockModel({ - id: "anthropic/claude-opus-4-7", - providerID: "anthropic", - api: { - id: "claude-opus-4-7", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - thinking: { - type: "adaptive", - display: "summarized", - }, - effort: "xhigh", - }) - expect(result.max).toEqual({ - thinking: { - type: "adaptive", - display: "summarized", - }, - effort: "max", - }) - }) + for (const testCase of [ + { + name: "opus 4.5", + apiIds: ["claude-opus-4-5-20251101", "claude-opus-4.5-20251101"], + efforts: ["low", "medium", "high"], + expectedHigh: { effort: "high" }, + }, + { + name: "sonnet 4.6", + apiIds: ["claude-sonnet-4-6", "claude-sonnet-4.6"], + efforts: ["low", "medium", "high", "max"], + expectedHigh: { thinking: { type: "adaptive" }, effort: "high" }, + }, + { + name: "opus 4.6", + apiIds: ["claude-opus-4-6", "claude-opus-4.6"], + efforts: ["low", "medium", "high", "max"], + expectedHigh: { thinking: { type: "adaptive" }, effort: "high" }, + }, + { + name: "opus 4.7", + apiIds: ["claude-opus-4-7", "claude-opus-4.7"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + ]) { + for (const apiId of testCase.apiIds) { + test(`${testCase.name} ${apiId} returns supported reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: `anthropic/${apiId}`, + providerID: "anthropic", + api: { + id: apiId, + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + expect(result.high).toEqual(testCase.expectedHigh) + }) + } + } test("github copilot opus 4.7 returns only medium reasoning effort", () => { const model = createMockModel({ From 4e14f79511728d04329da664b747dd0b359cf931 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 7 May 2026 23:17:43 -0500 Subject: [PATCH 0050/1034] fix: tweaks to transform logic for anthropic and bedrock (#26276) --- packages/opencode/src/provider/transform.ts | 18 +- packages/opencode/src/session/message-v2.ts | 39 ++--- .../opencode/test/session/message-v2.test.ts | 157 +++++++++++++++++- 3 files changed, 190 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 210c574d4fa0..3f52f6a2aaf4 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -135,9 +135,16 @@ function normalizeMessages( } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { + if (part.type === "text") { return part.text !== "" } + if (part.type === "reasoning") { + return ( + part.text.trim().length > 0 || + part.providerOptions?.anthropic?.signature != null || + part.providerOptions?.anthropic?.redactedData != null + ) + } return true }) if (filtered.length === 0) return undefined @@ -156,9 +163,16 @@ function normalizeMessages( } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { + if (part.type === "text") { return part.text !== "" } + if (part.type === "reasoning") { + return ( + part.text.trim().length > 0 || + part.providerOptions?.bedrock?.signature != null || + part.providerOptions?.bedrock?.redactedData != null + ) + } return true }) if (filtered.length === 0) return undefined diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ed09262d0efe..2930dbaeb305 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -35,7 +35,7 @@ interface FetchDecompressionError extends Error { path: string } -export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" +export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" export { isMedia } export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) @@ -734,25 +734,25 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( const result: UIMessage[] = [] const toolNames = new Set() // Track media from tool results that need to be injected as user messages - // for providers that don't support media in tool results. + // for providers that don't support that media type in tool results. // // OpenAI-compatible APIs only support string content in tool results, so we need - // to extract media and inject as user messages. Other SDKs (anthropic, google, - // bedrock) handle type: "content" with media parts natively. + // to extract media and inject as user messages. Some SDKs only support a subset + // of media in tool results; e.g. Bedrock supports images but not PDFs there. // - // Only apply this workaround if the model actually supports image input - - // otherwise there's no point extracting images. - const supportsMediaInToolResults = (() => { + // Only apply this workaround if the model actually supports that media input - + // otherwise unsupportedParts() will turn it into a user-visible error. + const supportsMediaInToolResult = (attachment: { mime: string }) => { if (model.api.npm === "@ai-sdk/anthropic") return true if (model.api.npm === "@ai-sdk/openai") return true - if (model.api.npm === "@ai-sdk/amazon-bedrock") return true + if (model.api.npm === "@ai-sdk/amazon-bedrock") return attachment.mime.startsWith("image/") if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true if (model.api.npm === "@ai-sdk/google") { const id = model.api.id.toLowerCase() return id.includes("gemini-3") && !id.includes("gemini-2") } return false - })() + } const toModelOutput = (options: { toolCallId: string; input: unknown; output: unknown }) => { const output = options.output @@ -797,9 +797,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "user", parts: [], } - result.push(userMessage) for (const part of msg.parts) { - if (part.type === "text" && !part.ignored) + // User message parts should never be empty + if (part.type === "text" && !part.ignored && part.text !== "") userMessage.parts.push({ type: "text", text: part.text, @@ -834,11 +834,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } } + if (userMessage.parts.length > 0) result.push(userMessage) } if (msg.info.role === "assistant") { const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` - const media: Array<{ mime: string; url: string }> = [] + const media: Array<{ mime: string; url: string; filename?: string }> = [] if ( msg.info.error && @@ -864,11 +865,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // a proxy, or a lower-level library, but preserving a non-empty separator // here is the only safe replay point we have. // Use a single space so the separator survives replay without changing - // the neighboring signed reasoning blocks. Bedrock-hosted Claude stores - // the same signature under the bedrock metadata namespace. + // the neighboring signed reasoning blocks. const hasSignedReasoning = msg.parts.some((part) => { if (part.type !== "reasoning") return false - return part.metadata?.anthropic?.signature != null || part.metadata?.bedrock?.signature != null + return part.metadata?.anthropic?.signature != null }) for (const part of msg.parts) { if (part.type === "text") { @@ -894,11 +894,11 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // For providers that don't support media in tool results, extract media files // (images, PDFs) to be sent as a separate user message const mediaAttachments = attachments.filter((a) => isMedia(a.mime)) - const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime)) - if (!supportsMediaInToolResults && mediaAttachments.length > 0) { - media.push(...mediaAttachments) + const extractedMedia = mediaAttachments.filter((a) => !supportsMediaInToolResult(a)) + if (extractedMedia.length > 0) { + media.push(...extractedMedia) } - const finalAttachments = supportsMediaInToolResults ? attachments : nonMediaAttachments + const finalAttachments = attachments.filter((a) => !isMedia(a.mime) || supportsMediaInToolResult(a)) const output = finalAttachments.length > 0 @@ -988,6 +988,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( type: "file" as const, url: attachment.url, mediaType: attachment.mime, + filename: attachment.filename, })), ], }) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 999b61b48e56..d9c71f8c07b4 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -155,6 +155,54 @@ describe("session.message-v2.toModelMessage", () => { expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) + test("filters out user messages with only empty text parts", async () => { + const messageID = "m-user" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + text: "", + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) + }) + + test("filters empty user text parts while keeping non-empty parts", async () => { + const messageID = "m-user" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + text: "", + }, + { + ...basePart(messageID, "p2"), + type: "text", + text: "hello", + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + ]) + }) + test("includes synthetic text parts", async () => { const messageID = "m-user" @@ -443,6 +491,108 @@ describe("session.message-v2.toModelMessage", () => { }) }) + test("moves bedrock pdf tool-result media into a separate user message", async () => { + const bedrockModel: Provider.Model = { + ...model, + id: ModelID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"), + providerID: ProviderID.make("amazon-bedrock"), + api: { + id: "anthropic.claude-sonnet-4-6", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + capabilities: { + ...model.capabilities, + attachment: true, + input: { + ...model.capabilities.input, + image: true, + pdf: true, + }, + }, + } + const pdf = Buffer.from("%PDF-1.4\n").toString("base64") + const userID = "m-user-bedrock-pdf" + const assistantID = "m-assistant-bedrock-pdf" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1-bedrock-pdf"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1-bedrock-pdf"), + type: "tool", + callID: "call-bedrock-pdf-1", + tool: "read", + state: { + status: "completed", + input: { filePath: "/tmp/example.pdf" }, + output: "PDF read successfully", + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + ...basePart(assistantID, "file-bedrock-pdf-1"), + type: "file", + mime: "application/pdf", + filename: "example.pdf", + url: `data:application/pdf;base64,${pdf}`, + }, + ], + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, bedrockModel)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-bedrock-pdf-1", + toolName: "read", + input: { filePath: "/tmp/example.pdf" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-bedrock-pdf-1", + toolName: "read", + output: { type: "text", value: "PDF read successfully" }, + }, + ], + }, + { + role: "user", + content: [ + { type: "text", text: "Attached media from tool result:" }, + { type: "file", mediaType: "application/pdf", filename: "example.pdf", data: `data:application/pdf;base64,${pdf}` }, + ], + }, + ]) + }) + test("omits provider metadata when assistant model differs", async () => { const userID = "m-user" const assistantID = "m-assistant" @@ -1134,8 +1284,9 @@ describe("session.message-v2.toModelMessage", () => { expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer") }) - test("substitutes space for empty text when reasoning signature is under 'bedrock' namespace", async () => { - // AWS Bedrock hosts Anthropic Claude but stores signatures under metadata.bedrock + test("leaves empty text alone when reasoning signature is under 'bedrock' namespace", async () => { + // Bedrock signed reasoning is preserved as reasoning metadata, but unlike the + // direct Anthropic path we do not preserve empty text separators for Bedrock. const assistantID = "m-assistant-bedrock" const input: MessageV2.WithParts[] = [ { @@ -1157,7 +1308,7 @@ describe("session.message-v2.toModelMessage", () => { expect(result).toHaveLength(1) const texts = (result[0].content as any[]).filter((p) => p.type === "text") - expect(texts.map((t) => t.text)).toStrictEqual([" ", "answer"]) + expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"]) }) test("leaves empty text alone when reasoning has no Anthropic signature", async () => { From 9c88235121eda5f5afd2dd806fbc6e6d8eef664d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 04:18:54 +0000 Subject: [PATCH 0051/1034] chore: generate --- packages/opencode/test/session/message-v2.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index d9c71f8c07b4..08629f5b1b4a 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -587,7 +587,12 @@ describe("session.message-v2.toModelMessage", () => { role: "user", content: [ { type: "text", text: "Attached media from tool result:" }, - { type: "file", mediaType: "application/pdf", filename: "example.pdf", data: `data:application/pdf;base64,${pdf}` }, + { + type: "file", + mediaType: "application/pdf", + filename: "example.pdf", + data: `data:application/pdf;base64,${pdf}`, + }, ], }, ]) From 30868f52ea997ada6ac452e47ec00fb5ee59302c Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 00:26:08 -0400 Subject: [PATCH 0052/1034] go: update rate limit error copy --- packages/console/app/src/i18n/ar.ts | 8 ++++++-- packages/console/app/src/i18n/br.ts | 8 ++++++-- packages/console/app/src/i18n/da.ts | 8 ++++++-- packages/console/app/src/i18n/de.ts | 8 ++++++-- packages/console/app/src/i18n/en.ts | 8 ++++++-- packages/console/app/src/i18n/es.ts | 8 ++++++-- packages/console/app/src/i18n/fr.ts | 8 ++++++-- packages/console/app/src/i18n/it.ts | 8 ++++++-- packages/console/app/src/i18n/ja.ts | 8 ++++++-- packages/console/app/src/i18n/ko.ts | 8 ++++++-- packages/console/app/src/i18n/no.ts | 8 ++++++-- packages/console/app/src/i18n/pl.ts | 8 ++++++-- packages/console/app/src/i18n/ru.ts | 8 ++++++-- packages/console/app/src/i18n/th.ts | 8 ++++++-- packages/console/app/src/i18n/tr.ts | 8 ++++++-- packages/console/app/src/i18n/zh.ts | 7 ++++++- packages/console/app/src/i18n/zht.ts | 7 ++++++- .../console/app/src/routes/zen/util/handler.ts | 16 +++++++++++++--- 18 files changed, 115 insertions(+), 35 deletions(-) diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 12ec7f1fbd6e..f413b5572f8c 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -355,8 +355,12 @@ export const dict = { "zen.api.error.missingApiKey": "مفتاح API مفقود.", "zen.api.error.invalidApiKey": "مفتاح API غير صالح.", "zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "تم تجاوز حصة الاشتراك. يمكنك الاستمرار في استخدام النماذج المجانية.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "تم الوصول إلى حد الاستخدام لمدة 5 ساعات. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "تم الوصول إلى حد الاستخدام الأسبوعي. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "تم الوصول إلى حد الاستخدام الشهري. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "لا توجد طريقة دفع. أضف طريقة دفع هنا: {{billingUrl}}", "zen.api.error.insufficientBalance": "رصيد غير كاف. إدارة فواتيرك هنا: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 0a6d8f153e97..8466acc5fd70 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "Chave de API ausente.", "zen.api.error.invalidApiKey": "Chave de API inválida.", "zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Cota de assinatura excedida. Você pode continuar usando modelos gratuitos.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Limite de uso de 5 horas atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Limite de uso semanal atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Limite de uso mensal atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Nenhuma forma de pagamento. Adicione uma forma de pagamento aqui: {{billingUrl}}", "zen.api.error.insufficientBalance": "Saldo insuficiente. Gerencie seu faturamento aqui: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 15e7151b670e..9338e3add5f9 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -359,8 +359,12 @@ export const dict = { "zen.api.error.missingApiKey": "Manglende API-nøgle.", "zen.api.error.invalidApiKey": "Ugyldig API-nøgle.", "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonnementskvote overskredet. Du kan fortsætte med at bruge gratis modeller.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Forbrugsgrænsen for 5 timer er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Ugentlig forbrugsgrænse er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Månedlig forbrugsgrænse er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Tilføj en betalingsmetode her: {{billingUrl}}", "zen.api.error.insufficientBalance": "Utilstrækkelig saldo. Administrer din fakturering her: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 0efcce78bff8..7a2d3e91b4bd 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -362,8 +362,12 @@ export const dict = { "zen.api.error.missingApiKey": "Fehlender API-Key.", "zen.api.error.invalidApiKey": "Ungültiger API-Key.", "zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonnement-Quote überschritten. Du kannst weiterhin kostenlose Modelle nutzen.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5-Stunden-Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Wöchentliches Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Monatliches Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Keine Zahlungsmethode. Füge hier eine Zahlungsmethode hinzu: {{billingUrl}}", "zen.api.error.insufficientBalance": "Unzureichendes Guthaben. Verwalte deine Abrechnung hier: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index f2cf3c14a4e8..b7ef397be6dd 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -355,8 +355,12 @@ export const dict = { "zen.api.error.missingApiKey": "Missing API key.", "zen.api.error.invalidApiKey": "Invalid API key.", "zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Subscription quota exceeded. You can continue using free models.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5-hour usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Weekly usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Monthly usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "No payment method. Add a payment method here: {{billingUrl}}", "zen.api.error.insufficientBalance": "Insufficient balance. Manage your billing here: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 5614a8c7ad5c..f6347d3b522d 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "Falta la clave API.", "zen.api.error.invalidApiKey": "Clave API inválida.", "zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Cuota de suscripción excedida. Puedes continuar usando modelos gratuitos.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Límite de uso de 5 horas alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Límite de uso semanal alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Límite de uso mensual alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Sin método de pago. Añade un método de pago aquí: {{billingUrl}}", "zen.api.error.insufficientBalance": "Saldo insuficiente. Gestiona tu facturación aquí: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 390025d27575..5d1cd0fab73d 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "Clé API manquante.", "zen.api.error.invalidApiKey": "Clé API invalide.", "zen.api.error.subscriptionQuotaExceeded": "Quota d'abonnement dépassé. Réessayez dans {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Quota d'abonnement dépassé. Vous pouvez continuer à utiliser les modèles gratuits.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Limite d'utilisation sur 5 heures atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Limite d'utilisation hebdomadaire atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Limite d'utilisation mensuelle atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Aucune méthode de paiement. Ajoutez une méthode de paiement ici : {{billingUrl}}", "zen.api.error.insufficientBalance": "Solde insuffisant. Gérez votre facturation ici : {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 3737186996bd..07da9434eb6c 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -359,8 +359,12 @@ export const dict = { "zen.api.error.missingApiKey": "Chiave API mancante.", "zen.api.error.invalidApiKey": "Chiave API non valida.", "zen.api.error.subscriptionQuotaExceeded": "Quota dell'abbonamento superata. Riprova tra {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Quota dell'abbonamento superata. Puoi continuare a utilizzare modelli gratuiti.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Limite di utilizzo di 5 ore raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Limite di utilizzo settimanale raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Limite di utilizzo mensile raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Nessun metodo di pagamento. Aggiungi un metodo di pagamento qui: {{billingUrl}}", "zen.api.error.insufficientBalance": "Saldo insufficiente. Gestisci la tua fatturazione qui: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 66f3c4a89dd6..975728fe7e58 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -360,8 +360,12 @@ export const dict = { "zen.api.error.invalidApiKey": "無効なAPIキーです。", "zen.api.error.subscriptionQuotaExceeded": "サブスクリプションの制限を超えました。{{retryIn}} 後に再試行してください。", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "サブスクリプションの制限を超えました。無料モデルは引き続きご利用いただけます。", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5時間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "週間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "月間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "お支払い方法がありません。こちらからお支払い方法を追加してください: {{billingUrl}}", "zen.api.error.insufficientBalance": "残高が不足しています。こちらから請求を管理してください: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 04482d35f639..293c3eb7d990 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -354,8 +354,12 @@ export const dict = { "zen.api.error.missingApiKey": "API 키가 누락되었습니다.", "zen.api.error.invalidApiKey": "유효하지 않은 API 키입니다.", "zen.api.error.subscriptionQuotaExceeded": "구독 할당량을 초과했습니다. {{retryIn}} 후 다시 시도해 주세요.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "구독 할당량을 초과했습니다. 무료 모델은 계속 사용할 수 있습니다.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5시간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "주간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "월간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "결제 수단이 없습니다. 결제 수단을 추가하세요: {{billingUrl}}", "zen.api.error.insufficientBalance": "잔액이 부족합니다. 결제 관리를 여기서 하세요: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 31200d3edd4f..27b5522e3261 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -360,8 +360,12 @@ export const dict = { "zen.api.error.missingApiKey": "Mangler API-nøkkel.", "zen.api.error.invalidApiKey": "Ugyldig API-nøkkel.", "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igjen om {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonnementskvote overskredet. Du kan fortsette å bruke gratis modeller.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5-timers bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Ukentlig bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Månedlig bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Legg til en betalingsmetode her: {{billingUrl}}", "zen.api.error.insufficientBalance": "Utilstrekkelig saldo. Administrer faktureringen din her: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 50d904bc568a..7f8c84915658 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -361,8 +361,12 @@ export const dict = { "zen.api.error.missingApiKey": "Brak klucza API.", "zen.api.error.invalidApiKey": "Nieprawidłowy klucz API.", "zen.api.error.subscriptionQuotaExceeded": "Przekroczono limit subskrypcji. Spróbuj ponownie za {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Przekroczono limit subskrypcji. Możesz kontynuować korzystanie z darmowych modeli.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Osiągnięto 5-godzinny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Osiągnięto tygodniowy limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Osiągnięto miesięczny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Brak metody płatności. Dodaj metodę płatności tutaj: {{billingUrl}}", "zen.api.error.insufficientBalance": "Niewystarczające saldo. Zarządzaj swoimi płatnościami tutaj: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 651309fc95d8..4ac54c2ac0f6 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -365,8 +365,12 @@ export const dict = { "zen.api.error.missingApiKey": "Отсутствует API ключ.", "zen.api.error.invalidApiKey": "Неверный API ключ.", "zen.api.error.subscriptionQuotaExceeded": "Квота подписки превышена. Повторите попытку через {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Квота подписки превышена. Вы можете продолжить использовать бесплатные модели.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Достигнут лимит использования за 5 часов. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Достигнут недельный лимит использования. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Достигнут месячный лимит использования. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Нет способа оплаты. Добавьте способ оплаты здесь: {{billingUrl}}", "zen.api.error.insufficientBalance": "Недостаточно средств. Управляйте оплатой здесь: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 42c9e455fd43..280b9d9fa8c3 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -356,8 +356,12 @@ export const dict = { "zen.api.error.missingApiKey": "ไม่มี API key", "zen.api.error.invalidApiKey": "API key ไม่ถูกต้อง", "zen.api.error.subscriptionQuotaExceeded": "โควต้าการสมัครสมาชิกเกินขีดจำกัด ลองใหม่ในอีก {{retryIn}}", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "โควต้าการสมัครสมาชิกเกินขีดจำกัด คุณสามารถดำเนินการต่อโดยใช้โมเดลฟรี", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "ถึงขีดจำกัดการใช้งานในรอบ 5 ชั่วโมงแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "ถึงขีดจำกัดการใช้งานรายสัปดาห์แล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "ถึงขีดจำกัดการใช้งานรายเดือนแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "ไม่มีวิธีการชำระเงิน เพิ่มวิธีการชำระเงินที่นี่: {{billingUrl}}", "zen.api.error.insufficientBalance": "ยอดเงินคงเหลือไม่เพียงพอ จัดการการเรียกเก็บเงินของคุณที่นี่: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 64380db375fc..a8f449dc471b 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "API anahtarı eksik.", "zen.api.error.invalidApiKey": "Geçersiz API anahtarı.", "zen.api.error.subscriptionQuotaExceeded": "Abonelik kotası aşıldı. {{retryIn}} içinde tekrar deneyin.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonelik kotası aşıldı. Ücretsiz modelleri kullanmaya devam edebilirsiniz.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5 saatlik kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Haftalık kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Aylık kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Ödeme yöntemi bulunamadı. Buradan bir ödeme yöntemi ekleyin: {{billingUrl}}", "zen.api.error.insufficientBalance": "Yetersiz bakiye. Faturalandırmanızı buradan yönetin: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 3b104cca6d75..ced0060ca02f 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -343,7 +343,12 @@ export const dict = { "zen.api.error.missingApiKey": "缺少 API 密钥。", "zen.api.error.invalidApiKey": "无效的 API 密钥。", "zen.api.error.subscriptionQuotaExceeded": "超出订阅配额。请在 {{retryIn}} 后重试。", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出订阅配额。您可以继续使用免费模型。", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "已达到 5 小时使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "已达到每周使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "已达到每月使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "没有付款方式。请在此处添加付款方式:{{billingUrl}}", "zen.api.error.insufficientBalance": "余额不足。请在此处管理您的计费:{{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index a4d5512da44d..e3e374a329ca 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -343,7 +343,12 @@ export const dict = { "zen.api.error.missingApiKey": "缺少 API 金鑰。", "zen.api.error.invalidApiKey": "無效的 API 金鑰。", "zen.api.error.subscriptionQuotaExceeded": "超出訂閱配額。請在 {{retryIn}} 後重試。", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出訂閱配額。你可以繼續使用免費模型。", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "已達 5 小時使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "已達每週使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "已達每月使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "無付款方式。請在此處新增付款方式:{{billingUrl}}", "zen.api.error.insufficientBalance": "餘額不足。請在此處管理你的帳務:{{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 4b6fe5feb81b..278a54161092 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -744,6 +744,7 @@ export async function handler( // Validate lite subscription billing if (opts.modelList === "lite" && authInfo.billing.lite && authInfo.lite) { try { + const consoleGoUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/go` const sub = authInfo.lite const liteData = LiteData.getLimits() @@ -756,7 +757,10 @@ export async function handler( }) if (result.status === "rate-limited") throw new GoUsageLimitError( - t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + t("zen.api.error.goSubscriptionWeeklyLimitExceeded", { + retryIn: formatRetryTime(result.resetInSec), + consoleGoUrl, + }), authInfo.workspaceID, "weekly", result.resetInSec, @@ -773,7 +777,10 @@ export async function handler( }) if (result.status === "rate-limited") throw new GoUsageLimitError( - t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + t("zen.api.error.goSubscriptionMonthlyLimitExceeded", { + retryIn: formatRetryTime(result.resetInSec), + consoleGoUrl, + }), authInfo.workspaceID, "monthly", result.resetInSec, @@ -790,7 +797,10 @@ export async function handler( }) if (result.status === "rate-limited") throw new GoUsageLimitError( - t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + t("zen.api.error.goSubscriptionRollingLimitExceeded", { + retryIn: formatRetryTime(result.resetInSec), + consoleGoUrl, + }), authInfo.workspaceID, "5 hour", result.resetInSec, From dd8bb44d1db520ada12da5efc5611696372d0810 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 8 May 2026 13:34:53 +0800 Subject: [PATCH 0053/1034] refactor(desktop): use electron-log in shell-env and simplify env merging (#26284) --- packages/desktop/src/main/logging.ts | 6 +++++- packages/desktop/src/main/server.ts | 19 ++++++++----------- packages/desktop/src/main/shell-env.ts | 12 +++++++----- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts index 1f1c5e54e35f..5d373ed27fbd 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -1,3 +1,4 @@ +import { MainLogger } from "electron-log" import log from "electron-log/main.js" import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs" import { dirname, join } from "node:path" @@ -5,11 +6,14 @@ import { dirname, join } from "node:path" const MAX_LOG_AGE_DAYS = 7 const TAIL_LINES = 1000 +let logger: MainLogger +export const getLogger = () => logger + export function initLogging() { log.transports.file.maxSize = 5 * 1024 * 1024 initConsoleTransport() cleanup() - return log + return (logger = log) } export function tail(): string { diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 635a93578afc..909138b89cf8 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url" import { app, utilityProcess } from "electron" import type { Details } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" -import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env" +import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" import type { SqliteMigrationProgress } from "../preload/types" @@ -57,16 +57,13 @@ export function setWslConfig(config: WslConfig) { export function preferAppEnv(userDataPath: string) { const shell = process.platform === "win32" ? null : getUserShell() - Object.assign( - process.env, - mergeShellEnv(shell ? loadShellEnv(shell) : null, { - ...process.env, - OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", - XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, - }), - ) + Object.assign(process.env, { + ...(shell ? loadShellEnv(shell) : null), + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_CLIENT: "desktop", + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + }) } export async function spawnLocalServer( diff --git a/packages/desktop/src/main/shell-env.ts b/packages/desktop/src/main/shell-env.ts index f57677323c5b..8a1ee1f5866b 100644 --- a/packages/desktop/src/main/shell-env.ts +++ b/packages/desktop/src/main/shell-env.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process" import { basename } from "node:path" +import { getLogger } from "./logging"; const TIMEOUT = 5_000 @@ -55,28 +56,29 @@ export function isNushell(shell: string) { } export function loadShellEnv(shell: string) { + const logger = getLogger() if (isNushell(shell)) { - console.log(`[server] Skipping shell env probe for nushell: ${shell}`) + logger.log(`[server] Skipping shell env probe for nushell: ${shell}`) return null } const interactive = probe(shell, "-il") if (interactive.type === "Loaded") { - console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) + logger.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) return interactive.value } if (interactive.type === "Timeout") { - console.warn(`[server] Interactive shell env probe timed out: ${shell}`) + logger.log(`[server] Interactive shell env probe timed out: ${shell}`) return null } const login = probe(shell, "-l") if (login.type === "Loaded") { - console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) + logger.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) return login.value } - console.warn(`[server] Falling back to app environment: ${shell}`) + logger.log(`[server] Falling back to app environment: ${shell}`) return null } From cef0c8ac844189872875cae6950fc14ee3b522cb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 05:36:06 +0000 Subject: [PATCH 0054/1034] chore: generate --- packages/desktop/src/main/shell-env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/src/main/shell-env.ts b/packages/desktop/src/main/shell-env.ts index 8a1ee1f5866b..4a65fbf0f7f8 100644 --- a/packages/desktop/src/main/shell-env.ts +++ b/packages/desktop/src/main/shell-env.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process" import { basename } from "node:path" -import { getLogger } from "./logging"; +import { getLogger } from "./logging" const TIMEOUT = 5_000 From 6f165e23deae6d3a812af2b0aaf1557d94251a15 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 8 May 2026 15:36:28 +1000 Subject: [PATCH 0055/1034] perf(ui): defer tool status width measurement (#26282) --- .../ui/src/components/tool-status-title.tsx | 124 +++++++++--------- 1 file changed, 61 insertions(+), 63 deletions(-) diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 412d92e3dbcf..5c46593f7100 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,4 +1,4 @@ -import { Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" +import { Show, createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { TextShimmer } from "./text-shimmer" @@ -15,10 +15,8 @@ function common(active: string, done: string) { } function contentWidth(el: HTMLSpanElement | undefined) { - if (!el) return 0 - const range = document.createRange() - range.selectNodeContents(el) - return Math.ceil(range.getBoundingClientRect().width) + if (!el) return + return `${Math.ceil(el.getBoundingClientRect().width)}px` } export function ToolStatusTitle(props: { @@ -37,99 +35,99 @@ export function ToolStatusTitle(props: { const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) const [state, setState] = createStore({ - width: "auto", - ready: false, + active: props.active, + animating: false, + width: undefined as string | undefined, }) const width = () => state.width - const ready = () => state.ready + const active = () => state.active + const animating = () => state.animating let activeRef: HTMLSpanElement | undefined let doneRef: HTMLSpanElement | undefined + let widthRef: HTMLSpanElement | undefined let frame: number | undefined - let readyFrame: number | undefined + let finishTimer: ReturnType | undefined - const measure = () => { - const target = props.active ? activeRef : doneRef - const px = contentWidth(target) - if (px > 0) setState("width", `${px}px`) + const finish = () => { + if (frame !== undefined) cancelAnimationFrame(frame) + if (finishTimer !== undefined) clearTimeout(finishTimer) + frame = undefined + finishTimer = undefined + setState("animating", false) + setState("width", undefined) } - const schedule = () => { - if (typeof requestAnimationFrame !== "function") { - measure() + const animate = () => { + const first = contentWidth(widthRef) + finish() + setState("animating", true) + setState("active", props.active) + const last = contentWidth(props.active ? activeRef : doneRef) + if (!first || !last) { + finish() return } - if (frame !== undefined) cancelAnimationFrame(frame) - frame = requestAnimationFrame(() => { - frame = undefined - measure() - }) - } - const finish = () => { - if (typeof requestAnimationFrame !== "function") { - setState("ready", true) + setState("width", first) + if (first === last) { + finishTimer = setTimeout(finish, 600) return } - if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) - readyFrame = requestAnimationFrame(() => { - readyFrame = undefined - setState("ready", true) + + frame = requestAnimationFrame(() => { + frame = undefined + setState("width", last) + finishTimer = setTimeout(finish, 600) }) } - createEffect(on([() => props.active, activeTail, doneTail, suffix], () => schedule())) - - onMount(() => { - measure() - const fonts = typeof document !== "undefined" ? document.fonts : undefined - if (!fonts) { - finish() - return - } - void fonts.ready.finally(() => { - measure() - finish() - }) - }) + createEffect(on([() => props.active, activeTail, doneTail], () => animate(), { defer: true })) onCleanup(() => { - if (frame !== undefined) cancelAnimationFrame(frame) - if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) + finish() }) return ( - - - - - - + + + + + + + + + + + } > - + - - - - - - - + + + + + + + + + + + From bb3f14119b25edcf0478757f7c5f9e1a8e664dab Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 01:51:31 -0400 Subject: [PATCH 0056/1034] tui: update go upsell copy --- packages/opencode/src/session/retry.ts | 24 ++++++++++---------- packages/opencode/test/session/retry.test.ts | 8 ++++--- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index a4ef5b7a8fa2..3bccee212d33 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -6,7 +6,6 @@ import { iife } from "@/util/iife" export type Err = ReturnType export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" -export const PAYG_UPSELL_MESSAGE = "Go usage exceeded, enable PAYG" export const GO_UPSELL_URL = "https://opencode.ai/go" export type Retryable = { @@ -83,11 +82,11 @@ export function retryable(error: Err) { if (error.data.responseBody?.includes("GoUsageLimitError")) { const body = parseJSON(error.data.responseBody) const workspace = str(body?.metadata?.workspace) - const limit = str(body?.metadata?.limit) - const resetAt = num(body?.metadata?.resetAt) + const limitName = str(body?.metadata?.limitName) + const retryAfter = num(error.data.responseHeaders?.["retry-after"]) const resetIn = iife(() => { - if (resetAt === undefined) return "" - const seconds = Math.max(0, Math.ceil(resetAt)) + if (retryAfter === undefined) return "" + const seconds = Math.max(0, Math.ceil(retryAfter)) const days = Math.floor(seconds / 86_400) const hours = Math.floor((seconds % 86_400) / 3_600) const minutes = Math.ceil((seconds % 3_600) / 60) @@ -97,16 +96,17 @@ export function retryable(error: Err) { if (hours > 0) return minutes > 0 ? `${unit(hours, "hour")} ${unit(minutes, "minute")}` : unit(hours, "hour") return minutes > 0 ? unit(minutes, "minute") : "less than a minute" }) + + const message = `${limitName} usage limit reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance` + + const link = `https://opencode.ai/workspace/${workspace}/go` return { - message: PAYG_UPSELL_MESSAGE, + message: `${message} - ${link}`, action: { title: "Go limit reached", - message: - limit && resetIn - ? `You hit your ${limit} limit. It will reset in ${resetIn}. You can also enable pay-as-you-go.` - : "Enable pay-as-you-go to keep using Go models after your subscription quota is used.", - label: "enable PAYG", - ...(workspace ? { link: `https://opencode.ai/workspace/${workspace}/go` } : {}), + message, + label: "open settings", + link, }, } } diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 8a4d6d6af02a..9bb5e4865276 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -279,11 +279,13 @@ describe("session.retry.retryable", () => { ) expect(SessionRetry.retryable(error)).toEqual({ - message: SessionRetry.PAYG_UPSELL_MESSAGE, + message: + "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance - https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", action: { title: "Go limit reached", - message: "You hit your 5 hour limit. It will reset in 5 hours 23 minutes. You can also enable pay-as-you-go.", - label: "enable PAYG", + message: + "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance", + label: "open settings", link: "https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", }, }) From 21ae91b4f237f9ef9947a6988f4a25de3ab1c31a Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 8 May 2026 14:19:09 +0800 Subject: [PATCH 0057/1034] refactor(desktop): convert main process to Effect-TS (#26148) --- packages/desktop/src/main/index.ts | 606 +++++++++++---------------- packages/desktop/src/main/updater.ts | 126 ++++++ 2 files changed, 362 insertions(+), 370 deletions(-) create mode 100644 packages/desktop/src/main/updater.ts diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 52e45a702cd6..1b624800e8e0 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -7,38 +7,9 @@ import { homedir, tmpdir } from "node:os" import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" -import { app, BrowserWindow, dialog } from "electron" -import pkg from "electron-updater" +import { app, BrowserWindow } from "electron" import contextMenu from "electron-context-menu" -contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) - -// on macOS apps run in `/` which can cause issues with ripgrep -try { - process.chdir(homedir()) -} catch {} - -process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" - -const APP_NAMES: Record = { - dev: "OpenCode Dev", - beta: "OpenCode Beta", - prod: "OpenCode", -} -const APP_IDS: Record = { - dev: "ai.opencode.desktop.dev", - beta: "ai.opencode.desktop.beta", - prod: "ai.opencode.desktop", -} -const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" -const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" -const onboardingTestRoot = setupOnboardingTestEnv() -app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") -app.setAppUserModelId(appId) -app.setPath("userData", onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId)) -if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) -const logger = initLogging() -const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" @@ -64,45 +35,125 @@ import { setDockIcon, } from "./windows" import { migrate } from "./migrate" +import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater" +import { Deferred, Effect, Fiber } from "effect" -const initEmitter = new EventEmitter() -let initStep: InitStep = { phase: "server_waiting" } +const APP_NAMES: Record = { + dev: "OpenCode Dev", + beta: "OpenCode Beta", + prod: "OpenCode", +} +const APP_IDS: Record = { + dev: "ai.opencode.desktop.dev", + beta: "ai.opencode.desktop.beta", + prod: "ai.opencode.desktop", +} +const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" +let logger: ReturnType let mainWindow: BrowserWindow | null = null let server: SidecarListener | null = null -const loadingComplete = defer() + +const initEmitter = new EventEmitter() +let initStep: InitStep = { phase: "server_waiting" } const pendingDeepLinks: string[] = [] -const serverReady = defer() +function useEnvProxy() { + try { + // Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2. + ;(http as any).setGlobalProxyFromEnv() + } catch (error) { + logger.warn("failed to load proxy environment", error) + } +} -useSystemCertificates() +function emitDeepLinks(urls: string[]) { + if (urls.length === 0) return + pendingDeepLinks.push(...urls) + if (mainWindow) sendDeepLinks(mainWindow, urls) +} -function setupOnboardingTestEnv() { - if (!TEST_ONBOARDING) return +function setInitStep(step: InitStep) { + initStep = step + logger.log("init step", { step }) + initEmitter.emit("step", step) +} - const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) - rmSync(root, { recursive: true, force: true }) - ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => - mkdirSync(join(root, dir), { recursive: true }), - ) - process.env.OPENCODE_DB = ":memory:" - process.env.XDG_DATA_HOME = join(root, "data") - process.env.XDG_CONFIG_HOME = join(root, "config") - process.env.XDG_CACHE_HOME = join(root, "cache") - process.env.XDG_STATE_HOME = join(root, "state") - return root +async function killSidecar() { + if (!server) return + const current = server + server = null + await current.stop() } -logger.log("app starting", { - version: app.getVersion(), - packaged: app.isPackaged, - onboardingTest: Boolean(onboardingTestRoot), -}) +function ensureLoopbackNoProxy() { + const loopback = ["127.0.0.1", "localhost", "::1"] + const upsert = (key: string) => { + const items = (process.env[key] ?? "") + .split(",") + .map((value: string) => value.trim()) + .filter((value: string) => Boolean(value)) -setupApp() + for (const host of loopback) { + if (items.some((value: string) => value.toLowerCase() === host)) continue + items.push(host) + } + + process.env[key] = items.join(",") + } + + upsert("NO_PROXY") + upsert("no_proxy") +} + +const main = Effect.gen(function* () { + contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) + + // on macOS apps run in `/` which can cause issues with ripgrep + try { + process.chdir(homedir()) + } catch {} + + process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" + + const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" + const onboardingTestRoot = ((): string | undefined => { + if (!TEST_ONBOARDING) return + + const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) + rmSync(root, { recursive: true, force: true }) + ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => + mkdirSync(join(root, dir), { recursive: true }), + ) + process.env.OPENCODE_DB = ":memory:" + process.env.XDG_DATA_HOME = join(root, "data") + process.env.XDG_CONFIG_HOME = join(root, "config") + process.env.XDG_CACHE_HOME = join(root, "cache") + process.env.XDG_STATE_HOME = join(root, "state") + return root + })() + app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") + app.setAppUserModelId(appId) + app.setPath( + "userData", + onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId), + ) + if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) + logger = initLogging() + + try { + setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) + } catch (error) { + logger.warn("failed to load system certificates", error) + } + + logger.log("app starting", { + version: app.getVersion(), + packaged: app.isPackaged, + onboardingTest: Boolean(onboardingTestRoot), + }) -function setupApp() { ensureLoopbackNoProxy() useEnvProxy() app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") @@ -121,7 +172,10 @@ function setupApp() { logger.log("deep link received via second-instance", { urls }) emitDeepLinks(urls) } - focusMainWindow() + if (mainWindow) { + mainWindow.show() + mainWindow.focus() + } }) app.on("open-url", (event: Event, url: string) => { @@ -144,61 +198,91 @@ function setupApp() { }) } - void app.whenReady().then(async () => { - if (!TEST_ONBOARDING) migrate() - app.setAsDefaultProtocolClient("opencode") - registerRendererProtocol() - setDockIcon() - setupAutoUpdater() - await initialize() + const serverReady = Deferred.makeUnsafe() + const loadingComplete = Deferred.makeUnsafe() + + registerIpcHandlers({ + killSidecar: () => killSidecar(), + awaitInitialization: Effect.fnUntraced( + function* (sendStep) { + sendStep(initStep) + const listener = (step: InitStep) => sendStep(step) + initEmitter.on("step", listener) + try { + logger.log("awaiting server ready") + const res = yield* Deferred.await(serverReady) + logger.log("server ready", { url: res.url }) + return res + } finally { + initEmitter.off("step", listener) + } + }, + (e) => Effect.runPromise(e), + ), + getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), + consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), + getDefaultServerUrl: () => getDefaultServerUrl(), + setDefaultServerUrl: (url) => setDefaultServerUrl(url), + getWslConfig: () => Promise.resolve(getWslConfig()), + setWslConfig: (config: WslConfig) => setWslConfig(config), + getDisplayBackend: async () => null, + setDisplayBackend: async () => undefined, + parseMarkdown: async (markdown) => parseMarkdown(markdown), + checkAppExists: (appName) => checkAppExists(appName), + wslPath: async (path, mode) => wslPath(path, mode), + resolveAppPath: async (appName) => resolveAppPath(appName), + loadingWindowComplete: () => Deferred.doneUnsafe(loadingComplete, Effect.void), + runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar), + checkUpdate: async () => checkUpdate(), + installUpdate: async () => installUpdate(killSidecar), + setBackgroundColor: (color) => setBackgroundColor(color), }) -} -function useSystemCertificates() { - try { - setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) - } catch (error) { - logger.warn("failed to load system certificates", error) - } -} + yield* Effect.promise(() => app.whenReady()) -function useEnvProxy() { - try { - // Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2. - ;(http as any).setGlobalProxyFromEnv() - } catch (error) { - logger.warn("failed to load proxy environment", error) - } -} + if (!TEST_ONBOARDING) migrate() + app.setAsDefaultProtocolClient("opencode") + registerRendererProtocol() + setDockIcon() + setupAutoUpdater() -function emitDeepLinks(urls: string[]) { - if (urls.length === 0) return - pendingDeepLinks.push(...urls) - if (mainWindow) sendDeepLinks(mainWindow, urls) -} + const needsMigration = ((): boolean => { + if (process.env.OPENCODE_DB === ":memory:") return false -function focusMainWindow() { - if (!mainWindow) return - mainWindow.show() - mainWindow.focus() -} + const xdg = process.env.XDG_DATA_HOME + const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") + return !existsSync(join(base, "opencode", "opencode.db")) + })() + let overlay: BrowserWindow | null = null -function setInitStep(step: InitStep) { - initStep = step - logger.log("init step", { step }) - initEmitter.emit("step", step) -} + const port = yield* Effect.gen(function* () { + const fromEnv = process.env.OPENCODE_PORT + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10) + if (!Number.isNaN(parsed)) return parsed + } -async function initialize() { - const needsMigration = !sqliteFileExists() - let overlay: BrowserWindow | null = null + const res = yield* Deferred.make() + const server = createServer() + server.on("error", (e) => Deferred.failSync(res, () => e)) + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (typeof address !== "object" || !address) { + server.close() + Deferred.failSync(res, () => new Error("Failed to get port")) + return + } + const port = address.port + server.close(() => Effect.runSync(Deferred.succeed(res, port))) + }) - const port = await getSidecarPort() + return yield* Deferred.await(res) + }) const hostname = "127.0.0.1" const url = `http://${hostname}:${port}` const password = randomUUID() - const loadingTask = (async () => { + const loadingTask = yield* Effect.gen(function* () { logger.log("sidecar connection started", { url }) initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => { @@ -208,298 +292,80 @@ async function initialize() { }) logger.log("spawning sidecar", { url }) - const { listener, health } = await spawnLocalServer( - hostname, - port, - password, - () => { - ensureLoopbackNoProxy() - useEnvProxy() - }, - { - needsMigration, - userDataPath: app.getPath("userData"), - onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), - onStdout: (message) => logger.log("sidecar stdout", { message }), - onStderr: (message) => logger.warn("sidecar stderr", { message }), - onExit: (code) => logger.warn("sidecar exited", { code }), - }, + const { listener, health } = yield* Effect.promise(() => + spawnLocalServer( + hostname, + port, + password, + () => { + ensureLoopbackNoProxy() + useEnvProxy() + }, + { + needsMigration, + userDataPath: app.getPath("userData"), + onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), + onStdout: (message) => logger.log("sidecar stdout", { message }), + onStderr: (message) => logger.warn("sidecar stderr", { message }), + onExit: (code) => logger.warn("sidecar exited", { code }), + }, + ), ) server = listener - serverReady.resolve({ + yield* Deferred.succeed(serverReady, { url, username: "opencode", password, }) - await Promise.race([ - health.wait, - delay(30_000).then(() => { - throw new Error("Sidecar health check timed out") - }), - ]).catch((error) => { - logger.error("sidecar health check failed", error) - }) + yield* Effect.promise(() => health.wait).pipe( + Effect.timeout("30 seconds"), + Effect.catch((e) => + Effect.sync(() => { + logger.error("sidecar health check failed", e.toString()) + }), + ), + ) logger.log("loading task finished") - })() + }).pipe(Effect.forkChild) if (needsMigration) { - const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) + const show = yield* loadingTask.pipe( + Fiber.await, + Effect.timeout("1 second"), + Effect.as(false), + Effect.catch(() => Effect.succeed(true)), + ) if (show) { overlay = createLoadingWindow() - await delay(1_000) + yield* Effect.sleep("1 second") } } - await loadingTask + yield* Fiber.await(loadingTask) setInitStep({ phase: "done" }) - if (overlay) { - await loadingComplete.promise - } + if (overlay) yield* Deferred.await(loadingComplete) mainWindow = createMainWindow() - wireMenu() - - overlay?.close() -} - -function wireMenu() { - if (!mainWindow) return - createMenu({ - trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), - checkForUpdates: () => { - void checkForUpdates(true) - }, - reload: () => mainWindow?.reload(), - relaunch: () => { - void killSidecar().finally(() => { - app.relaunch() - app.exit(0) - }) - }, - }) -} - -registerIpcHandlers({ - killSidecar: () => killSidecar(), - awaitInitialization: async (sendStep) => { - sendStep(initStep) - const listener = (step: InitStep) => sendStep(step) - initEmitter.on("step", listener) - try { - logger.log("awaiting server ready") - const res = await serverReady.promise - logger.log("server ready", { url: res.url }) - return res - } finally { - initEmitter.off("step", listener) - } - }, - getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), - consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), - getDefaultServerUrl: () => getDefaultServerUrl(), - setDefaultServerUrl: (url) => setDefaultServerUrl(url), - getWslConfig: () => Promise.resolve(getWslConfig()), - setWslConfig: (config: WslConfig) => setWslConfig(config), - getDisplayBackend: async () => null, - setDisplayBackend: async () => undefined, - parseMarkdown: async (markdown) => parseMarkdown(markdown), - checkAppExists: (appName) => checkAppExists(appName), - wslPath: async (path, mode) => wslPath(path, mode), - resolveAppPath: async (appName) => resolveAppPath(appName), - loadingWindowComplete: () => loadingComplete.resolve(), - runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail), - checkUpdate: async () => checkUpdate(), - installUpdate: async () => installUpdate(), - setBackgroundColor: (color) => setBackgroundColor(color), -}) - -async function killSidecar() { - if (!server) return - const current = server - server = null - await current.stop() -} - -function ensureLoopbackNoProxy() { - const loopback = ["127.0.0.1", "localhost", "::1"] - const upsert = (key: string) => { - const items = (process.env[key] ?? "") - .split(",") - .map((value: string) => value.trim()) - .filter((value: string) => Boolean(value)) - - for (const host of loopback) { - if (items.some((value: string) => value.toLowerCase() === host)) continue - items.push(host) - } - - process.env[key] = items.join(",") - } - - upsert("NO_PROXY") - upsert("no_proxy") -} - -async function getSidecarPort() { - const fromEnv = process.env.OPENCODE_PORT - if (fromEnv) { - const parsed = Number.parseInt(fromEnv, 10) - if (!Number.isNaN(parsed)) return parsed - } - - return await new Promise((resolve, reject) => { - const server = createServer() - server.on("error", reject) - server.listen(0, "127.0.0.1", () => { - const address = server.address() - if (typeof address !== "object" || !address) { - server.close() - reject(new Error("Failed to get port")) - return - } - const port = address.port - server.close(() => resolve(port)) - }) - }) -} - -function sqliteFileExists() { - if (process.env.OPENCODE_DB === ":memory:") return true - - const xdg = process.env.XDG_DATA_HOME - const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") - return existsSync(join(base, "opencode", "opencode.db")) -} - -function setupAutoUpdater() { - if (!UPDATER_ENABLED) return - autoUpdater.logger = logger - autoUpdater.channel = "latest" - autoUpdater.allowPrerelease = false - autoUpdater.allowDowngrade = true - autoUpdater.autoDownload = false - autoUpdater.autoInstallOnAppQuit = false - logger.log("auto updater configured", { - channel: autoUpdater.channel, - allowPrerelease: autoUpdater.allowPrerelease, - allowDowngrade: autoUpdater.allowDowngrade, - currentVersion: app.getVersion(), - }) -} - -let downloadedUpdateVersion: string | undefined - -async function checkUpdate() { - if (!UPDATER_ENABLED) return { updateAvailable: false } - if (downloadedUpdateVersion) { - logger.log("returning cached downloaded update", { - version: downloadedUpdateVersion, - }) - return { updateAvailable: true, version: downloadedUpdateVersion } - } - logger.log("checking for updates", { - currentVersion: app.getVersion(), - channel: autoUpdater.channel, - allowPrerelease: autoUpdater.allowPrerelease, - allowDowngrade: autoUpdater.allowDowngrade, - }) - try { - const result = await autoUpdater.checkForUpdates() - const updateInfo = result?.updateInfo - logger.log("update metadata fetched", { - releaseVersion: updateInfo?.version ?? null, - releaseDate: updateInfo?.releaseDate ?? null, - releaseName: updateInfo?.releaseName ?? null, - files: updateInfo?.files?.map((file) => file.url) ?? [], - }) - const version = result?.updateInfo?.version - if (result?.isUpdateAvailable === false || !version) { - logger.log("no update available", { - reason: "provider returned no newer version", - }) - return { updateAvailable: false } - } - logger.log("update available", { version }) - await autoUpdater.downloadUpdate() - logger.log("update download completed", { version }) - downloadedUpdateVersion = version - return { updateAvailable: true, version } - } catch (error) { - logger.error("update check failed", error) - return { updateAvailable: false, failed: true } - } -} - -async function installUpdate() { - if (!downloadedUpdateVersion) { - logger.log("install update skipped", { - reason: "no downloaded update ready", - }) - return - } - logger.log("installing downloaded update", { - version: downloadedUpdateVersion, - }) - await killSidecar() - autoUpdater.quitAndInstall(true, true) -} - -async function checkForUpdates(alertOnFail: boolean) { - if (!UPDATER_ENABLED) return - logger.log("checkForUpdates invoked", { alertOnFail }) - const result = await checkUpdate() - if (!result.updateAvailable) { - if (result.failed) { - logger.log("no update decision", { reason: "update check failed" }) - if (!alertOnFail) return - await dialog.showMessageBox({ - type: "error", - message: "Update check failed.", - title: "Update Error", - }) - return - } - - logger.log("no update decision", { reason: "already up to date" }) - if (!alertOnFail) return - await dialog.showMessageBox({ - type: "info", - message: "You're up to date.", - title: "No Updates", + if (mainWindow) { + createMenu({ + trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), + checkForUpdates: () => { + void checkForUpdates(true, killSidecar) + }, + reload: () => mainWindow?.reload(), + relaunch: () => { + void killSidecar().finally(() => { + app.relaunch() + app.exit(0) + }) + }, }) - return - } - - const response = await dialog.showMessageBox({ - type: "info", - message: `Update ${result.version ?? ""} downloaded. Restart now?`, - title: "Update Ready", - buttons: ["Restart", "Later"], - defaultId: 0, - cancelId: 1, - }) - logger.log("update prompt response", { - version: result.version ?? null, - restartNow: response.response === 0, - }) - if (response.response === 0) { - await installUpdate() } -} -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} + overlay?.close() +}) -function defer() { - let resolve!: (value: T) => void - let reject!: (error: Error) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} +Effect.runFork(main) diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts new file mode 100644 index 000000000000..a220e0089103 --- /dev/null +++ b/packages/desktop/src/main/updater.ts @@ -0,0 +1,126 @@ +import { app, dialog } from "electron" +import pkg from "electron-updater" +import { UPDATER_ENABLED } from "./constants" +import { initLogging } from "./logging" + +const logger = initLogging() +const { autoUpdater } = pkg + +let downloadedUpdateVersion: string | undefined + +export function setupAutoUpdater() { + if (!UPDATER_ENABLED) return + autoUpdater.logger = logger + autoUpdater.channel = "latest" + autoUpdater.allowPrerelease = false + autoUpdater.allowDowngrade = true + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = false + logger.log("auto updater configured", { + channel: autoUpdater.channel, + allowPrerelease: autoUpdater.allowPrerelease, + allowDowngrade: autoUpdater.allowDowngrade, + currentVersion: app.getVersion(), + }) +} + +export async function checkUpdate() { + if (!UPDATER_ENABLED) return { updateAvailable: false } + if (downloadedUpdateVersion) { + logger.log("returning cached downloaded update", { + version: downloadedUpdateVersion, + }) + return { updateAvailable: true, version: downloadedUpdateVersion } + } + logger.log("checking for updates", { + currentVersion: app.getVersion(), + channel: autoUpdater.channel, + allowPrerelease: autoUpdater.allowPrerelease, + allowDowngrade: autoUpdater.allowDowngrade, + }) + try { + const result = await autoUpdater.checkForUpdates() + const updateInfo = result?.updateInfo + logger.log("update metadata fetched", { + releaseVersion: updateInfo?.version ?? null, + releaseDate: updateInfo?.releaseDate ?? null, + releaseName: updateInfo?.releaseName ?? null, + files: updateInfo?.files?.map((file) => file.url) ?? [], + }) + const version = result?.updateInfo?.version + if (result?.isUpdateAvailable === false || !version) { + logger.log("no update available", { + reason: "provider returned no newer version", + }) + return { updateAvailable: false } + } + logger.log("update available", { version }) + await autoUpdater.downloadUpdate() + logger.log("update download completed", { version }) + downloadedUpdateVersion = version + return { updateAvailable: true, version } + } catch (error) { + logger.error("update check failed", error) + return { updateAvailable: false, failed: true } + } +} + +export async function installUpdate(killSidecar: () => Promise) { + if (!downloadedUpdateVersion) { + logger.log("install update skipped", { + reason: "no downloaded update ready", + }) + return + } + logger.log("installing downloaded update", { + version: downloadedUpdateVersion, + }) + await killSidecar() + autoUpdater.quitAndInstall() +} + +export async function checkForUpdates( + alertOnFail: boolean, + killSidecar: () => Promise, +) { + if (!UPDATER_ENABLED) return + logger.log("checkForUpdates invoked", { alertOnFail }) + const result = await checkUpdate() + if (!result.updateAvailable) { + if (result.failed) { + logger.log("no update decision", { reason: "update check failed" }) + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "error", + message: "Update check failed.", + title: "Update Error", + }) + return + } + + logger.log("no update decision", { reason: "already up to date" }) + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "info", + message: "You're up to date.", + title: "No Updates", + }) + return + } + + const response = await dialog.showMessageBox({ + type: "info", + message: `Update ${result.version ?? ""} downloaded. Restart now?`, + title: "Update Ready", + buttons: ["Restart", "Later"], + defaultId: 0, + cancelId: 1, + }) + logger.log("update prompt response", { + version: result.version ?? null, + restartNow: response.response === 0, + }) + if (response.response === 0) { + await installUpdate(killSidecar) + } +} From 014dbd34c4f5612d9a037b3641a8244b213a8a30 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 06:20:21 +0000 Subject: [PATCH 0058/1034] chore: generate --- packages/desktop/src/main/updater.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts index a220e0089103..b7f4bce78507 100644 --- a/packages/desktop/src/main/updater.ts +++ b/packages/desktop/src/main/updater.ts @@ -79,10 +79,7 @@ export async function installUpdate(killSidecar: () => Promise) { autoUpdater.quitAndInstall() } -export async function checkForUpdates( - alertOnFail: boolean, - killSidecar: () => Promise, -) { +export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise) { if (!UPDATER_ENABLED) return logger.log("checkForUpdates invoked", { alertOnFail }) const result = await checkUpdate() From f8c6742e5483a6e198e13674e526cca35691290e Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 02:03:39 -0400 Subject: [PATCH 0059/1034] zen: lift default rate limit --- packages/console/app/src/routes/zen/util/keyRateLimiter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts index 0bf495f7dbbb..37fe9f127e2b 100644 --- a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts @@ -13,7 +13,7 @@ export function createRateLimiter( if (!zenApiKey) return const dict = i18n(localeFromRequest(request)) - const LIMIT = rateLimit ?? 300 + const LIMIT = rateLimit ?? 500 const yyyyMMddHHmm = new Date(Date.now()) .toISOString() .replace(/[^0-9]/g, "") From 6869186fc69983becd55f2a9ec6f9c623037d3fc Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 03:52:56 -0400 Subject: [PATCH 0060/1034] zen: update tpm rate limit algo --- .../src/routes/zen/util/modelTpmLimiter.ts | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts b/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts index 8e3e8cc95ed4..2ccc47589f05 100644 --- a/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts +++ b/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts @@ -1,4 +1,4 @@ -import { and, Database, eq, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js" +import { and, Database, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js" import { ModelTpmRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js" import { UsageInfo } from "./provider/provider" @@ -6,12 +6,16 @@ export function createModelTpmLimiter(providers: { id: string; model: string; tp const ids = providers.filter((p) => p.tpmLimit).map((p) => `${p.id}/${p.model}`) if (ids.length === 0) return - const yyyyMMddHHmm = parseInt( - new Date(Date.now()) - .toISOString() - .replace(/[^0-9]/g, "") - .substring(0, 12), - ) + const toInterval = (date: Date) => + parseInt( + date + .toISOString() + .replace(/[^0-9]/g, "") + .substring(0, 12), + ) + const now = Date.now() + const currInterval = toInterval(new Date(now)) + const prevInterval = toInterval(new Date(now - 60_000)) return { check: async () => { @@ -19,13 +23,18 @@ export function createModelTpmLimiter(providers: { id: string; model: string; tp tx .select() .from(ModelTpmRateLimitTable) - .where(and(inArray(ModelTpmRateLimitTable.id, ids), eq(ModelTpmRateLimitTable.interval, yyyyMMddHHmm))), + .where( + and( + inArray(ModelTpmRateLimitTable.id, ids), + inArray(ModelTpmRateLimitTable.interval, [currInterval, prevInterval]), + ), + ), ) // convert to map of model to count return data.reduce( (acc, curr) => { - acc[curr.id] = curr.count + acc[curr.id] = Math.max(acc[curr.id] ?? 0, curr.count) return acc }, {} as Record, @@ -39,7 +48,7 @@ export function createModelTpmLimiter(providers: { id: string; model: string; tp await Database.use((tx) => tx .insert(ModelTpmRateLimitTable) - .values({ id, interval: yyyyMMddHHmm, count: usage }) + .values({ id, interval: currInterval, count: usage }) .onDuplicateKeyUpdate({ set: { count: sql`${ModelTpmRateLimitTable.count} + ${usage}` } }), ) }, From ae25278edaed91a9d385977df579160be830142b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 8 May 2026 14:10:18 +0530 Subject: [PATCH 0061/1034] test(session): update go retry fixture (#26312) --- packages/opencode/test/session/retry.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 9bb5e4865276..0b6729479654 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -263,6 +263,9 @@ describe("session.retry.retryable", () => { message: "Subscription quota exceeded. You can continue using free models.", isRetryable: true, statusCode: 429, + responseHeaders: { + "retry-after": "19380", + }, responseBody: JSON.stringify({ type: "error", error: { @@ -271,8 +274,7 @@ describe("session.retry.retryable", () => { }, metadata: { workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", - limit: "5 hour", - resetAt: 19_380, + limitName: "5 hour", }, }), }).toObject(), From a43d3e0e1ee9bbc6f5a6ed3a069c4ac3ec6c0d6f Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 8 May 2026 14:19:36 +0530 Subject: [PATCH 0062/1034] feat(websearch): add parallel provider rollout (#26227) --- packages/core/src/flag/flag.ts | 1 + packages/opencode/src/cli/cmd/run.ts | 9 +- .../tui/feature-plugins/system/session-v2.tsx | 12 +- .../src/cli/cmd/tui/routes/session/index.tsx | 7 +- .../cli/cmd/tui/routes/session/permission.tsx | 3 +- .../opencode/src/command/template/review.txt | 2 +- .../src/tool/{mcp-exa.ts => mcp-websearch.ts} | 35 +++++- packages/opencode/src/tool/registry.ts | 9 +- packages/opencode/src/tool/websearch.ts | 103 +++++++++++++++--- packages/opencode/src/tool/websearch.txt | 6 +- packages/opencode/test/tool/websearch.test.ts | 92 ++++++++++++++++ packages/ui/src/components/message-part.tsx | 37 ++++++- .../ui/src/components/tool-error-card.tsx | 4 +- 13 files changed, 276 insertions(+), 44 deletions(-) rename packages/opencode/src/tool/{mcp-exa.ts => mcp-websearch.ts} (63%) create mode 100644 packages/opencode/test/tool/websearch.test.ts diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 0daae55800c1..f55c14bd053c 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -77,6 +77,7 @@ export const Flag = { OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"), OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"), OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"), + OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"), diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index a05b273e4489..5c38c2871fb3 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -20,7 +20,7 @@ import { ReadTool } from "../../tool/read" import { WebFetchTool } from "../../tool/webfetch" import { EditTool } from "../../tool/edit" import { WriteTool } from "../../tool/write" -import { WebSearchTool } from "../../tool/websearch" +import { WebSearchTool, webSearchProviderLabel } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" import { ShellTool } from "../../tool/shell" @@ -148,7 +148,7 @@ function edit(info: ToolProps) { function websearch(info: ToolProps) { inline({ icon: "◈", - title: `Exa Web Search "${info.input.query}"`, + title: `${webSearchProviderLabel(info.metadata.provider)} "${info.input.query}"`, }) } @@ -469,7 +469,10 @@ export const RunCommand = effectCmd({ } inline({ icon: "✗", - title: `${part.tool} failed`, + title: + part.tool === "websearch" + ? `${webSearchProviderLabel(props(part).metadata.provider)} failed` + : `${part.tool} failed`, }) UI.error(part.state.error) } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 0d899a8bae67..8fca0de0c8c9 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -9,6 +9,7 @@ import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/c import { useBindings } from "../../keymap" import { Locale } from "@/util/locale" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import { webSearchProviderLabel } from "@/tool/websearch" import path from "path" import stripAnsi from "strip-ansi" import type { @@ -89,6 +90,7 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { - + )} @@ -400,7 +403,7 @@ function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; sub ) } -function AssistantTool(props: { part: SessionMessageAssistantTool }) { +function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: string }) { const input = createMemo(() => toolInputRecord(props.part.state.input)) const toolprops = { get input() { @@ -412,6 +415,7 @@ function AssistantTool(props: { part: SessionMessageAssistantTool }) { get output() { return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content) }, + sessionID: props.sessionID, part: props.part, } return ( @@ -469,6 +473,7 @@ type ToolProps = { input: Record metadata: Record output?: string + sessionID: string part: SessionMessageAssistantTool } @@ -775,9 +780,10 @@ function CodeSearch(props: ToolProps) { } function WebSearch(props: ToolProps) { + const label = createMemo(() => webSearchProviderLabel(props.metadata.provider)) return ( - Exa Web Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} {(results) => <>({results()} results)} ) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d2b50c32f837..af70f83711fa 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -45,7 +45,7 @@ import type { GrepTool } from "@/tool/grep" import type { EditTool } from "@/tool/edit" import type { ApplyPatchTool } from "@/tool/apply_patch" import type { WebFetchTool } from "@/tool/webfetch" -import type { WebSearchTool } from "@/tool/websearch" +import { webSearchProviderLabel, type WebSearchTool } from "@/tool/websearch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" import type { SkillTool } from "@/tool/skill" @@ -1933,10 +1933,11 @@ function WebFetch(props: ToolProps) { } function WebSearch(props: ToolProps) { - const metadata = props.metadata as { numResults?: number } + const metadata = props.metadata as { numResults?: number; provider?: unknown } return ( - Exa Web Search "{props.input.query}" ({metadata.numResults} results) + {webSearchProviderLabel(metadata.provider)} "{props.input.query}"{" "} + ({metadata.numResults} results) ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 5e7e80b66aea..fd4c96d124b4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -13,6 +13,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Locale } from "@/util/locale" import { Global } from "@opencode-ai/core/global" import { ShellID } from "@/tool/shell/id" +import { webSearchProviderLabel } from "@/tool/websearch" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -338,7 +339,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const query = typeof data.query === "string" ? data.query : "" return { icon: "◈", - title: `Exa Web Search "${query}"`, + title: `${webSearchProviderLabel(data.provider)} "${query}"`, body: ( diff --git a/packages/opencode/src/command/template/review.txt b/packages/opencode/src/command/template/review.txt index b745247e7fca..43c67385771f 100644 --- a/packages/opencode/src/command/template/review.txt +++ b/packages/opencode/src/command/template/review.txt @@ -85,7 +85,7 @@ Use these to inform your review: - **Explore agent** - Find how existing code handles similar problems. Check patterns, conventions, and prior art before claiming something doesn't fit. - **Exa Code Context** - Verify correct usage of libraries/APIs before flagging something as wrong. -- **Exa Web Search** - Research best practices if you're unsure about a pattern. +- **Web Search** - Research best practices if you're unsure about a pattern. If you're uncertain about something and can't verify it with these tools, say "I'm not sure about X" rather than flagging it as a definite issue. diff --git a/packages/opencode/src/tool/mcp-exa.ts b/packages/opencode/src/tool/mcp-websearch.ts similarity index 63% rename from packages/opencode/src/tool/mcp-exa.ts rename to packages/opencode/src/tool/mcp-websearch.ts index af9a3390e3c6..208924cba5aa 100644 --- a/packages/opencode/src/tool/mcp-exa.ts +++ b/packages/opencode/src/tool/mcp-websearch.ts @@ -1,9 +1,10 @@ import { Duration, Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" -const URL = process.env.EXA_API_KEY +export const EXA_URL = process.env.EXA_API_KEY ? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}` : "https://mcp.exa.ai/mcp" +export const PARALLEL_URL = "https://search.parallel.ai/mcp" const McpResult = Schema.Struct({ result: Schema.Struct({ @@ -18,11 +19,23 @@ const McpResult = Schema.Struct({ const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult)) -const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) { +const parsePayload = (payload: string) => + Effect.gen(function* () { + const trimmed = payload.trim() + if (!trimmed.startsWith("{")) return undefined + const data = yield* decode(trimmed) + return data.result.content.find((item) => item.text)?.text + }) + +export const parseResponse = Effect.fn("McpWebSearch.parseResponse")(function* (body: string) { + const trimmed = body.trim() + const direct = trimmed ? yield* parsePayload(trimmed) : undefined + if (direct) return direct + for (const line of body.split("\n")) { if (!line.startsWith("data: ")) continue - const data = yield* decode(line.substring(6)) - if (data.result.content[0]?.text) return data.result.content[0].text + const data = yield* parsePayload(line.substring(6)) + if (data) return data } return undefined }) @@ -35,6 +48,13 @@ export const SearchArgs = Schema.Struct({ contextMaxCharacters: Schema.optional(Schema.Number), }) +export const ParallelSearchArgs = Schema.Struct({ + objective: Schema.String, + search_queries: Schema.Array(Schema.String), + session_id: Schema.optional(Schema.String), + model_name: Schema.optional(Schema.String), +}) + const McpRequest = (args: Schema.Struct) => Schema.Struct({ jsonrpc: Schema.Literal("2.0"), @@ -48,14 +68,17 @@ const McpRequest = (args: Schema.Struct) => export const call = ( http: HttpClient.HttpClient, + url: string, tool: string, args: Schema.Struct, value: Schema.Struct.Type, timeout: Duration.Input, + headers?: Record, ) => Effect.gen(function* () { - const request = yield* HttpClientRequest.post(URL).pipe( + const request = yield* HttpClientRequest.post(url).pipe( HttpClientRequest.accept("application/json, text/event-stream"), + HttpClientRequest.setHeaders(headers ?? {}), HttpClientRequest.schemaBodyJson(McpRequest(args))({ jsonrpc: "2.0" as const, id: 1 as const, @@ -69,5 +92,5 @@ export const call = ( Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }), ) const body = yield* response.text - return yield* parseSse(body) + return yield* parseResponse(body) }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a4eb31acc747..b288bf7ae5a2 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -49,6 +49,13 @@ import { Permission } from "@/permission" const log = Log.create({ service: "tool.registry" }) +export function webSearchEnabled( + providerID: ProviderID, + flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL }, +) { + return providerID === ProviderID.opencode || flags.exa || flags.parallel +} + type TaskDef = Tool.InferDef type ReadDef = Tool.InferDef @@ -284,7 +291,7 @@ export const layer: Layer.Layer< const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { if (tool.id === WebSearchTool.id) { - return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA + return webSearchEnabled(input.providerID) } const usePatch = diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index ff4c696a25e7..0218ecbe3bb2 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,8 +1,11 @@ import { Effect, Schema } from "effect" import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" -import * as McpExa from "./mcp-exa" +import * as McpWebSearch from "./mcp-websearch" import DESCRIPTION from "./websearch.txt" +import { Flag } from "@opencode-ai/core/flag/flag" +import { checksum } from "@opencode-ai/core/util/encode" +import { InstallationVersion } from "@opencode-ai/core/installation/version" export const Parameters = Schema.Struct({ query: Schema.String.annotate({ description: "Websearch query" }), @@ -21,6 +24,81 @@ export const Parameters = Schema.Struct({ }), }) +const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"]) +export type WebSearchProvider = Schema.Schema.Type + +export function selectWebSearchProvider( + sessionID: string, + flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL }, +): WebSearchProvider { + const override = process.env.OPENCODE_WEBSEARCH_PROVIDER + if (override === "exa" || override === "parallel") return override + if (flags.parallel) return "parallel" + if (flags.exa) return "exa" + + return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel" +} + +export function webSearchProviderLabel(provider: unknown) { + if (provider === "parallel") return "Parallel Web Search" + if (provider === "exa") return "Exa Web Search" + return "Web Search" +} + +export function webSearchModelName(extra: Tool.Context["extra"]) { + const model = extra?.model + if (!model || typeof model !== "object") return undefined + const api = "api" in model && model.api && typeof model.api === "object" ? model.api : undefined + const apiID = api && "id" in api && typeof api.id === "string" ? api.id : undefined + const id = "id" in model && typeof model.id === "string" ? model.id : undefined + return (apiID ?? id)?.slice(0, 100) +} + +function parallelAuthHeaders() { + const headers = { "User-Agent": `opencode/${InstallationVersion}` } + if (!process.env.PARALLEL_API_KEY) return headers + return { ...headers, Authorization: `Bearer ${process.env.PARALLEL_API_KEY}` } +} + +function callProvider( + http: HttpClient.HttpClient, + provider: WebSearchProvider, + params: Schema.Schema.Type, + ctx: Tool.Context, +) { + if (provider === "parallel") { + return McpWebSearch.call( + http, + McpWebSearch.PARALLEL_URL, + "web_search", + McpWebSearch.ParallelSearchArgs, + { + objective: params.query, + search_queries: [params.query], + session_id: ctx.sessionID, + model_name: webSearchModelName(ctx.extra), + }, + "25 seconds", + parallelAuthHeaders(), + ) + } + + return McpWebSearch.call( + http, + McpWebSearch.EXA_URL, + "web_search_exa", + McpWebSearch.SearchArgs, + { + query: params.query, + type: params.type || "auto", + numResults: params.numResults || 8, + livecrawl: params.livecrawl || "fallback", + contextMaxCharacters: params.contextMaxCharacters, + }, + "25 seconds", + ) +} + export const WebSearchTool = Tool.define( "websearch", Effect.gen(function* () { @@ -33,6 +111,10 @@ export const WebSearchTool = Tool.define( parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const provider = selectWebSearchProvider(ctx.sessionID) + const title = webSearchProviderLabel(provider) + yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } }) + yield* ctx.ask({ permission: "websearch", patterns: [params.query], @@ -43,27 +125,16 @@ export const WebSearchTool = Tool.define( livecrawl: params.livecrawl, type: params.type, contextMaxCharacters: params.contextMaxCharacters, + provider, }, }) - const result = yield* McpExa.call( - http, - "web_search_exa", - McpExa.SearchArgs, - { - query: params.query, - type: params.type || "auto", - numResults: params.numResults || 8, - livecrawl: params.livecrawl || "fallback", - contextMaxCharacters: params.contextMaxCharacters, - }, - "25 seconds", - ) + const result = yield* callProvider(http, provider, params, ctx) return { output: result ?? "No search results found. Please try a different query.", - title: `Web search: ${params.query}`, - metadata: {}, + title: `${title}: ${params.query}`, + metadata: { provider }, } }).pipe(Effect.orDie), } diff --git a/packages/opencode/src/tool/websearch.txt b/packages/opencode/src/tool/websearch.txt index 551c0f3b593b..ad5238cbd5cd 100644 --- a/packages/opencode/src/tool/websearch.txt +++ b/packages/opencode/src/tool/websearch.txt @@ -1,12 +1,12 @@ -- Search the web using Exa AI - performs real-time web searches and can scrape content from specific URLs +- Search the web using the session's web search provider - performs real-time web searches and can scrape content from specific URLs - Provides up-to-date information for current events and recent data - Supports configurable result counts and returns the content from the most relevant websites - Use this tool for accessing information beyond knowledge cutoff - Searches are performed automatically within a single API call Usage notes: - - Supports live crawling modes: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) - - Search types: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) + - Supports live crawling modes when available: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) + - Search types when available: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) - Configurable context length for optimal LLM integration - Domain filtering and advanced search options available diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts new file mode 100644 index 000000000000..477fe2b428be --- /dev/null +++ b/packages/opencode/test/tool/websearch.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { parseResponse } from "../../src/tool/mcp-websearch" +import { + selectWebSearchProvider, + webSearchModelName, + webSearchProviderLabel, +} from "../../src/tool/websearch" +import { ProviderID } from "../../src/provider/schema" +import { webSearchEnabled } from "../../src/tool/registry" + +const SESSION_ID = "ses_0196aabbccddeeff001122334455" + +describe("websearch provider", () => { + test("selects a stable provider per session", () => { + expect(selectWebSearchProvider(SESSION_ID)).toBe(selectWebSearchProvider(SESSION_ID)) + }) + + test("supports an operational override", () => { + const original = process.env.OPENCODE_WEBSEARCH_PROVIDER + + try { + process.env.OPENCODE_WEBSEARCH_PROVIDER = "parallel" + expect(selectWebSearchProvider(SESSION_ID)).toBe("parallel") + + process.env.OPENCODE_WEBSEARCH_PROVIDER = "exa" + expect(selectWebSearchProvider(SESSION_ID)).toBe("exa") + } finally { + if (original === undefined) delete process.env.OPENCODE_WEBSEARCH_PROVIDER + else process.env.OPENCODE_WEBSEARCH_PROVIDER = original + } + }) + + test("routes to Exa when the Exa flag is enabled", () => { + expect(selectWebSearchProvider(SESSION_ID, { exa: true, parallel: false })).toBe("exa") + }) + + test("routes to Parallel when the Parallel flag is enabled", () => { + expect(selectWebSearchProvider(SESSION_ID, { exa: false, parallel: true })).toBe("parallel") + }) + + test("is only enabled for opencode or explicit websearch provider flags", () => { + expect(webSearchEnabled(ProviderID.opencode, { exa: false, parallel: false })).toBe(true) + expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: false })).toBe(false) + expect(webSearchEnabled(ProviderID.openai, { exa: true, parallel: false })).toBe(true) + expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: true })).toBe(true) + }) + + test("uses branded labels", () => { + expect(webSearchProviderLabel("parallel")).toBe("Parallel Web Search") + expect(webSearchProviderLabel("exa")).toBe("Exa Web Search") + expect(webSearchProviderLabel(undefined)).toBe("Web Search") + }) + + test("uses the provider API model id for Parallel analytics", () => { + expect( + webSearchModelName({ + model: { + id: "claude-opus-4-7", + api: { id: "claude-opus-4.7" }, + }, + }), + ).toBe("claude-opus-4.7") + }) +}) + +describe("websearch MCP response parser", () => { + const payload = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + content: [ + { + type: "text", + text: "search results", + }, + ], + }, + }) + + test("parses plain JSON-RPC responses", async () => { + await expect(Effect.runPromise(parseResponse(payload))).resolves.toBe("search results") + }) + + test("parses SSE JSON-RPC responses", async () => { + await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe("search results") + }) + + test("ignores non-JSON SSE data frames", async () => { + await expect(Effect.runPromise(parseResponse(`data: [DONE]\ndata: ${payload}\n\n`))).resolves.toBe("search results") + }) +}) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index c36a52f81eee..92b6e95acccd 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -317,7 +317,17 @@ function taskAgent( } } -export function getToolInfo(tool: string, input: any = {}): ToolInfo { +function webSearchProviderLabel(provider: unknown) { + if (provider === "parallel") return "Parallel Web Search" + if (provider === "exa") return "Exa Web Search" + return "Web Search" +} + +export function getToolInfo( + tool: string, + input: any = {}, + metadata: Record | undefined = {}, +): ToolInfo { const i18n = useI18n() switch (tool) { case "read": @@ -353,7 +363,7 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "websearch": return { icon: "window-cursor", - title: i18n.t("ui.tool.websearch"), + title: webSearchProviderLabel(metadata?.provider), subtitle: input.query, } case "task": { @@ -692,7 +702,11 @@ function isContextGroupTool(part: PartType): part is ToolPart { } function contextToolDetail(part: ToolPart): string | undefined { - const info = getToolInfo(part.tool, part.state.input ?? {}) + const info = getToolInfo( + part.tool, + part.state.input ?? {}, + "metadata" in part.state ? part.state.metadata : undefined, + ) if (info.subtitle) return info.subtitle if (part.state.status === "error") return part.state.error if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) @@ -744,7 +758,11 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { } } default: { - const info = getToolInfo(part.tool, input) + const info = getToolInfo( + part.tool, + input, + "metadata" in part.state ? part.state.metadata : undefined, + ) return { title: info.title, subtitle: info.subtitle || contextToolDetail(part), @@ -1224,6 +1242,7 @@ export interface ToolProps { input: Record metadata: Record tool: string + sessionID?: string output?: string status?: string hideDetails?: boolean @@ -1346,6 +1365,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { { const value = props.input.query if (typeof value !== "string") return "" return value }) + const title = createMemo(() => webSearchProviderLabel(props.metadata.provider)) return ( , "children" | "variant"> { tool: string error: string + title?: string defaultOpen?: boolean subtitle?: string href?: string @@ -23,8 +24,9 @@ export function ToolErrorCard(props: ToolErrorCardProps) { }) const open = () => state.open const copied = () => state.copied - const [split, rest] = splitProps(props, ["tool", "error", "defaultOpen", "subtitle", "href"]) + const [split, rest] = splitProps(props, ["tool", "error", "title", "defaultOpen", "subtitle", "href"]) const name = createMemo(() => { + if (split.title) return split.title const map: Record = { read: "ui.tool.read", list: "ui.tool.list", From edbc02855d2d359e93cc6241c2114a2e464f29ee Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 08:50:42 +0000 Subject: [PATCH 0063/1034] chore: generate --- packages/opencode/test/tool/websearch.test.ts | 10 ++++------ packages/ui/src/components/message-part.tsx | 12 ++---------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts index 477fe2b428be..591b385fdc53 100644 --- a/packages/opencode/test/tool/websearch.test.ts +++ b/packages/opencode/test/tool/websearch.test.ts @@ -1,11 +1,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { parseResponse } from "../../src/tool/mcp-websearch" -import { - selectWebSearchProvider, - webSearchModelName, - webSearchProviderLabel, -} from "../../src/tool/websearch" +import { selectWebSearchProvider, webSearchModelName, webSearchProviderLabel } from "../../src/tool/websearch" import { ProviderID } from "../../src/provider/schema" import { webSearchEnabled } from "../../src/tool/registry" @@ -83,7 +79,9 @@ describe("websearch MCP response parser", () => { }) test("parses SSE JSON-RPC responses", async () => { - await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe("search results") + await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe( + "search results", + ) }) test("ignores non-JSON SSE data frames", async () => { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 92b6e95acccd..d9771671a69c 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -758,11 +758,7 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { } } default: { - const info = getToolInfo( - part.tool, - input, - "metadata" in part.state ? part.state.metadata : undefined, - ) + const info = getToolInfo(part.tool, input, "metadata" in part.state ? part.state.metadata : undefined) return { title: info.title, subtitle: info.subtitle || contextToolDetail(part), @@ -1365,11 +1361,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { Date: Fri, 8 May 2026 12:06:30 +0200 Subject: [PATCH 0064/1034] chore: reduce alerts threshold --- infra/monitoring.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 313e6c1dd4ae..1b5d097c21ec 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -45,7 +45,7 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { { op: "COUNT", name: "TOTAL", filterCombination: "AND", filters }, { op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters }, ], - formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 500), DIV($FAILED, $TOTAL), 0)" }], + formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }], timeRange: 900, }).json } @@ -86,9 +86,9 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { }, ], formulas: [ - { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 250), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, + { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 50), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, ], - timeRange: 1800, + timeRange: 900, }).json } @@ -100,7 +100,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { queryJson: modelHttpErrorsQuery("go"), alertType: "on_change", frequency: 300, - thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -119,7 +119,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { queryJson: modelHttpErrorsQuery("zen"), alertType: "on_change", frequency: 300, - thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -138,7 +138,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", { queryJson: providerHttpErrorsQuery("go"), alertType: "on_change", frequency: 600, - thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -157,7 +157,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { queryJson: providerHttpErrorsQuery("zen"), alertType: "on_change", frequency: 600, - thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -184,7 +184,7 @@ new honeycomb.Trigger("IncreasedFreeTierRequests", { }).json, alertType: "on_change", frequency: 900, - thresholds: [{ op: ">=", value: 60, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 50, exceededLimit: 1 }], baselineDetails: [{ type: "percentage", offsetMinutes: 1440 }], recipients: [ { From 7f2b5ee8c29bfb16aeace26402b688d2ece8af25 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 8 May 2026 12:17:14 +0200 Subject: [PATCH 0065/1034] feat(opencode): add interactive split-footer mode to run (#23557) --- bun.lock | 30 +- package.json | 6 +- packages/opencode/src/cli/cmd/run.ts | 776 +++++---- packages/opencode/src/cli/cmd/run/demo.ts | 1281 +++++++++++++++ .../opencode/src/cli/cmd/run/entry.body.ts | 194 +++ .../src/cli/cmd/run/footer.command.tsx | 647 ++++++++ .../opencode/src/cli/cmd/run/footer.menu.tsx | 290 ++++ .../src/cli/cmd/run/footer.permission.tsx | 478 ++++++ .../src/cli/cmd/run/footer.prompt.tsx | 1108 +++++++++++++ .../src/cli/cmd/run/footer.question.tsx | 582 +++++++ .../src/cli/cmd/run/footer.subagent.tsx | 192 +++ packages/opencode/src/cli/cmd/run/footer.ts | 893 ++++++++++ .../opencode/src/cli/cmd/run/footer.view.tsx | 719 ++++++++ .../opencode/src/cli/cmd/run/keymap.shared.ts | 154 ++ packages/opencode/src/cli/cmd/run/otel.ts | 119 ++ .../src/cli/cmd/run/permission.shared.ts | 256 +++ .../opencode/src/cli/cmd/run/prompt.shared.ts | 328 ++++ .../src/cli/cmd/run/question.shared.ts | 340 ++++ .../opencode/src/cli/cmd/run/runtime.boot.ts | 214 +++ .../src/cli/cmd/run/runtime.lifecycle.ts | 308 ++++ .../opencode/src/cli/cmd/run/runtime.queue.ts | 293 ++++ .../src/cli/cmd/run/runtime.shared.ts | 17 + .../opencode/src/cli/cmd/run/runtime.stdin.ts | 37 + packages/opencode/src/cli/cmd/run/runtime.ts | 793 +++++++++ .../src/cli/cmd/run/scrollback.shared.ts | 92 ++ .../src/cli/cmd/run/scrollback.surface.ts | 391 +++++ .../src/cli/cmd/run/scrollback.writer.tsx | 330 ++++ .../opencode/src/cli/cmd/run/session-data.ts | 970 +++++++++++ .../src/cli/cmd/run/session.shared.ts | 196 +++ packages/opencode/src/cli/cmd/run/splash.ts | 302 ++++ .../src/cli/cmd/run/stream.transport.ts | 1008 ++++++++++++ packages/opencode/src/cli/cmd/run/stream.ts | 175 ++ .../opencode/src/cli/cmd/run/subagent-data.ts | 746 +++++++++ packages/opencode/src/cli/cmd/run/theme.ts | 599 +++++++ packages/opencode/src/cli/cmd/run/tool.ts | 1460 +++++++++++++++++ packages/opencode/src/cli/cmd/run/trace.ts | 94 ++ packages/opencode/src/cli/cmd/run/types.ts | 317 ++++ .../src/cli/cmd/run/variant.shared.ts | 213 +++ packages/opencode/src/cli/cmd/tui/attach.ts | 2 +- .../src/cli/cmd/tui/component/spinner.tsx | 4 +- .../src/cli/cmd/tui/context/theme.tsx | 6 +- packages/opencode/src/cli/cmd/tui/thread.ts | 2 +- .../opencode/test/cli/run/entry.body.test.ts | 483 ++++++ .../opencode/test/cli/run/footer.menu.test.ts | 43 + .../test/cli/run/footer.view.test.tsx | 273 +++ .../test/cli/run/permission.shared.test.ts | 144 ++ .../test/cli/run/prompt.shared.test.ts | 132 ++ .../test/cli/run/question.shared.test.ts | 115 ++ .../test/cli/run/runtime.boot.test.ts | 303 ++++ .../test/cli/run/runtime.queue.test.ts | 318 ++++ .../test/cli/run/runtime.stdin.test.ts | 71 + .../test/cli/run/scrollback.surface.test.ts | 883 ++++++++++ .../test/cli/run/session-data.test.ts | 422 +++++ .../test/cli/run/session.shared.test.ts | 247 +++ packages/opencode/test/cli/run/stream.test.ts | 55 + .../test/cli/run/stream.transport.test.ts | 1062 ++++++++++++ .../test/cli/run/subagent-data.test.ts | 328 ++++ packages/opencode/test/cli/run/theme.test.ts | 122 ++ .../test/cli/run/variant.shared.test.ts | 214 +++ packages/plugin/package.json | 6 +- 60 files changed, 21843 insertions(+), 340 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/run/demo.ts create mode 100644 packages/opencode/src/cli/cmd/run/entry.body.ts create mode 100644 packages/opencode/src/cli/cmd/run/footer.command.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.menu.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.permission.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.prompt.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.question.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.subagent.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.ts create mode 100644 packages/opencode/src/cli/cmd/run/footer.view.tsx create mode 100644 packages/opencode/src/cli/cmd/run/keymap.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/otel.ts create mode 100644 packages/opencode/src/cli/cmd/run/permission.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/prompt.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/question.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.boot.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.queue.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.stdin.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.surface.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.writer.tsx create mode 100644 packages/opencode/src/cli/cmd/run/session-data.ts create mode 100644 packages/opencode/src/cli/cmd/run/session.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/splash.ts create mode 100644 packages/opencode/src/cli/cmd/run/stream.transport.ts create mode 100644 packages/opencode/src/cli/cmd/run/stream.ts create mode 100644 packages/opencode/src/cli/cmd/run/subagent-data.ts create mode 100644 packages/opencode/src/cli/cmd/run/theme.ts create mode 100644 packages/opencode/src/cli/cmd/run/tool.ts create mode 100644 packages/opencode/src/cli/cmd/run/trace.ts create mode 100644 packages/opencode/src/cli/cmd/run/types.ts create mode 100644 packages/opencode/src/cli/cmd/run/variant.shared.ts create mode 100644 packages/opencode/test/cli/run/entry.body.test.ts create mode 100644 packages/opencode/test/cli/run/footer.menu.test.ts create mode 100644 packages/opencode/test/cli/run/footer.view.test.tsx create mode 100644 packages/opencode/test/cli/run/permission.shared.test.ts create mode 100644 packages/opencode/test/cli/run/prompt.shared.test.ts create mode 100644 packages/opencode/test/cli/run/question.shared.test.ts create mode 100644 packages/opencode/test/cli/run/runtime.boot.test.ts create mode 100644 packages/opencode/test/cli/run/runtime.queue.test.ts create mode 100644 packages/opencode/test/cli/run/runtime.stdin.test.ts create mode 100644 packages/opencode/test/cli/run/scrollback.surface.test.ts create mode 100644 packages/opencode/test/cli/run/session-data.test.ts create mode 100644 packages/opencode/test/cli/run/session.shared.test.ts create mode 100644 packages/opencode/test/cli/run/stream.test.ts create mode 100644 packages/opencode/test/cli/run/stream.transport.test.ts create mode 100644 packages/opencode/test/cli/run/subagent-data.test.ts create mode 100644 packages/opencode/test/cli/run/theme.test.ts create mode 100644 packages/opencode/test/cli/run/variant.shared.test.ts diff --git a/bun.lock b/bun.lock index 2f21ed7d542f..3e73e0c236f5 100644 --- a/bun.lock +++ b/bun.lock @@ -486,9 +486,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.4", - "@opentui/keymap": ">=0.2.4", - "@opentui/solid": ">=0.2.4", + "@opentui/core": ">=0.2.5", + "@opentui/keymap": ">=0.2.5", + "@opentui/solid": ">=0.2.5", }, "optionalPeers": [ "@opentui/core", @@ -667,9 +667,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.4", - "@opentui/keymap": "0.2.4", - "@opentui/solid": "0.2.4", + "@opentui/core": "0.2.5", + "@opentui/keymap": "0.2.5", + "@opentui/solid": "0.2.5", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1594,23 +1594,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.4", "@opentui/core-darwin-x64": "0.2.4", "@opentui/core-linux-arm64": "0.2.4", "@opentui/core-linux-x64": "0.2.4", "@opentui/core-win32-arm64": "0.2.4", "@opentui/core-win32-x64": "0.2.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6xRdxmSgCFsEIwUwv7Pr+XKS1gBOwYF0tS/DE4KxTNzuH39VQDot7blzm8UKl6okdurFAxkt2+1HJJStl+rICw=="], + "@opentui/core": ["@opentui/core@0.2.5", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.5", "@opentui/core-darwin-x64": "0.2.5", "@opentui/core-linux-arm64": "0.2.5", "@opentui/core-linux-x64": "0.2.5", "@opentui/core-win32-arm64": "0.2.5", "@opentui/core-win32-x64": "0.2.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-A5DNOW39S60LtOcBdWYx7fuIGsPcClzbdKz9WuLp+wgy0Bt/jPw5XX6dk3k4dCX4jmhA1nX7x7680+GXLHPL6Q=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2GlEndoBQkA8qSxr9RQEOgprdheCBRZvbUIfui5AUUmREZfgIQP+w399cJwmhlwSoNVNtfzLQGHxUFGfPhzMrQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jdl8TN7oxV8NTaKZsUAt0B/A4hIYiyUKwXNSe4w1OchNMlgjwF1fx/7RhgHXSvWh1Fcqi1IH5FfhsmO89Aed1A=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-5V/Fcwg1rYTeKH9/bj3pMG1837APMIaYPfNWz0Ha87m5wcUKjodQOMf/xTzz2NJuLE/m5rydSuvY9uh5EJx3QA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-78sKg0ZvwFHzZZGJCeaSNIVi2dadDxQymHAmrK698zEgnQr4eLVVB+MxNpxJx55/z9Y+YqbfSZaobC6w6Q3y5A=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-yQoWlEH9sQ/OfpCYcoGxzV4mbPaMCbYIl3thD/vcvIqOSa386vjKZFdTeU5Lu1PHiz3MMU/8Fzej5pJ6ZFJFZA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-BtqbOjP64hKQaVd0ApHunt0MjkEEKTvxpaBwk7OhwVCoYakQBDZTZXUQ9zuPXvaHc9IF286z1PnJGLu0t11BAw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-OxhBUqFHNcIhiKzon3+HYo4T2Me0ooRJQJW8bDVfEc7gtcWGm3ix/+8o7feQAdcbQW63Chwzed2glvbrOrDCzg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-c3sEXtmOd1E5R4wfWh/MejplxgApYKqzyJ0AVMTU8pU1MHRAMwD8UFDMSVQhl7rYMTuBYPWok3IoCK2u8a2A4A=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-UWECe8y9vzdcotRf1ljOvWFOjNiGqAnmeC/SyylhvvoNhh/TqvbZawHVFifw/GZUlBEBdZcXF0f3XGGsW4M5Nw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WlgpYkgmuvMPc2mYGJSwN7c+VGAxiZvMKwZEbS+w9PMj7sJhvY+zFrOJNFpvjbAFw8vS3Kz39km4Nj7GF8JH6w=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-P7yBRAWwiMZXDnVzzXCNksjkCzAvQ5b2X32JzL3gACf+yobs4bvA9F47Ud+XgKZiqILf/c7fa0cud2E0cfWxlA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-4X7BHJ7Wztzj7p0E+SsN0d4goUVU7Dy2VnhnD4n65ODgVbW59iqasAvbnPLbX3ghjgKiwQ+2SD+ImCIHE6uCAA=="], - "@opentui/keymap": ["@opentui/keymap@0.2.4", "", { "dependencies": { "@opentui/core": "0.2.4" }, "peerDependencies": { "@opentui/react": "0.2.4", "@opentui/solid": "0.2.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-TOEkPKlcfhP2Xqo6xtU6zYTsvwHv4syqZ2v89hjNLCuY476j4UMTxeszJCfuSABplwm0OfllV94rVFl0BheWVw=="], + "@opentui/keymap": ["@opentui/keymap@0.2.5", "", { "dependencies": { "@opentui/core": "0.2.5" }, "peerDependencies": { "@opentui/react": "0.2.5", "@opentui/solid": "0.2.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/B6Gy9LLRRKhvyDV1rFX0p7BUN8NQOcXwTV8E0xb7ym1yREvVmij+hCRkXXddMme2HW9NmV0+RRHo4kJzJxkNQ=="], - "@opentui/solid": ["@opentui/solid@0.2.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-EKuUwcTElRW0jKrXNJrTiWVOBvok78wk8viVwsyy3h8sD9qcLyCQA+XGmOINapADNGvgBohW9dKOSTFsqjZlvA=="], + "@opentui/solid": ["@opentui/solid@0.2.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M8MxDYJzjtF8TvxB6Q7656GOSS+QIg89jD0jf/asfF4qeip5TQhNZ3ba+R1v2fVuIkQCyRJzTtOtMZiglzGKPQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/package.json b/package.json index 15d96e131c66..f2258ab698ee 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.4", - "@opentui/keymap": "0.2.4", - "@opentui/solid": "0.2.4", + "@opentui/core": "0.2.5", + "@opentui/keymap": "0.2.5", + "@opentui/solid": "0.2.5", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 5c38c2871fb3..bca89c3cabdb 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,3 +1,16 @@ +// CLI entry point for `opencode run`. +// +// Handles three modes: +// 1. Non-interactive (default): sends a single prompt, streams events to +// stdout, and exits when the session goes idle. +// 2. Interactive local (`--interactive`): boots the split-footer direct mode +// with an in-process server (no external HTTP). +// 3. Interactive attach (`--interactive --attach`): connects to a running +// opencode server and runs interactive mode against it. +// +// Also supports `--command` for slash-command execution, `--format json` for +// raw event streaming, `--continue` / `--session` for session resumption, +// and `--fork` for forking before continuing. import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" @@ -9,38 +22,39 @@ import { ServerAuth } from "@/server/auth" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" -import { Server } from "../../server/server" -import { Provider } from "@/provider/provider" -import { Agent } from "../../agent/agent" -import { Permission } from "../../permission" -import { Tool } from "@/tool/tool" -import { GlobTool } from "../../tool/glob" -import { GrepTool } from "../../tool/grep" -import { ReadTool } from "../../tool/read" -import { WebFetchTool } from "../../tool/webfetch" -import { EditTool } from "../../tool/edit" -import { WriteTool } from "../../tool/write" -import { WebSearchTool, webSearchProviderLabel } from "../../tool/websearch" -import { TaskTool } from "../../tool/task" -import { SkillTool } from "../../tool/skill" -import { ShellTool } from "../../tool/shell" -import { ShellID } from "../../tool/shell/id" -import { TodoWriteTool } from "../../tool/todo" -import { Locale } from "@/util/locale" - -type ToolProps = { - input: Tool.InferParameters - metadata: Tool.InferMetadata - part: ToolPart -} +import { Agent } from "@/agent/agent" +import { Permission } from "@/permission" +import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" + +const runtimeTask = import("./run/runtime") +type ModelInput = Parameters[0]["model"] -function props(part: ToolPart): ToolProps { - const state = part.state +function pick(value: string | undefined): ModelInput | undefined { + if (!value) return undefined + const [providerID, ...rest] = value.split("/") return { - input: state.input as Tool.InferParameters, - metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata, - part, + providerID, + modelID: rest.join("/"), + } as ModelInput +} + +function resolveRunInput(value?: string, piped?: string): string | undefined { + if (!value) { + return piped + } + + if (!piped) { + return value } + + return value + "\n" + piped +} + +type FilePart = { + type: "file" + url: string + filename: string + mime: string } type Inline = { @@ -49,6 +63,12 @@ type Inline = { description?: string } +type SessionInfo = { + id: string + title?: string + directory?: string +} + function inline(info: Inline) { const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : "" UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix) @@ -62,145 +82,40 @@ function block(info: Inline, output?: string) { UI.empty() } -function fallback(part: ToolPart) { - const state = part.state - const input = "input" in state ? state.input : undefined - const title = - ("title" in state && state.title ? state.title : undefined) || - (input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown") - inline({ - icon: "⚙", - title: `${part.tool} ${title}`, - }) -} - -function glob(info: ToolProps) { - const root = info.input.path ?? "" - const title = `Glob "${info.input.pattern}"` - const suffix = root ? `in ${normalizePath(root)}` : "" - const num = info.metadata.count - const description = - num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}` - inline({ - icon: "✱", - title, - ...(description && { description }), - }) -} - -function grep(info: ToolProps) { - const root = info.input.path ?? "" - const title = `Grep "${info.input.pattern}"` - const suffix = root ? `in ${normalizePath(root)}` : "" - const num = info.metadata.matches - const description = - num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}` - inline({ - icon: "✱", - title, - ...(description && { description }), - }) -} - -function read(info: ToolProps) { - const file = normalizePath(info.input.filePath) - const pairs = Object.entries(info.input).filter(([key, value]) => { - if (key === "filePath") return false - return typeof value === "string" || typeof value === "number" || typeof value === "boolean" - }) - const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined - inline({ - icon: "→", - title: `Read ${file}`, - ...(description && { description }), - }) -} - -function write(info: ToolProps) { - block( - { - icon: "←", - title: `Write ${normalizePath(info.input.filePath)}`, - }, - info.part.state.status === "completed" ? info.part.state.output : undefined, - ) -} - -function webfetch(info: ToolProps) { - inline({ - icon: "%", - title: `WebFetch ${info.input.url}`, - }) -} - -function edit(info: ToolProps) { - const title = normalizePath(info.input.filePath) - const diff = info.metadata.diff - block( - { - icon: "←", - title: `Edit ${title}`, - }, - diff, - ) -} - -function websearch(info: ToolProps) { - inline({ - icon: "◈", - title: `${webSearchProviderLabel(info.metadata.provider)} "${info.input.query}"`, - }) -} - -function task(info: ToolProps) { - const input = info.part.state.input - const status = info.part.state.status - const subagent = - typeof input.subagent_type === "string" && input.subagent_type.trim().length > 0 ? input.subagent_type : "unknown" - const agent = Locale.titlecase(subagent) - const desc = - typeof input.description === "string" && input.description.trim().length > 0 ? input.description : undefined - const icon = status === "error" ? "✗" : status === "running" ? "•" : "✓" - const name = desc ?? `${agent} Task` - inline({ - icon, - title: name, - description: desc ? `${agent} Agent` : undefined, - }) -} - -function skill(info: ToolProps) { - inline({ - icon: "→", - title: `Skill "${info.input.name}"`, - }) -} - -function shell(info: ToolProps) { - const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined - block( - { - icon: "$", - title: `${info.input.command}`, - }, - output, - ) -} - -function todo(info: ToolProps) { - block( - { - icon: "#", - title: "Todos", - }, - info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"), - ) +async function tool(part: ToolPart) { + try { + const { toolInlineInfo } = await import("./run/tool") + const next = toolInlineInfo(part) + if (next.mode === "block") { + block(next, next.body) + return + } + + inline(next) + } catch { + inline({ + icon: "\u2699", + title: part.tool, + }) + } } -function normalizePath(input?: string) { - if (!input) return "" - if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "." - return input +async function toolError(part: ToolPart) { + try { + const { toolInlineInfo } = await import("./run/tool") + const next = toolInlineInfo(part) + inline({ + icon: "✗", + title: `${next.title} failed`, + ...(next.description && { description: next.description }), + }) + return + } catch { + inline({ + icon: "✗", + title: `${part.tool} failed`, + }) + } } export const RunCommand = effectCmd({ @@ -296,38 +211,98 @@ export const RunCommand = effectCmd({ .option("thinking", { type: "boolean", describe: "show thinking blocks", + }) + .option("interactive", { + alias: ["i"], + type: "boolean", + describe: "run in direct interactive split-footer mode", default: false, }) .option("dangerously-skip-permissions", { type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, + }) + .option("demo", { + type: "boolean", + default: false, + describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately", }), handler: Effect.fn("Cli.run")(function* (args) { const agentSvc = yield* Agent.Service yield* Effect.promise(async () => { + const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") + const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false) + const die = (message: string): never => { + UI.error(message) + process.exit(1) + } + const dieInteractive = (error: unknown): never => { + if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) { + die(error.message) + } + + throw error + } + let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") + if (args.interactive && args.command) { + die("--interactive cannot be used with --command") + } + + if (args.demo && !args.interactive) { + die("--demo requires --interactive") + } + + if (args.interactive && args.format === "json") { + die("--interactive cannot be used with --format json") + } + + if (args.interactive && !process.stdout.isTTY) { + die("--interactive requires a TTY stdout") + } + + if (args.interactive) { + try { + resolveInteractiveStdin().cleanup?.() + } catch (error) { + dieInteractive(error) + } + } + + const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) const directory = (() => { - if (!args.dir) return undefined + if (!args.dir) return args.attach ? undefined : root if (args.attach) return args.dir + try { - process.chdir(args.dir) + process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir)) return process.cwd() } catch { UI.error("Failed to change directory to " + args.dir) process.exit(1) } })() + const attachHeaders = args.attach + ? ServerAuth.headers({ password: args.password, username: args.username }) + : undefined + const attachSDK = (dir?: string) => { + return createOpencodeClient({ + baseUrl: args.attach!, + directory: dir, + headers: attachHeaders, + }) + } - const files: { type: "file"; url: string; filename: string; mime: string }[] = [] + const files: FilePart[] = [] if (args.file) { const list = Array.isArray(args.file) ? args.file : [args.file] for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) + const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath) if (!(await Filesystem.exists(resolvedPath))) { UI.error(`File not found: ${filePath}`) process.exit(1) @@ -344,9 +319,11 @@ export const RunCommand = effectCmd({ } } - if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text() + message = resolveRunInput(message, piped) ?? "" + const initialInput = resolveRunInput(rawMessage, piped) - if (message.trim().length === 0 && !args.command) { + if (message.trim().length === 0 && !args.command && !args.interactive) { UI.error("You must provide a message or a command") process.exit(1) } @@ -356,23 +333,25 @@ export const RunCommand = effectCmd({ process.exit(1) } - const rules: Permission.Ruleset = [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - ] + const rules: Permission.Ruleset = args.interactive + ? [] + : [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] function title() { if (args.title === undefined) return @@ -380,19 +359,83 @@ export const RunCommand = effectCmd({ return message.slice(0, 50) + (message.length > 50 ? "..." : "") } - async function session(sdk: OpencodeClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + async function session(sdk: OpencodeClient): Promise { + if (args.session) { + const current = await sdk.session + .get({ + sessionID: args.session, + }) + .catch(() => undefined) + + if (!current?.data) { + UI.error("Session not found") + process.exit(1) + } + + if (args.fork) { + const forked = await sdk.session.fork({ + sessionID: args.session, + }) + const id = forked.data?.id + if (!id) { + return + } - if (baseID && args.fork) { - const forked = await sdk.session.fork({ sessionID: baseID }) - return forked.data?.id + return { + id, + title: forked.data?.title ?? current.data.title, + directory: forked.data?.directory ?? current.data.directory, + } + } + + return { + id: current.data.id, + title: current.data.title, + directory: current.data.directory, + } } - if (baseID) return baseID + const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined + + if (base && args.fork) { + const forked = await sdk.session.fork({ + sessionID: base.id, + }) + const id = forked.data?.id + if (!id) { + return + } + + return { + id, + title: forked.data?.title ?? base.title, + directory: forked.data?.directory ?? base.directory, + } + } + + if (base) { + return { + id: base.id, + title: base.title, + directory: base.directory, + } + } const name = title() - const result = await sdk.session.create({ title: name, permission: rules }) - return result.data?.id + const result = await sdk.session.create({ + title: name, + permission: rules, + }) + const id = result.data?.id + if (!id) { + return + } + + return { + id, + title: result.data?.title ?? name, + directory: result.data?.directory, + } } async function share(sdk: OpencodeClient, sessionID: string) { @@ -410,43 +453,159 @@ export const RunCommand = effectCmd({ } } + async function createFreshSession( + sdk: OpencodeClient, + input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined }, + ): Promise { + const result = await sdk.session.create({ + title: args.title !== undefined && args.title !== "" ? args.title : undefined, + agent: input.agent, + model: input.model + ? { + providerID: input.model.providerID, + id: input.model.modelID, + variant: input.variant, + } + : undefined, + permission: rules, + }) + const id = result.data?.id + if (!id) { + throw new Error("Failed to create session") + } + + void share(sdk, id).catch(() => {}) + return { + id, + title: result.data?.title, + } + } + + async function current(sdk: OpencodeClient): Promise { + if (!args.attach) { + return directory ?? root + } + + const next = await sdk.path + .get() + .then((x) => x.data?.directory) + .catch(() => undefined) + if (next) { + return next + } + + UI.error("Failed to resolve remote directory") + process.exit(1) + } + + async function localAgent() { + if (!args.agent) return undefined + const name = args.agent + + const entry = await Effect.runPromise(agentSvc.get(name)) + if (!entry) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + if (entry.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + return name + } + + async function attachAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + const name = args.agent + + const modes = await sdk.app + .agents(undefined, { throwOnError: true }) + .then((x) => x.data ?? []) + .catch(() => undefined) + + if (!modes) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `failed to list agents from ${args.attach}. Falling back to default agent`, + ) + return undefined + } + + const agent = modes.find((a) => a.name === name) + if (!agent) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + + if (agent.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + + return name + } + + async function pickAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + if (args.attach) { + return attachAgent(sdk) + } + + return localAgent() + } + async function execute(sdk: OpencodeClient) { - function tool(part: ToolPart) { - try { - if (part.tool === ShellID.ToolID) return shell(props(part)) - if (part.tool === "glob") return glob(props(part)) - if (part.tool === "grep") return grep(props(part)) - if (part.tool === "read") return read(props(part)) - if (part.tool === "write") return write(props(part)) - if (part.tool === "webfetch") return webfetch(props(part)) - if (part.tool === "edit") return edit(props(part)) - if (part.tool === "websearch") return websearch(props(part)) - if (part.tool === "task") return task(props(part)) - if (part.tool === "todowrite") return todo(props(part)) - if (part.tool === "skill") return skill(props(part)) - return fallback(part) - } catch { - return fallback(part) - } + const sess = await session(sdk) + if (!sess?.id) { + UI.error("Session not found") + process.exit(1) } + const sessionID = sess.id function emit(type: string, data: Record) { if (args.format === "json") { - process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) + process.stdout.write( + JSON.stringify({ + type, + timestamp: Date.now(), + sessionID, + ...data, + }) + EOL, + ) return true } return false } - const events = await sdk.event.subscribe() - let error: string | undefined - - async function loop() { + // Consume one subscribed event stream for the active session and mirror it + // to stdout/UI. `client` is passed explicitly because attach mode may + // rebind the SDK to the session's directory after the subscription is + // created, and replies issued from inside the loop must use that client. + async function loop(client: OpencodeClient, events: Awaited>) { const toggles = new Map() + let error: string | undefined for await (const event of events.stream) { if ( event.type === "message.updated" && + event.properties.sessionID === sessionID && event.properties.info.role === "assistant" && args.format !== "json" && toggles.get("start") !== true @@ -464,16 +623,10 @@ export const RunCommand = effectCmd({ if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { if (emit("tool_use", { part })) continue if (part.state.status === "completed") { - tool(part) + await tool(part) continue } - inline({ - icon: "✗", - title: - part.tool === "websearch" - ? `${webSearchProviderLabel(props(part).metadata.provider)} failed` - : `${part.tool} failed`, - }) + await toolError(part) UI.error(part.state.error) } @@ -484,7 +637,7 @@ export const RunCommand = effectCmd({ args.format !== "json" ) { if (toggles.get(part.id) === true) continue - task(props(part)) + await tool(part) toggles.set(part.id, true) } @@ -509,7 +662,7 @@ export const RunCommand = effectCmd({ UI.empty() } - if (part.type === "reasoning" && part.time?.end && args.thinking) { + if (part.type === "reasoning" && part.time?.end && thinking) { if (emit("reasoning", { part })) continue const text = part.text.trim() if (!text) continue @@ -549,7 +702,7 @@ export const RunCommand = effectCmd({ if (permission.sessionID !== sessionID) continue if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "once", }) @@ -559,7 +712,7 @@ export const RunCommand = effectCmd({ UI.Style.TEXT_NORMAL + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, ) - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "reject", }) @@ -567,114 +720,113 @@ export const RunCommand = effectCmd({ } } } + const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root) + const client = args.attach ? attachSDK(cwd) : sdk // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined - const name = args.agent - - // When attaching, validate against the running server instead of local Instance state. - if (args.attach) { - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) - - if (!modes) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, - ) - return undefined - } + const agent = await pickAgent(client) - const agent = modes.find((a) => a.name === name) - if (!agent) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - - if (agent.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } + await share(client, sessionID) - return name - } + if (!args.interactive) { + const events = await client.event.subscribe() + loop(client, events).catch((e) => { + console.error(e) + process.exit(1) + }) - const entry = await Effect.runPromise(agentSvc.get(name)) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined + if (args.command) { + await client.session.command({ + sessionID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) + return } - if (entry.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - return name - })() - - const sessionID = await session(sdk) - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - await share(sdk, sessionID) - loop().catch((e) => { - console.error(e) - process.exit(1) - }) - - if (args.command) { - await sdk.session.command({ + const model = pick(args.model) + await client.session.prompt({ sessionID, agent, - model: args.model, - command: args.command, - arguments: message, + model, variant: args.variant, + parts: [...files, { type: "text", text: message }], }) - } else { - const model = args.model ? Provider.parseModel(args.model) : undefined - await sdk.session.prompt({ + return + } + + const model = pick(args.model) + const { runInteractiveMode } = await runtimeTask + try { + await runInteractiveMode({ + sdk: client, + directory: cwd, sessionID, + sessionTitle: sess.title, + resume: Boolean(args.session || args.continue) && !args.fork, agent, model, variant: args.variant, - parts: [...files, { type: "text", text: message }], + files, + initialInput, + createSession: createFreshSession, + thinking, + demo: args.demo, }) + } catch (error) { + dieInteractive(error) + } + return + } + + if (args.interactive && !args.attach && !args.session && !args.continue) { + const model = pick(args.model) + const { runInteractiveLocalMode } = await runtimeTask + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const { Server } = await import("@/server/server") + const request = new Request(input, init) + return Server.Default().app.fetch(request) + }) as typeof globalThis.fetch + + try { + return await runInteractiveLocalMode({ + directory: directory ?? root, + fetch: fetchFn, + resolveAgent: localAgent, + session, + share, + createSession: createFreshSession, + agent: args.agent, + model, + variant: args.variant, + files, + initialInput, + thinking, + demo: args.demo, + }) + } catch (error) { + dieInteractive(error) } } if (args.attach) { - const headers = ServerAuth.headers({ password: args.password, username: args.username }) - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) + const sdk = attachSDK(directory) return await execute(sdk) } const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const { Server } = await import("@/server/server") const request = new Request(input, init) return Server.Default().app.fetch(request) }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + const sdk = createOpencodeClient({ + baseUrl: "http://opencode.internal", + fetch: fetchFn, + directory, + }) await execute(sdk) }) }), diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts new file mode 100644 index 000000000000..195ef6f4968c --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -0,0 +1,1281 @@ +// Demo mode for testing direct interactive mode without a real SDK. +// +// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic +// SDK events that feed through the real reducer and footer pipeline. This +// lets you test scrollback formatting, permission UI, question UI, and tool +// snapshots without making actual model calls. Pass a demo slash command as +// the initial interactive message to trigger a preview immediately. +// +// Slash commands: +// /permission [kind] → triggers a permission request variant +// /question [kind] → triggers a question request variant +// /fmt → emits a specific tool/text type (text, reasoning, bash, +// write, edit, patch, task, todo, question, error, mix) +// +// Demo mode also handles permission and question replies locally, completing +// or failing the synthetic tool parts as appropriate. +import path from "path" +import type { Event, ToolPart } from "@opencode-ai/sdk/v2" +import { createSessionData, reduceSessionData, type SessionData } from "./session-data" +import { writeSessionOutput } from "./stream" +import type { + FooterApi, + PermissionReply, + QuestionReject, + QuestionReply, + RunPrompt, + StreamCommit, +} from "./types" + +const KINDS = [ + "markdown", + "table", + "text", + "reasoning", + "bash", + "write", + "edit", + "patch", + "task", + "todo", + "question", + "error", + "mix", +] +const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const +const QUESTIONS = ["multi", "single", "checklist", "custom"] as const + +type PermissionKind = (typeof PERMISSIONS)[number] +type QuestionKind = (typeof QUESTIONS)[number] + +function permissionKind(value: string | undefined): PermissionKind | undefined { + const next = (value || "edit").toLowerCase() + return PERMISSIONS.find((item) => item === next) +} + +function questionKind(value: string | undefined): QuestionKind | undefined { + const next = (value || "multi").toLowerCase() + return QUESTIONS.find((item) => item === next) +} + +const SAMPLE_MARKDOWN = [ + "# Direct Mode Demo", + "", + "This is a realistic assistant response for direct-mode formatting checks.", + "It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.", + "", + "## Summary", + "", + "- Restored the final markdown flush so the last block is committed on idle.", + "- Switched markdown scrollback commits back to top-level block boundaries.", + "- Added footer-level regression coverage for split-footer rendering.", + "", + "## Status", + "", + "| Area | Before | After | Notes |", + "| --- | --- | --- | --- |", + "| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |", + "| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |", + "| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |", + "", + "> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.", + "", + "```ts", + "const result = { markdown: true, tables: 2, stable: true }", + "```", + "", + "## Files", + "", + "| File | Change |", + "| --- | --- |", + "| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |", + "| `footer.ts` | Keep active surfaces across footer-height-only resizes |", + "| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |", + "", + "Next step: run `/fmt table` if you want a tighter table-only sample.", +].join("\n") + +const SAMPLE_TABLE = [ + "# Table Sample", + "", + "| Kind | Example | Notes |", + "| --- | --- | --- |", + "| Pipe | `A\\|B` | Escaped pipes should stay in one cell |", + "| Unicode | `漢字` | Wide characters should remain aligned |", + "| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |", + "| Status | done | Final row should still appear after idle |", +].join("\n") + +type Ref = { + msg: string + part: string + call: string + tool: string + input: Record + start: number +} + +type Ask = { + ref: Ref +} + +type Perm = { + ref: Ref + done: { + title: string + output: string + metadata?: Record + } +} + +type Permit = { + ref: Ref + permission: string + patterns: string[] + metadata?: Record + always: string[] + done: Perm["done"] +} + +type State = { + id: string + thinking: boolean + data: SessionData + footer: FooterApi + limits: () => Record + msg: number + part: number + call: number + perm: number + ask: number + perms: Map + asks: Map +} + +type Input = { + sessionID: string + thinking: boolean + limits: () => Record + footer: FooterApi +} + +function note(footer: FooterApi, text: string): void { + footer.append({ + kind: "system", + text, + phase: "start", + source: "system", + }) +} + +function clearSubagent(footer: FooterApi): void { + footer.event({ + type: "stream.subagent", + state: { + tabs: [], + details: {}, + permissions: [], + questions: [], + }, + }) +} + +function showSubagent( + state: State, + input: { + sessionID: string + partID: string + callID: string + label: string + description: string + status: "running" | "completed" | "error" + title?: string + toolCalls?: number + commits: StreamCommit[] + }, +) { + state.footer.event({ + type: "stream.subagent", + state: { + tabs: [ + { + sessionID: input.sessionID, + partID: input.partID, + callID: input.callID, + label: input.label, + description: input.description, + status: input.status, + title: input.title, + toolCalls: input.toolCalls, + lastUpdatedAt: Date.now(), + }, + ], + details: { + [input.sessionID]: { + sessionID: input.sessionID, + commits: input.commits, + }, + }, + permissions: [], + questions: [], + }, + }) +} + +function wait(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (!signal) { + setTimeout(resolve, ms) + return + } + + if (signal.aborted) { + resolve() + return + } + + const done = () => { + clearTimeout(timer) + signal.removeEventListener("abort", done) + resolve() + } + + const timer = setTimeout(() => { + signal.removeEventListener("abort", done) + resolve() + }, ms) + + signal.addEventListener("abort", done, { once: true }) + }) +} + +function split(text: string): string[] { + if (text.length <= 48) { + return [text] + } + + const size = Math.ceil(text.length / 3) + return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)] +} + +function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefix: string): string { + state[key] += 1 + return `demo_${prefix}_${state[key]}` +} + +function feed(state: State, event: Event): void { + const out = reduceSessionData({ + data: state.data, + event, + sessionID: state.id, + thinking: state.thinking, + limits: state.limits(), + }) + state.data = out.data + writeSessionOutput( + { + footer: state.footer, + }, + out, + ) +} + +function open(state: State): string { + const id = take(state, "msg", "msg") + feed(state, { + type: "message.updated", + properties: { + sessionID: state.id, + info: { + id, + sessionID: state.id, + role: "assistant", + time: { + created: Date.now(), + }, + parentID: `user_${id}`, + modelID: "demo", + providerID: "demo", + mode: "demo", + agent: "demo", + path: { + cwd: process.cwd(), + root: process.cwd(), + }, + cost: 0.001, + tokens: { + input: 120, + output: 320, + reasoning: 80, + cache: { + read: 0, + write: 0, + }, + }, + }, + }, + } as Event) + return id +} + +async function emitText(state: State, body: string, signal?: AbortSignal): Promise { + const msg = open(state) + const part = take(state, "part", "part") + const start = Date.now() + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "text", + text: "", + time: { + start, + }, + }, + }, + } as Event) + + let next = "" + for (const item of split(body)) { + if (signal?.aborted) { + return + } + + next += item + feed(state, { + type: "message.part.delta", + properties: { + sessionID: state.id, + messageID: msg, + partID: part, + field: "text", + delta: item, + }, + } as Event) + await wait(45, signal) + } + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "text", + text: next, + time: { + start, + end: Date.now(), + }, + }, + }, + } as Event) +} + +async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise { + const msg = open(state) + const part = take(state, "part", "part") + const start = Date.now() + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "reasoning", + text: "", + time: { + start, + }, + }, + }, + } as Event) + + let next = "" + for (const item of split(body)) { + if (signal?.aborted) { + return + } + + next += item + feed(state, { + type: "message.part.delta", + properties: { + sessionID: state.id, + messageID: msg, + partID: part, + field: "text", + delta: item, + }, + } as Event) + await wait(45, signal) + } + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "reasoning", + text: next, + time: { + start, + end: Date.now(), + }, + }, + }, + } as Event) +} + +function make(state: State, tool: string, input: Record): Ref { + return { + msg: open(state), + part: take(state, "part", "part"), + call: take(state, "call", "call"), + tool, + input, + start: Date.now(), + } +} + +function startTool(state: State, ref: Ref, metadata: Record = {}): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "running", + input: ref.input, + metadata, + time: { + start: ref.start, + }, + }, + }, + }, + } as Event) +} + +function askPermission(state: State, item: Permit): void { + startTool(state, item.ref) + + const id = take(state, "perm", "perm") + state.perms.set(id, { + ref: item.ref, + done: item.done, + }) + + feed(state, { + type: "permission.asked", + properties: { + id, + sessionID: state.id, + permission: item.permission, + patterns: item.patterns, + metadata: item.metadata ?? {}, + always: item.always, + tool: { + messageID: item.ref.msg, + callID: item.ref.call, + }, + }, + } as Event) +} + +function doneTool( + state: State, + ref: Ref, + output: { + title: string + output: string + metadata?: Record + }, +): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "completed", + input: ref.input, + output: output.output, + title: output.title, + metadata: output.metadata ?? {}, + time: { + start: ref.start, + end: Date.now(), + }, + }, + }, + }, + } as Event) +} + +function failTool(state: State, ref: Ref, error: string): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "error", + input: ref.input, + error, + metadata: {}, + time: { + start: ref.start, + end: Date.now(), + }, + }, + }, + }, + } as Event) +} + +function emitError(state: State, text: string): void { + const event = { + id: `session.error:${state.id}:${Date.now()}`, + type: "session.error", + properties: { + sessionID: state.id, + error: { + name: "UnknownError", + data: { + message: text, + }, + }, + }, + } satisfies Event + feed(state, event) +} + +async function emitBash(state: State, signal?: AbortSignal): Promise { + const ref = make(state, "bash", { + command: "git status", + workdir: process.cwd(), + description: "Show git status", + }) + startTool(state, ref) + await wait(70, signal) + doneTool(state, ref, { + title: "git status", + output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`, + metadata: { + exitCode: 0, + }, + }) +} + +function emitWrite(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "write", { + filePath: file, + content: "export const demo = 42\n", + }) + doneTool(state, ref, { + title: "write", + output: "", + metadata: {}, + }) +} + +function emitEdit(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "edit", { + filePath: file, + }) + doneTool(state, ref, { + title: "edit", + output: "", + metadata: { + diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + }, + }) +} + +function emitPatch(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "apply_patch", { + patchText: "*** Begin Patch\n*** End Patch", + }) + doneTool(state, ref, { + title: "apply_patch", + output: "", + metadata: { + files: [ + { + type: "update", + filePath: file, + relativePath: "src/demo-format.ts", + diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + deletions: 1, + }, + { + type: "add", + filePath: path.join(process.cwd(), "README-demo.md"), + relativePath: "README-demo.md", + diff: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n", + deletions: 0, + }, + ], + }, + }) +} + +function emitTask(state: State): void { + const ref = make(state, "task", { + description: "Scan run/* for reducer touchpoints", + subagent_type: "explore", + }) + doneTool(state, ref, { + title: "Reducer touchpoints found", + output: "", + metadata: { + toolcalls: 4, + sessionId: "sub_demo_1", + }, + }) + const part = { + id: "sub_demo_tool_1", + type: "tool", + sessionID: "sub_demo_1", + messageID: "sub_demo_msg_tool", + callID: "sub_demo_call_1", + tool: "read", + state: { + status: "running", + input: { + filePath: "packages/opencode/src/cli/cmd/run/stream.ts", + offset: 1, + limit: 200, + }, + time: { + start: Date.now(), + }, + }, + } satisfies ToolPart + showSubagent(state, { + sessionID: "sub_demo_1", + partID: ref.part, + callID: ref.call, + label: "Explore", + description: "Scan run/* for reducer touchpoints", + status: "completed", + title: "Reducer touchpoints found", + toolCalls: 4, + commits: [ + { + kind: "user", + text: "Scan run/* for reducer touchpoints", + phase: "start", + source: "system", + }, + { + kind: "reasoning", + text: "Thinking: tracing reducer and footer boundaries", + phase: "progress", + source: "reasoning", + messageID: "sub_demo_msg_reasoning", + partID: "sub_demo_reasoning_1", + }, + { + kind: "tool", + text: "running read", + phase: "start", + source: "tool", + messageID: "sub_demo_msg_tool", + partID: "sub_demo_tool_1", + tool: "read", + part, + }, + { + kind: "assistant", + text: "Footer updates flow through stream.ts into RunFooter", + phase: "progress", + source: "assistant", + messageID: "sub_demo_msg_text", + partID: "sub_demo_text_1", + }, + ], + }) +} + +function emitTodo(state: State): void { + const ref = make(state, "todowrite", { + todos: [ + { + content: "Trigger permission UI", + status: "completed", + }, + { + content: "Trigger question UI", + status: "in_progress", + }, + { + content: "Tune tool formatting", + status: "pending", + }, + ], + }) + doneTool(state, ref, { + title: "todowrite", + output: "", + metadata: {}, + }) +} + +function emitQuestionTool(state: State): void { + const ref = make(state, "question", { + questions: [ + { + header: "Style", + question: "Which output style do you want to inspect?", + options: [ + { label: "Diff", description: "Show diff block" }, + { label: "Code", description: "Show code block" }, + ], + multiple: false, + }, + { + header: "Extras", + question: "Pick extra rows", + options: [ + { label: "Usage", description: "Add usage row" }, + { label: "Duration", description: "Add duration row" }, + ], + multiple: true, + custom: true, + }, + ], + }) + doneTool(state, ref, { + title: "question", + output: "", + metadata: { + answers: [["Diff"], ["Usage", "custom-note"]], + }, + }) +} + +function emitPermission(state: State, kind: PermissionKind = "edit"): void { + const root = process.cwd() + const file = path.join(root, "src", "demo-format.ts") + + if (kind === "bash") { + const command = "git status --short" + const ref = make(state, "bash", { + command, + workdir: root, + description: "Inspect worktree changes", + }) + askPermission(state, { + ref, + permission: "bash", + patterns: [command], + always: ["*"], + done: { + title: "git status --short", + output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`, + metadata: { + exitCode: 0, + }, + }, + }) + return + } + + if (kind === "read") { + const target = path.join(root, "package.json") + const ref = make(state, "read", { + filePath: target, + offset: 1, + limit: 80, + }) + askPermission(state, { + ref, + permission: "read", + patterns: [target], + always: [target], + done: { + title: "read", + output: ["1: {", '2: "name": "opencode",', '3: "private": true', "4: }"].join("\n"), + metadata: {}, + }, + }) + return + } + + if (kind === "task") { + const ref = make(state, "task", { + description: "Inspect footer spacing across direct-mode prompts", + subagent_type: "explore", + }) + askPermission(state, { + ref, + permission: "task", + patterns: ["explore"], + always: ["*"], + done: { + title: "Footer spacing checked", + output: "", + metadata: { + toolcalls: 3, + sessionId: "sub_demo_perm_1", + }, + }, + }) + return + } + + if (kind === "external") { + const dir = path.join(path.dirname(root), "demo-shared") + const target = path.join(dir, "README.md") + const ref = make(state, "read", { + filePath: target, + offset: 1, + limit: 40, + }) + askPermission(state, { + ref, + permission: "external_directory", + patterns: [`${dir}/**`], + metadata: { + parentDir: dir, + filepath: target, + }, + always: [`${dir}/**`], + done: { + title: "read", + output: `1: # External demo\n2: Shared preview file\nPath: ${target}`, + metadata: {}, + }, + }) + return + } + + if (kind === "doom") { + const ref = make(state, "task", { + description: "Retry the formatter after repeated failures", + subagent_type: "general", + }) + askPermission(state, { + ref, + permission: "doom_loop", + patterns: ["*"], + always: ["*"], + done: { + title: "Retry allowed", + output: "Continuing after repeated failures.\n", + metadata: {}, + }, + }) + return + } + + const diff = "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n" + const ref = make(state, "edit", { + filePath: file, + filepath: file, + diff, + }) + askPermission(state, { + ref, + permission: "edit", + patterns: [file], + always: [file], + done: { + title: "edit", + output: "", + metadata: { + diff, + }, + }, + }) +} + +function emitQuestion(state: State, kind: QuestionKind = "multi"): void { + const questions = (() => { + if (kind === "single") { + return [ + { + header: "Mode", + question: "Which footer should be the reference for spacing checks?", + options: [ + { label: "Permission", description: "Inspect the permission footer" }, + { label: "Question", description: "Keep this question footer open" }, + { label: "Prompt", description: "Return to the normal composer" }, + ], + multiple: false, + custom: false, + }, + ] + } + + if (kind === "checklist") { + return [ + { + header: "Checks", + question: "Select the direct-mode cases you want to inspect next", + options: [ + { label: "Diff", description: "Show an edit diff in the footer" }, + { label: "Task", description: "Show a structured task summary" }, + { label: "Todo", description: "Show a todo snapshot" }, + { label: "Error", description: "Show an error transcript row" }, + ], + multiple: true, + custom: false, + }, + ] + } + + if (kind === "custom") { + return [ + { + header: "Reply", + question: "What custom answer should appear in the footer preview?", + options: [ + { label: "Short note", description: "Keep the answer to one line" }, + { label: "Wrapped note", description: "Use a longer answer to test wrapping" }, + ], + multiple: false, + custom: true, + }, + ] + } + + return [ + { + header: "Layout", + question: "Which footer view should stay active while testing?", + options: [ + { label: "Prompt", description: "Return to prompt" }, + { label: "Question", description: "Keep question open" }, + ], + multiple: false, + }, + { + header: "Rows", + question: "Pick formatting previews", + options: [ + { label: "Diff", description: "Emit edit diff" }, + { label: "Task", description: "Emit task card" }, + { label: "Todo", description: "Emit todo card" }, + ], + multiple: true, + custom: true, + }, + ] + })() + + const ref = make(state, "question", { questions }) + startTool(state, ref) + + const id = take(state, "ask", "ask") + state.asks.set(id, { ref }) + + feed(state, { + type: "question.asked", + properties: { + id, + sessionID: state.id, + questions, + tool: { + messageID: ref.msg, + callID: ref.call, + }, + }, + } as Event) +} + +async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise { + if (kind === "text") { + await emitText(state, body || SAMPLE_MARKDOWN, signal) + return true + } + + if (kind === "markdown" || kind === "md") { + await emitText(state, body || SAMPLE_MARKDOWN, signal) + return true + } + + if (kind === "table") { + await emitText(state, body || SAMPLE_TABLE, signal) + return true + } + + if (kind === "reasoning") { + await emitReasoning(state, body || "Planning next steps [REDACTED] while preserving reducer ordering.", signal) + return true + } + + if (kind === "bash") { + await emitBash(state, signal) + return true + } + + if (kind === "write") { + emitWrite(state) + return true + } + + if (kind === "edit") { + emitEdit(state) + return true + } + + if (kind === "patch") { + emitPatch(state) + return true + } + + if (kind === "task") { + emitTask(state) + return true + } + + if (kind === "todo") { + emitTodo(state) + return true + } + + if (kind === "question") { + emitQuestionTool(state) + return true + } + + if (kind === "error") { + emitError(state, body || "demo error event") + return true + } + + if (kind === "mix") { + await emitText(state, SAMPLE_MARKDOWN, signal) + await wait(50, signal) + await emitReasoning(state, "Thinking through formatter edge cases [REDACTED].", signal) + await wait(50, signal) + await emitBash(state, signal) + emitWrite(state) + emitEdit(state) + emitPatch(state) + emitTask(state) + emitTodo(state) + emitQuestionTool(state) + emitError(state, "demo mixed scenario error") + return true + } + + return false +} + +function intro(state: State): void { + note( + state.footer, + [ + "Demo slash commands enabled for interactive mode.", + `- /permission [kind] (${PERMISSIONS.join(", ")})`, + `- /question [kind] (${QUESTIONS.join(", ")})`, + `- /fmt (${KINDS.join(", ")})`, + "Examples:", + "- /permission bash", + "- /question custom", + "- /fmt markdown", + "- /fmt table", + "- /fmt text your custom text", + ].join("\n"), + ) +} + +export function createRunDemo(input: Input) { + const state: State = { + id: input.sessionID, + thinking: input.thinking, + data: createSessionData(), + footer: input.footer, + limits: input.limits, + msg: 0, + part: 0, + call: 0, + perm: 0, + ask: 0, + perms: new Map(), + asks: new Map(), + } + + const start = async (): Promise => { + intro(state) + } + + const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise => { + const text = line.text.trim() + const list = text.split(/\s+/) + const cmd = list[0] || "" + + clearSubagent(state.footer) + + if (cmd === "/help") { + intro(state) + return true + } + + if (cmd === "/permission") { + const kind = permissionKind(list[1]) + if (!kind) { + note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`) + return true + } + + emitPermission(state, kind) + return true + } + + if (cmd === "/question") { + const kind = questionKind(list[1]) + if (!kind) { + note(state.footer, `Pick a question kind: ${QUESTIONS.join(", ")}`) + return true + } + + emitQuestion(state, kind) + return true + } + + if (cmd === "/fmt") { + const kind = (list[1] || "").toLowerCase() + const body = list.slice(2).join(" ") + if (!kind) { + note(state.footer, `Pick a kind: ${KINDS.join(", ")}`) + return true + } + + const ok = await emitFmt(state, kind, body, signal) + if (ok) { + return true + } + + note(state.footer, `Unknown kind "${kind}". Use: ${KINDS.join(", ")}`) + return true + } + + return false + } + + const permission = (input: PermissionReply): boolean => { + const item = state.perms.get(input.requestID) + if (!item || !input.reply) { + return false + } + + state.perms.delete(input.requestID) + const event = { + id: `permission.replied:${input.requestID}:${Date.now()}`, + type: "permission.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + reply: input.reply, + }, + } satisfies Event + feed(state, event) + + if (input.reply === "reject") { + failTool(state, item.ref, input.message || "permission rejected") + return true + } + + doneTool(state, item.ref, item.done) + return true + } + + const questionReply = (input: QuestionReply): boolean => { + const ask = state.asks.get(input.requestID) + if (!ask || !input.answers) { + return false + } + + state.asks.delete(input.requestID) + const event = { + id: `question.replied:${input.requestID}:${Date.now()}`, + type: "question.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + answers: input.answers, + }, + } satisfies Event + feed(state, event) + doneTool(state, ask.ref, { + title: "question", + output: "", + metadata: { + answers: input.answers, + }, + }) + return true + } + + const questionReject = (input: QuestionReject): boolean => { + const ask = state.asks.get(input.requestID) + if (!ask) { + return false + } + + state.asks.delete(input.requestID) + feed(state, { + type: "question.rejected", + properties: { + sessionID: state.id, + requestID: input.requestID, + }, + } as Event) + failTool(state, ask.ref, "question rejected") + return true + } + + return { + start, + prompt, + permission, + questionReply, + questionReject, + } +} diff --git a/packages/opencode/src/cli/cmd/run/entry.body.ts b/packages/opencode/src/cli/cmd/run/entry.body.ts new file mode 100644 index 000000000000..bb058e8a37f6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/entry.body.ts @@ -0,0 +1,194 @@ +import { toolEntryBody } from "./tool" +import type { RunEntryBody, StreamCommit } from "./types" + +export type EntryFlags = { + startOnNewLine: boolean + trailingNewline: boolean +} + +export const RUN_ENTRY_NONE: RunEntryBody = { + type: "none", +} + +export function cleanRunText(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") +} + +function textBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "text", + content, + } +} + +function codeBody(content: string, filetype?: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "code", + content, + filetype, + } +} + +function markdownBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "markdown", + content, + } +} + +function userBody(raw: string): RunEntryBody { + if (!raw.trim()) { + return RUN_ENTRY_NONE + } + + const lead = raw.match(/^\n+/)?.[0] ?? "" + const body = lead ? raw.slice(lead.length) : raw + return textBody(`${lead}› ${body}`) +} + +function reasoningBody(raw: string): RunEntryBody { + const clean = raw.replace(/\[REDACTED\]/g, "") + if (!clean) { + return RUN_ENTRY_NONE + } + + const lead = clean.match(/^\n+/)?.[0] ?? "" + const body = lead ? clean.slice(lead.length) : clean + const mark = "Thinking:" + if (body.startsWith(mark)) { + return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length).trimStart()}`, "markdown") + } + + return codeBody(clean, "markdown") +} + +function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody { + return textBody(phase === "progress" ? raw : raw.trim()) +} + +export function entryFlags(commit: StreamCommit): EntryFlags { + if (commit.kind === "user") { + return { + startOnNewLine: true, + trailingNewline: false, + } + } + + if (commit.kind === "tool") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + if (commit.kind === "assistant" || commit.kind === "reasoning") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + if (commit.kind === "error") { + return { + startOnNewLine: true, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } +} + +export function entryDone(commit: StreamCommit): boolean { + if (commit.kind === "assistant" || commit.kind === "reasoning") { + return commit.phase === "final" + } + + if (commit.kind === "tool") { + return commit.phase === "final" || (commit.phase === "progress" && commit.toolState === "completed") + } + + return true +} + +export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolean { + if (commit.phase !== "progress") { + return false + } + + if (body.type === "none") { + return false + } + + if (commit.kind === "tool") { + return commit.toolState !== "completed" + } + + return commit.kind === "assistant" || commit.kind === "reasoning" +} + +export function entryBody(commit: StreamCommit): RunEntryBody { + const raw = cleanRunText(commit.text) + + if (commit.kind === "user") { + return userBody(raw) + } + + if (commit.kind === "tool") { + return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE + } + + if (commit.kind === "assistant") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE + } + + return markdownBody(raw) + } + + if (commit.kind === "reasoning") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE + } + + return reasoningBody(raw) + } + + return systemBody(raw, commit.phase) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx new file mode 100644 index 000000000000..9e964d02bcd8 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -0,0 +1,647 @@ +/** @jsxImportSource @opentui/solid */ +import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/core" +import { useKeyboard, type JSX } from "@opentui/solid" +import fuzzysort from "fuzzysort" +import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" +import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" +import { formatBindings } from "./keymap.shared" +import type { RunFooterTheme } from "./theme" +import type { FooterKeybinds, RunCommand, RunInput, RunProvider } from "./types" + +type PanelEntry = RunFooterMenuItem & { + category: string + keywords?: string +} + +type CommandEntry = + | (PanelEntry & { action: "model" }) + | (PanelEntry & { action: "variant.cycle" }) + | (PanelEntry & { action: "variant.list" }) + | (PanelEntry & { action: "slash"; name: string }) + | (PanelEntry & { action: "exit" }) + +type ModelEntry = PanelEntry & { + providerID: string + modelID: string + providerName: string + current: boolean +} + +type VariantEntry = PanelEntry & { + variant: string | undefined + current: boolean +} + +type MenuState = ReturnType + +const PANEL_PAD = 2 +const PANEL_LIST_ROWS = 10 +export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + 6 +const PANEL_PAGE = PANEL_LIST_ROWS - 1 +const PANEL_BORDER = { + topLeft: "", + bottomLeft: "", + vertical: "┃", + topRight: "", + bottomRight: "", + horizontal: " ", + bottomT: "", + topT: "", + cross: "", + leftT: "", + rightT: "", +} +const PANEL_BOTTOM_BORDER = { + ...PANEL_BORDER, + vertical: "╹", +} +const HALF_BLOCK_BORDER = { + topLeft: "", + bottomLeft: "", + vertical: "", + topRight: "", + bottomRight: "", + horizontal: "▀", + bottomT: "", + topT: "", + cross: "", + leftT: "", + rightT: "", +} + +function countLabel(count: number, total: number, query: string) { + if (!query.trim()) { + return `${total}` + } + + return `${count}/${total}` +} + +function categoryRank(category: string) { + if (category === "Project Commands") { + return 0 + } + + if (category === "MCP Commands") { + return 1 + } + + return 2 +} + +function handleKey(input: { + event: KeyEvent + menu: MenuState + field: () => InputRenderable | undefined + setQuery: (value: string) => void + select: () => void + close: () => void +}) { + const name = input.event.name.toLowerCase() + const ctrl = input.event.ctrl && !input.event.meta && !input.event.shift && !input.event.super + + if (name === "escape" || (ctrl && name === "c")) { + input.event.preventDefault() + input.close() + return + } + + if (name === "up" || (ctrl && name === "p")) { + input.event.preventDefault() + input.menu.move(-1) + return + } + + if (name === "down" || (ctrl && name === "n")) { + input.event.preventDefault() + input.menu.move(1) + return + } + + if (name === "pageup") { + input.event.preventDefault() + input.menu.reveal(input.menu.selected() - PANEL_PAGE) + return + } + + if (name === "pagedown") { + input.event.preventDefault() + input.menu.reveal(input.menu.selected() + PANEL_PAGE) + return + } + + if (name === "home") { + input.event.preventDefault() + input.menu.reveal(0) + return + } + + if (name === "end") { + input.event.preventDefault() + input.menu.reveal(Number.POSITIVE_INFINITY) + return + } + + if (name === "return") { + input.event.preventDefault() + input.select() + return + } + + if (ctrl && name === "u") { + input.event.preventDefault() + input.setQuery("") + input.field()?.setText("") + } +} + +function match(query: string, entries: T[]) { + const text = query.trim() + if (!text) { + return entries + } + + return fuzzysort + .go(text, entries, { keys: ["display", "category", "description", "keywords"] }) + .map((item) => item.obj) +} + +function PanelShell(props: { + id: string + title: string + countVisible?: boolean + query: string + count: number + total: number + placeholder: string + theme: Accessor + inputRef: (input: InputRenderable) => void + onQuery: (query: string) => void + children: JSX.Element +}) { + return ( + + + + + + {props.title} + + {props.countVisible !== false ? ( + + {countLabel(props.count, props.total, props.query)} + + ) : null} + + + esc + + + + + { + props.inputRef(input) + input.traits = { status: "FILTER" } + queueMicrotask(() => { + if (!input.isDestroyed) { + input.focus() + } + }) + }} + /> + + + + {props.children} + + + + + + + ) +} + +export function RunCommandMenuBody(props: { + theme: Accessor + commands: Accessor + variants: Accessor + keybinds: FooterKeybinds + onClose: () => void + onModel: () => void + onVariant: () => void + onVariantCycle: () => void + onCommand: (name: string) => void + onNew: () => void + onExit: () => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => { + const builtins = ["new"] + return [ + { + action: "model", + category: "Suggested", + display: "Switch model", + }, + { + action: "variant.cycle", + category: "Suggested", + display: "Variant cycle", + footer: formatBindings(props.keybinds.variantCycle, props.keybinds.leader), + keywords: "variant cycle", + }, + ...(props.variants().length > 0 + ? [ + { + action: "variant.list" as const, + category: "Suggested", + display: "Switch model variant", + keywords: `variant variants ${props.variants().join(" ")}`, + }, + ] + : []), + { + action: "slash", + category: "Session", + name: "new", + display: "New session", + footer: "/new", + keywords: "new session clear", + }, + ...(props.commands() ?? []) + .filter((item) => item.source !== "skill" && !builtins.includes(item.name)) + .map( + (item) => + ({ + action: "slash", + category: item.source === "mcp" ? "MCP Commands" : "Project Commands", + name: item.name, + display: item.name, + footer: `/${item.name}`, + keywords: + item.source === "mcp" + ? `/${item.name} ${item.name} mcp ${item.description ?? ""}` + : `/${item.name} ${item.name} ${item.description ?? ""}`, + }) satisfies CommandEntry, + ) + .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)), + { action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" }, + ] + }) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const pick = (item: CommandEntry) => { + if (item.action === "model") { + props.onModel() + return + } + + if (item.action === "variant.cycle") { + props.onVariantCycle() + return + } + + if (item.action === "variant.list") { + props.onVariant() + return + } + + if (item.action === "exit") { + props.onExit() + return + } + + if (item.name === "new") { + props.onNew() + return + } + + props.onCommand(item.name) + } + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + pick(item) + } + + createEffect(() => { + query() + menu.reset() + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty="No results found" + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={!query().trim()} + /> + + ) +} + +export function RunVariantSelectBody(props: { + theme: Accessor + variants: Accessor + current: Accessor + onClose: () => void + onSelect: (variant: string | undefined) => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => [ + { + category: "", + display: "Default", + description: props.current() === undefined ? "current" : undefined, + keywords: "default", + variant: undefined, + current: props.current() === undefined, + }, + ...props.variants().map((variant) => ({ + category: "", + display: variant, + description: props.current() === variant ? "current" : undefined, + keywords: variant, + variant, + current: props.current() === variant, + })), + ]) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const pick = (item: VariantEntry) => { + props.onSelect(item.variant) + } + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + pick(item) + } + + createEffect(() => { + query() + menu.reset() + }) + + createEffect(() => { + if (query().trim()) { + return + } + + const index = items().findIndex((item) => item.current) + if (index !== -1) { + menu.reveal(index) + } + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty="No results found" + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={false} + /> + + ) +} + +export function RunModelSelectBody(props: { + theme: Accessor + providers: Accessor + current: Accessor + onClose: () => void + onSelect: (model: NonNullable) => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => + (props.providers() ?? []) + .flatMap((provider) => + Object.entries(provider.models) + .filter(([, model]) => model.status !== "deprecated") + .map(([modelID, model]) => { + const title = model.name ?? modelID + const current = props.current()?.providerID === provider.id && props.current()?.modelID === modelID + const footer = current + ? "current" + : model.cost?.input === 0 && provider.id === "opencode" + ? "Free" + : title !== modelID + ? modelID + : undefined + return { + providerID: provider.id, + modelID, + providerName: provider.name, + category: provider.name, + display: title, + footer, + keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`, + current, + } + }), + ) + .sort((a, b) => { + const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode") + if (provider !== 0) { + return provider + } + + const name = a.providerName.localeCompare(b.providerName) + if (name !== 0) { + return name + } + + return a.display.localeCompare(b.display) + }), + ) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const pick = (item: ModelEntry) => { + props.onSelect({ providerID: item.providerID, modelID: item.modelID }) + } + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + pick(item) + } + + createEffect(() => { + query() + menu.reset() + }) + + createEffect(() => { + if (query().trim()) { + return + } + + const index = items().findIndex((item) => item.current) + if (index !== -1) { + menu.reveal(index) + } + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty={props.providers() ? "No results found" : "Models loading"} + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={!query().trim()} + /> + + ) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.menu.tsx b/packages/opencode/src/cli/cmd/run/footer.menu.tsx new file mode 100644 index 000000000000..7a3332165b13 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.menu.tsx @@ -0,0 +1,290 @@ +/** @jsxImportSource @opentui/solid */ +import { TextAttributes } from "@opentui/core" +import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" +import { transparent, type RunFooterTheme } from "./theme" + +export const FOOTER_MENU_ROWS = 8 + +export type RunFooterMenuItem = { + display: string + description?: string + category?: string + footer?: string +} + +type RunFooterMenuRow = + | { type: "header"; label: string } + | { type: "item"; item: RunFooterMenuItem; index: number } + | { type: "spacer" } + +function maxOffset(count: number, limit: number) { + return Math.max(0, count - limit) +} + +function previewMargin(limit: number) { + return Math.max(0, Math.min(2, Math.floor((limit - 1) / 2))) +} + +function revealOffset(value: number, input: { count: number; limit: number; selected: number }) { + const max = maxOffset(input.count, input.limit) + if (input.selected < value) { + return Math.min(max, input.selected) + } + + if (input.selected >= value + input.limit) { + return Math.min(max, input.selected - input.limit + 1) + } + + return Math.min(max, value) +} + +function moveOffset(value: number, input: { count: number; limit: number; selected: number; dir: -1 | 1 }) { + const max = maxOffset(input.count, input.limit) + const margin = previewMargin(input.limit) + if (input.dir < 0 && input.selected < value + margin) { + return Math.max(0, Math.min(max, input.selected - margin)) + } + + if (input.dir > 0 && input.selected > value + input.limit - margin - 1) { + return Math.min(max, input.selected - input.limit + margin + 1) + } + + return Math.min(max, value) +} + +export function createFooterMenuState(input: { count: Accessor; limit?: number }) { + const [selected, setSelected] = createSignal(0) + const [offset, setOffset] = createSignal(0) + const limit = () => input.limit ?? FOOTER_MENU_ROWS + const rows = createMemo(() => Math.max(1, Math.min(limit(), input.count()))) + + const reveal = (index: number) => { + const count = input.count() + if (count === 0) { + setSelected(0) + setOffset(0) + return + } + + const next = Math.max(0, Math.min(count - 1, index)) + setSelected(next) + setOffset((value) => revealOffset(value, { count, limit: limit(), selected: next })) + } + + const reset = () => { + setSelected(0) + setOffset(0) + } + + createEffect(() => { + const count = input.count() + if (count === 0) { + reset() + return + } + + if (selected() >= count) { + setSelected(count - 1) + } + + setOffset((value) => revealOffset(value, { count, limit: limit(), selected: selected() })) + }) + + const move = (dir: -1 | 1) => { + const count = input.count() + if (count === 0) { + reset() + return + } + + const next = Math.max(0, Math.min(count - 1, selected() + dir)) + setSelected(next) + setOffset((value) => moveOffset(value, { count, limit: limit(), selected: next, dir })) + } + + return { + selected, + offset, + rows, + reveal, + reset, + move, + } +} + +export function RunFooterMenu(props: { + id?: string + theme: Accessor + items: Accessor + selected: Accessor + offset: Accessor + rows: Accessor + limit?: number + empty?: string + border?: boolean + paddingLeft?: number + paddingRight?: number + grouped?: boolean +}) { + const limit = () => props.limit ?? FOOTER_MENU_ROWS + const border = () => props.border ?? true + const [groupOffset, setGroupOffset] = createSignal(0) + let previous = -1 + const groupedRows = createMemo(() => { + const all: RunFooterMenuRow[] = [] + let category = "" + props.items().forEach((item, index) => { + if (item.category && item.category !== category) { + if (all.length > 0) { + all.push({ type: "spacer" }) + } + + category = item.category + all.push({ type: "header", label: item.category }) + } + + all.push({ type: "item", item, index }) + }) + return all + }) + + createEffect(() => { + if (!props.grouped) { + return + } + + const all = groupedRows() + const selected = all.findIndex((item) => item.type === "item" && item.index === props.selected()) + if (all.length === 0 || selected === -1) { + setGroupOffset(0) + previous = props.selected() + return + } + + const dir = + props.selected() === previous + 1 ? 1 + : props.selected() === previous - 1 ? -1 + : undefined + setGroupOffset((value) => + dir + ? moveOffset(value, { count: all.length, limit: limit(), selected, dir }) + : revealOffset(value, { count: all.length, limit: limit(), selected }), + ) + previous = props.selected() + }) + + const rows = createMemo(() => { + if (!props.grouped) { + return props.items().slice(props.offset(), props.offset() + limit()).map((item, index) => ({ + type: "item", + item, + index: index + props.offset(), + })) + } + + const all = groupedRows() + const start = Math.max(0, Math.min(groupOffset(), all.length - limit())) + return all.slice(start, start + limit()) + }) + const descriptionColumn = createMemo(() => { + const width = Math.max(0, ...props.items().filter((item) => item.description).map((item) => Bun.stringWidth(item.display))) + return width === 0 ? 0 : width + 2 + }) + const descriptionPad = (item: RunFooterMenuItem) => { + if (!item.description) { + return "" + } + + return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display))) + } + return ( + + {rows().length === 0 ? ( + + {border() ? ( + + ┃ + + ) : undefined} + + + {props.empty ?? "No matching items"} + + + + ) : ( + rows().map((row) => { + if (row.type === "spacer") { + return + } + + if (row.type === "header") { + return ( + + + {row.label} + + + ) + } + + const active = () => row.index === props.selected() + const inset = () => (active() ? 1 : 0) + return ( + + {border() ? ( + + ┃ + + ) : undefined} + + + + + {row.item.display} + {row.item.description ? ( + + {descriptionPad(row.item)} + {row.item.description} + + ) : undefined} + + {row.item.footer ? ( + + {row.item.footer} + + ) : undefined} + + + + + ) + }) + )} + + ) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx new file mode 100644 index 000000000000..b38c2da9d1c9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -0,0 +1,478 @@ +// Permission UI body for the direct-mode footer. +// +// Renders inside the footer when the reducer pushes a FooterView of type +// "permission". Uses a three-stage state machine (permission.shared.ts): +// +// permission → shows the request with Allow once / Always / Reject buttons +// always → confirmation step before granting permanent access +// reject → text field for the rejection message +// +// Keyboard: left/right to select, enter to confirm, esc to reject. +// The diff view (when available) uses the same diff component as scrollback +// tool snapshots. +/** @jsxImportSource @opentui/solid */ +import type { TextareaRenderable } from "@opentui/core" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js" +import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import { + createPermissionBodyState, + permissionAlwaysLines, + permissionCancel, + permissionEscape, + permissionHover, + permissionInfo, + permissionLabel, + permissionOptions, + permissionReject, + permissionRun, + permissionShift, + type PermissionOption, +} from "./permission.shared" +import { toolFiletype } from "./tool" +import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme" +import type { PermissionReply, RunDiffStyle } from "./types" + +function buttons( + list: PermissionOption[], + selected: PermissionOption, + theme: RunFooterTheme, + disabled: boolean, + onHover: (option: PermissionOption) => void, + onSelect: (option: PermissionOption) => void, +) { + return ( + + + {(option) => ( + { + if (!disabled) onHover(option) + }} + onMouseUp={() => { + if (!disabled) onSelect(option) + }} + > + {permissionLabel(option)} + + )} + + + ) +} + +function RejectField(props: { + theme: RunFooterTheme + text: string + disabled: boolean + onChange: (text: string) => void + onConfirm: () => void + onCancel: () => void +}) { + let area: TextareaRenderable | undefined + + createEffect(() => { + if (!area || area.isDestroyed) { + return + } + + if (area.plainText !== props.text) { + area.setText(props.text) + area.cursorOffset = props.text.length + } + + queueMicrotask(() => { + if (!area || area.isDestroyed || props.disabled) { + return + } + area.focus() + }) + }) + + return ( +