Compare commits

..
Author SHA1 Message Date
usrnk1 3c8301e155 feat(desktop): adapt composer suggestion menu 2026-09-16 15:47:36 +02:00
62 changed files with 215 additions and 415 deletions
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -113,7 +113,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.5",
"version": "2.0.4",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -253,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -280,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -321,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -409,7 +409,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -498,7 +498,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -514,7 +514,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -533,7 +533,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -546,7 +546,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -560,7 +560,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -575,7 +575,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -614,7 +614,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -644,7 +644,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -659,7 +659,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -683,7 +683,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -704,7 +704,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -726,7 +726,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -761,7 +761,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -781,7 +781,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -815,7 +815,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -834,7 +834,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -880,7 +880,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -894,7 +894,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -929,7 +929,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -964,7 +964,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -997,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1038,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.5",
"version": "2.0.4",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+2 -10
View File
@@ -336,18 +336,12 @@ export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const OpenResponsesErrorObject = Schema.Struct({
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
Schema.decodeTo(OpenResponsesErrorObject, {
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
encode: SchemaGetter.passthrough(),
}),
)
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
@@ -440,9 +434,7 @@ export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) &&
value.type === undefined &&
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
? { ...value, type: "error" }
: value,
),
@@ -1,9 +1,8 @@
import { expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { Effect, Schema } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Azure, Meta, OpenAI, XAI } from "../../src/providers/index.js"
import { WebSocketTransport } from "../../src/route.js"
import { Meta } from "../../src/providers/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -45,59 +44,11 @@ it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const message = "gRPC error: Response with id=resp_missing not found"
for (const error of [{ type: "api_error", message }, message]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
type: "error",
error: typeof error === "string" ? { message } : error,
})
}
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
}),
)
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
type: "error",
error: { message: "Gateway failed" },
})
expect(
yield* decode(
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
),
).toEqual({
type: "response.failed",
response: { id: "resp_failed", error: { message: "Gateway failed" } },
})
}
}),
)
for (const model of [
OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.6-sol"),
XAI.configure({ apiKey: "fixture" }).responses("grok-4.6"),
Azure.configure({ apiKey: "fixture", resourceName: "fixture" }).responses("deployment"),
]) {
it.effect(`preserves string error messages and raw bodies through ${model.provider} Responses`, () =>
Effect.gen(function* () {
const raw = '{ "type": "error", "error": "Gateway rejected the request", "trace": "original" }'
const webSocket = WebSocketTransport.makeDirect({
open: () => Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(raw), close: Effect.void }),
})
for (const options of [{ webSocket }, {}]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), options).pipe(
Effect.provide(fixedResponse(sseEvents(raw))),
Effect.flip,
)
expect(error.reason._tag).toBe("UnknownProvider")
expect(error.message).toBe("Gateway rejected the request")
expect(error.reason.body).toBe(raw)
}
}),
)
}
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
@@ -122,6 +122,35 @@ story("does not mask or pad controls when they fit", async ({ mount }) => {
await expect(controls).toHaveCSS("padding-inline-end", "0px")
})
story("grows suggestions while preserving visible timeline context", async ({ mount }) => {
const component = await mount("opencode-composer-flow--constrained-command-suggestions")
const boundary = component.locator('[data-slot="composer-suggestion-boundary-story"]')
const suggestions = component.locator('[data-component="composer-suggestions"]')
await expect(suggestions).toHaveCSS("max-height", "166px")
await expect(suggestions).toHaveCSS("scroll-padding-bottom", "18px")
await expect.poll(() => suggestions.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
await expect
.poll(async () => {
const menu = await suggestions.boundingBox()
const items = await suggestions.locator("[data-suggestion-id]").evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect()
return { top: rect.top, bottom: rect.bottom }
}),
)
if (!menu) return false
const bottom = menu.y + menu.height
return items.some((item) => item.top < bottom && item.bottom > bottom)
})
.toBe(true)
await boundary.evaluate((element) => {
element.style.height = "400px"
})
await expect(suggestions).toHaveCSS("max-height", "306px")
})
// ThemeProvider writes resolved token values into a <style> block, so toggling data-color-scheme by hand
// leaves every --v2-* variable at its previous value. Switch themes through the Storybook global instead.
for (const [theme, background] of [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.5",
"version": "2.0.4",
"description": "",
"type": "module",
"exports": {
+24 -1
View File
@@ -57,6 +57,8 @@ function ComposerStory(props: {
continueOnStop?: boolean
longLabels?: boolean
alternate?: "queue" | "steer"
manySuggestions?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const [draft, setDraft] = createStore<ComposerPersistedState>({
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
@@ -93,6 +95,15 @@ function ComposerStory(props: {
const commands: ComposerSuggestion[] = [
{ id: "command.test", kind: "command", label: "/test", trigger: "test", title: "Run tests" },
{ id: "command.review", kind: "command", label: "/review", trigger: "review", title: "Review changes" },
...(props.manySuggestions
? Array.from({ length: 12 }, (_, index) => ({
id: `command.example-${index}`,
kind: "command" as const,
label: `/example-${index}`,
trigger: `example-${index}`,
title: `Run example ${index}`,
}))
: []),
]
const context: ComposerSuggestion[] = [
{
@@ -206,7 +217,7 @@ function ComposerStory(props: {
<output class="text-12-regular text-text-weak" aria-live="polite">
{story.activity}
</output>
<Composer model={model} borderUnderlay />
<Composer model={model} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
</div>
)
}
@@ -273,6 +284,18 @@ export const SlashSuggestions = { render: () => <ComposerStory suggestions="comm
export const ContextSuggestions = { render: () => <ComposerStory suggestions="context" /> }
function ConstrainedCommandSuggestionsStory() {
let boundary: HTMLDivElement | undefined
return (
<div class="mx-auto w-full max-w-200">
<div ref={boundary} data-slot="composer-suggestion-boundary-story" class="h-60" />
<ComposerStory suggestions="command" manySuggestions suggestionBoundary={() => boundary} />
</div>
)
}
export const ConstrainedCommandSuggestions = { render: () => <ConstrainedCommandSuggestionsStory /> }
export const RunningAndStopping = { render: () => <ComposerStory working stopping label="Session is running" /> }
export const SteeringFollowUp = {
+7 -1
View File
@@ -12,7 +12,12 @@ import { formatKeybind, useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerModel } from "./model"
export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) {
export function Composer(props: {
class?: string
model: ComposerModel
borderUnderlay?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
@@ -28,6 +33,7 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
exitShellKeybind={[formatKeybind("esc", language.t)]}
suggestionBoundary={props.suggestionBoundary}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
+36 -3
View File
@@ -1,5 +1,6 @@
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
@@ -36,6 +37,12 @@ export type {
} from "../types"
export type ComposerMode = "normal" | "shell"
const COMPOSER_SUGGESTION_MAX_HEIGHT = 320
const COMPOSER_SUGGESTION_ROW_HEIGHT = 28
const COMPOSER_SUGGESTION_ROW_PEEK = 18
const COMPOSER_SUGGESTION_TOP_PADDING = 8
const COMPOSER_SUGGESTION_SEARCH_HEIGHT = 28
const COMPOSER_SUGGESTION_CONTEXT_RESERVE = 80
export type ComposerEditorProps = {
controller: ComposerEditorModel
@@ -49,6 +56,7 @@ export type ComposerEditorProps = {
attachShortcut?: string
alternateKeybind?: string[]
exitShellKeybind?: string[]
suggestionBoundary?: () => HTMLElement | undefined
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -114,6 +122,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
<ComposerEditorPopover
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
items={props.controller.suggestions()}
boundary={props.suggestionBoundary}
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
search={
state.popover.type === "command-menu"
@@ -717,18 +726,29 @@ export function ComposerEditorPopover(props: {
onValueChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
}
boundary?: () => HTMLElement | undefined
onActiveChange: (item: ComposerSuggestion) => void
onSelect: (item: ComposerSuggestion) => void
}) {
const [store, setStore] = createStore({ maxHeight: COMPOSER_SUGGESTION_MAX_HEIGHT })
const resize = (height: number) =>
setStore("maxHeight", composerSuggestionMaxHeight(height, props.search !== undefined))
createEffect(() => resize(props.boundary?.()?.clientHeight ?? COMPOSER_SUGGESTION_MAX_HEIGHT * 2))
createResizeObserver(
props.boundary ?? (() => undefined),
(rect) => resize(rect.height),
)
return (
<div
data-component="composer-suggestions"
class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
class="absolute inset-x-0 -top-2 z-40 flex -translate-y-full scroll-pb-[18px] flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
style={{ "max-height": `${store.maxHeight}px` }}
onMouseDown={(event) => event.preventDefault()}
>
<Show when={props.search}>
{(search) => (
<div class="px-2 py-1">
<div class="shrink-0 px-2 py-1">
<input
ref={(element) => requestAnimationFrame(() => element.focus())}
value={search().value}
@@ -752,7 +772,7 @@ export function ComposerEditorPopover(props: {
type="button"
data-suggestion-id={item.id}
data-active={props.activeID === item.id ? "" : undefined}
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
onClick={() => props.onSelect(item)}
@@ -777,6 +797,19 @@ export function ComposerEditorPopover(props: {
)
}
function composerSuggestionMaxHeight(boundaryHeight: number, search: boolean) {
const reserve = Math.min(COMPOSER_SUGGESTION_CONTEXT_RESERVE, boundaryHeight / 4)
const limit = Math.min(COMPOSER_SUGGESTION_MAX_HEIGHT, boundaryHeight - reserve)
const chrome = COMPOSER_SUGGESTION_TOP_PADDING + (search ? COMPOSER_SUGGESTION_SEARCH_HEIGHT : 0)
if (limit < chrome + COMPOSER_SUGGESTION_ROW_HEIGHT + COMPOSER_SUGGESTION_ROW_PEEK) return limit
return (
chrome +
Math.floor((limit - chrome - COMPOSER_SUGGESTION_ROW_PEEK) / COMPOSER_SUGGESTION_ROW_HEIGHT) *
COMPOSER_SUGGESTION_ROW_HEIGHT +
COMPOSER_SUGGESTION_ROW_PEEK
)
}
// "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the
// delivery opposite to what plain Enter does. Visible only while the queue
// exposes an alternate (turn running and composer holding a value), so it
+5 -2
View File
@@ -213,7 +213,10 @@ export function createActiveSessionRegion(input: {
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
export function ActiveSessionComposerRegion(props: { model: SessionComposerController }) {
export function ActiveSessionComposerRegion(props: {
model: SessionComposerController
suggestionBoundary: () => HTMLElement | undefined
}) {
return (
<SessionComposerRegion
controller={props.model.region}
@@ -221,7 +224,7 @@ export function ActiveSessionComposerRegion(props: { model: SessionComposerContr
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer model={props.model.composer} borderUnderlay />
<Composer model={props.model.composer} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
</div>
</div>
}
+1 -1
View File
@@ -325,7 +325,7 @@ export function SessionScreen(props: { session: SessionModel }) {
</div>
<Show when={composer.active()} keyed>
{(model) => <ActiveSessionComposerRegion model={model} />}
{(model) => <ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} />}
</Show>
</>
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"bin": {
+2 -5
View File
@@ -13,7 +13,6 @@ import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { ShellEnvironment } from "./shell-environment"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
import { databasePath } from "./database-path"
@@ -29,9 +28,6 @@ export type Options = {
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
export const run = Effect.fnUntraced(function* (options: Options) {
// A managed service may have been started by a GUI client with launchd's environment. Adopt the
// login shell's before anything reads process.env, including the OPENCODE_* settings below.
if (options.mode === "service") yield* ShellEnvironment.adopt()
return yield* processEffect(options).pipe(
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
@@ -42,8 +38,9 @@ export const run = Effect.fnUntraced(function* (options: Options) {
],
}),
),
Effect.provide(NodeServices.layer),
)
}, Effect.provide(NodeServices.layer))
})
const processEffect = Effect.fnUntraced(function* (options: Options) {
const inherited = process.env.OPENCODE_PTY_HANDOFF
-80
View File
@@ -1,80 +0,0 @@
export * as ShellEnvironment from "./shell-environment"
import { randomUUID } from "node:crypto"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
// Set on the probe shell so an rc file that itself starts opencode cannot recurse into another probe.
export const RESOLVING = "OPENCODE_RESOLVING_SHELL_ENVIRONMENT"
// Shell bookkeeping that describes the probe process, not the user's environment.
const TRANSIENT = new Set([RESOLVING, "SHLVL", "PWD", "OLDPWD", "_"])
export type Variables = Readonly<Record<string, string | undefined>>
/**
* Resolve the environment of the user's interactive login shell when the current environment did
* not come from one.
*
* A background service elected by a GUI client (the desktop app, an editor, a login item) inherits
* launchd's environment: PATH is `/usr/bin:/bin:/usr/sbin:/sbin` and nothing exported from
* `.zshrc` or `.bashrc` exists. Every process the server spawns extends `process.env`, so stdio MCP
* servers using `#!/usr/bin/env node`, `npx`, `uvx`, formatters, and hooks fail with no useful error
* while the bash tool, which prefers the client's terminal environment, keeps working. Editors such
* as VS Code and Zed resolve the login shell at startup for the same reason.
*
* Returns `undefined` when there is nothing to adopt: Windows, an environment that already passed
* through a shell (`SHLVL`), a probe already in progress, or a shell that fails to report.
*/
export const resolve = Effect.fnUntraced(function* (env: Variables = process.env) {
if (process.platform === "win32") return undefined
if (env[RESOLVING] !== undefined || env.SHLVL !== undefined) return undefined
const spawner = yield* ChildProcessSpawner
const marker = randomUUID()
// Interactive and login: most PATH edits live in `.zshrc`/`.bashrc`, which only interactive shells read.
// `env -0` keeps values containing newlines intact.
const output = yield* spawner
.string(
ChildProcess.make(env.SHELL || "/bin/sh", ["-ilc", `printf '%s' '${marker}'; env -0; printf '%s' '${marker}'`], {
env: { ...env, [RESOLVING]: "1" },
stdin: "ignore",
// Interactive shells without a terminal warn on stderr; nothing reads it, so never let it fill.
stderr: "ignore",
}),
)
.pipe(
Effect.timeout("10 seconds"),
Effect.tapError((error) => Effect.logWarning("shell environment unavailable", { error })),
Effect.orElseSucceed(() => undefined),
)
if (output === undefined) return undefined
const start = output.indexOf(marker)
const end = output.lastIndexOf(marker)
if (start === -1 || end === start) {
yield* Effect.logWarning("shell environment unavailable", { shell: env.SHELL, reason: "missing markers" })
return undefined
}
return Object.fromEntries(
output
.slice(start + marker.length, end)
.split("\0")
.flatMap((entry) => {
const separator = entry.indexOf("=")
if (separator <= 0) return []
const key = entry.slice(0, separator)
return TRANSIENT.has(key) ? [] : [[key, entry.slice(separator + 1)] as const]
}),
)
})
/** Apply the resolved login-shell environment to this process before anything reads `process.env`. */
export const adopt = Effect.fnUntraced(function* () {
const variables = yield* resolve()
if (variables === undefined) return
Object.assign(process.env, variables)
yield* Effect.logInfo("shell environment adopted", {
shell: process.env.SHELL,
variables: Object.keys(variables).length,
})
})
@@ -1,68 +0,0 @@
import { NodeServices } from "@effect/platform-node"
import { afterAll, beforeAll, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { ShellEnvironment } from "../src/shell-environment"
// A stand-in login shell: it records every invocation, applies "rc file" exports, then runs the
// probe script exactly as `$SHELL -ilc <script>` would.
let root: string
let shell: string
let invocations: string
beforeAll(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-shell-env-"))
shell = path.join(root, "login-shell")
invocations = path.join(root, "invocations")
await fs.writeFile(
shell,
[
"#!/bin/sh",
`printf '%s\\n' "$1" >> '${invocations}'`,
`[ "$1" = -ilc ] || exit 64`,
'export PATH="/rc/bin:$PATH"',
"export FROM_RC=yes",
"export MULTILINE='first",
"second'",
'export SAW_GUARD="${OPENCODE_RESOLVING_SHELL_ENVIRONMENT:-unset}"',
"export SHLVL=1 PWD=/rc OLDPWD=/rc",
'eval "$2"',
].join("\n"),
{ mode: 0o755 },
)
})
afterAll(() => fs.rm(root, { recursive: true, force: true }))
const skip = process.platform === "win32"
const launchd = { SHELL: "", HOME: "", PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }
const resolve = (env: Record<string, string | undefined>) =>
Effect.runPromise(
ShellEnvironment.resolve({ ...launchd, ...env, SHELL: env.SHELL ?? shell, HOME: root }).pipe(
Effect.provide(NodeServices.layer),
),
)
test.skipIf(skip)("adopts the login shell's exports over a launchd environment", async () => {
const variables = await resolve({})
expect(variables?.PATH).toBe("/rc/bin:/usr/bin:/bin:/usr/sbin:/sbin")
expect(variables?.FROM_RC).toBe("yes")
expect(variables?.MULTILINE).toBe("first\nsecond")
expect(variables?.SAW_GUARD).toBe("1")
for (const key of [ShellEnvironment.RESOLVING, "SHLVL", "PWD", "OLDPWD", "_"])
expect(variables).not.toHaveProperty(key)
})
test.skipIf(skip)("does not probe when the environment already came from a shell or a probe", async () => {
const before = await fs.readFile(invocations, "utf8").catch(() => "")
expect(await resolve({ SHLVL: "1" })).toBeUndefined()
expect(await resolve({ [ShellEnvironment.RESOLVING]: "1" })).toBeUndefined()
expect(await fs.readFile(invocations, "utf8").catch(() => "")).toBe(before)
})
test.skipIf(skip)("falls back to the inherited environment when the shell cannot report", async () => {
expect(await resolve({ SHELL: "/bin/false" })).toBeUndefined()
expect(await resolve({ SHELL: path.join(root, "missing-shell") })).toBeUndefined()
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.5",
"version": "2.0.4",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.5",
"version": "2.0.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.5",
"version": "2.0.4",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+1 -2
View File
@@ -167,8 +167,7 @@ export const AzurePlugin = define({
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft))
draft.transport = item.provider.transport ?? "websocket"
if (responsesWebSocketCapable(item.provider, draft)) draft.transport = "websocket"
})
}
}
+1 -1
View File
@@ -270,7 +270,7 @@ export const OpenAIPlugin = define({
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
models.update(model.providerID, model.id, (draft) => {
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
draft.transport = "websocket"
if (!chatgpt) return
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
+1 -1
View File
@@ -98,7 +98,7 @@ export const XAIPlugin = define({
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(providerID, model.id, (draft) => {
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
draft.transport = "websocket"
})
}
})
@@ -100,6 +100,7 @@ export const layer = (options?: Options) =>
const recoverShell = Effect.fnUntraced(function* (
background: Job.Background,
recovery: Extract<Job.Recovery, { kind: "shell" }>,
suspended: ReadonlySet<SessionSchema.ID>,
) {
const state = background.status === "running" ? "cancelled" : background.status
const text =
@@ -123,9 +124,7 @@ export const layer = (options?: Options) =>
state,
text,
}),
// Restart notices must not revive idle owners of long-lived shells.
// Interrupted executions resume separately after their notices are admitted.
resume: false,
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
@@ -209,7 +208,7 @@ export const layer = (options?: Options) =>
if ((yield* jobs.get(background.id))?.status === "running") return
const recovery = background.recovery
yield* recovery.kind === "shell"
? recoverShell(background, recovery)
? recoverShell(background, recovery, suspended)
: recoverSubagent(background, recovery, suspended)
}),
{ discard: true },
@@ -10,9 +10,6 @@ import { Model } from "@opencode/core/model"
import { ModelResolver } from "@opencode/core/model-resolver"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { AzurePlugin } from "@opencode/core/plugin/provider/azure"
import { OpenAIPlugin } from "@opencode/core/plugin/provider/openai"
import { XAIPlugin } from "@opencode/core/plugin/provider/xai"
import { Provider } from "@opencode/core/provider"
import { withEnv } from "../fixture/env"
import { testEffect } from "../lib/effect"
@@ -109,50 +106,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
for (const builtin of [
{ id: "openai", model: "gpt-5.6-sol", package: "@opencode/ai/providers/openai/responses", plugin: OpenAIPlugin },
{ id: "xai", model: "grok-4.6", package: "@opencode/ai/providers/xai", plugin: XAIPlugin },
{ id: "azure", model: "gpt-5.6-sol", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
{ id: "custom-azure", model: "deployment", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
]) {
it.live(`provider transport overrides ${builtin.id} defaults while model overrides still win`, () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const providerID = Provider.ID.make(builtin.id)
const modelID = Model.ID.make(builtin.model)
yield* providers.transform((editor) => {
editor.update(providerID, (provider) => {
provider.activation = "enabled"
provider.package = builtin.package
})
editor.models.update(providerID, modelID, () => {})
})
yield* builtin.plugin.effect(host)
expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket")
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
[builtin.id]: {
transport: "http",
models: { override: { modelID: builtin.model, transport: "websocket" } },
},
},
}),
}),
])
expect((yield* models.get(providerID, modelID))?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket")
}),
)
}
it.effect("adds key auth for custom providers without env credentials", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+3 -16
View File
@@ -379,7 +379,7 @@ describe("SessionExecution lifecycle", () => {
})
describe("SessionRestart background recovery", () => {
it.effect("keeps shell owners idle until a user prompt delivers recovered notices exactly once", () =>
it.effect("wakes idle shell owners and delivers recovered notices exactly once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const store = yield* SessionStore.Service
@@ -416,20 +416,6 @@ describe("SessionRestart background recovery", () => {
yield* restart.resumeSuspendedSessions
yield* Effect.forEach([parent, child], execution.awaitIdle, { discard: true })
expect(drained).toEqual([])
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
expect(yield* SessionInbox.list(database.db, child)).toHaveLength(1)
expect(yield* restarted.pendingBackground).toEqual([])
yield* restart.resumeSuspendedSessions
expect(drained).toEqual([])
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
expect(yield* SessionInbox.list(database.db, child)).toHaveLength(1)
yield* seedInbox(database, parent, ["steer"])
yield* seedInbox(database, child, ["steer"])
yield* execution.wake(parent)
yield* execution.wake(child)
yield* Effect.forEach([parent, child], execution.awaitIdle, { discard: true })
expect(drained.toSorted()).toEqual([parent, child].toSorted())
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toMatchObject([
{
@@ -523,7 +509,7 @@ describe("SessionRestart background recovery", () => {
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
yield* Context.get(context, SessionExecution.Service).awaitIdle(sessionID)
expect(drained).toEqual([])
expect(drained).toEqual([sessionID])
const inbox = yield* SessionInbox.list(database.db, sessionID)
expect(inbox).toMatchObject([
{
@@ -575,6 +561,7 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
{
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.5",
"version": "2.0.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.5",
"version": "2.0.4",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.5",
"version": "2.0.4",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.5",
"version": "2.0.4",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { buildStatsQuery, toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
import { toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
import { modelAuthor, normalizeInferenceModel, statModel, statProvider } from "./model-normalization"
describe("inference stat normalization", () => {
@@ -38,22 +38,6 @@ describe("inference stat normalization", () => {
expect(statProvider("unknown", "", "custom-provider")).toBe("custom-provider")
})
test("merges union alpha routes without exposing the provider", () => {
;["opencode-go/union-alpha", "opencode/union-alpha"].forEach((model) => {
expect(statModel(model, "")).toBe("union-alpha")
expect(statProvider(model, "gpt-test-model", "test-provider")).toBe("unknown")
expect(
toModelAggregate({ ...aggregate(model, "test-provider"), provider_model: "gpt-test-model" }),
).toMatchObject([{ model: "union-alpha", provider: "unknown", requests: 1 }])
expect(toProviderAggregate(aggregate(model, "test-provider"))).toMatchObject([
{ provider: "unknown", requests: 1 },
])
expect(toGeoAggregate({ ...aggregate(model, "test-provider"), country: "US" })).toMatchObject([
{ model: "union-alpha", provider: "unknown", country: "US", requests: 1 },
])
})
})
test("merges renamed models under their current name", () => {
expect(statModel("deepseek-flash", "")).toBe("deepseek-v4.1-flash")
expect(statModel("x-preview-f", "")).toBe("ox-alpha")
@@ -110,20 +94,6 @@ describe("inference stat normalization", () => {
}),
).toMatchObject([{ period_key: "2026-W20" }])
})
test("normalizes union alpha routes in stat queries", () => {
process.env.SST_RESOURCE_InferenceEvent = JSON.stringify({
catalog: "inference",
database: "events",
table: "generation",
})
process.env.SST_RESOURCE_StatsSyncConfig = JSON.stringify({ dataset: "zen" })
const query = buildStatsQuery(new Date("2026-05-20T00:00:00.000Z"), new Date("2026-05-21T00:00:00.000Z"))
expect(query).toContain("WHEN lower(model) = 'opencode-go/union-alpha' THEN 'union-alpha'")
expect(query).toContain("WHEN lower(model) = 'opencode/union-alpha' THEN 'union-alpha'")
expect(query).toContain("WHEN lower(model) IN ('omen-alpha', 'union-alpha') THEN 'unknown'")
})
})
function aggregate(model: string, provider: string) {
@@ -7,7 +7,6 @@ import {
MODEL_AUTHOR_RULES,
MODEL_NAME_ALIASES,
RETIRED_STAT_PROVIDERS,
STEALTH_MODELS,
statModel,
statProvider,
} from "./model-normalization"
@@ -264,7 +263,6 @@ ${Object.entries(MODEL_NAME_ALIASES)
function statProviderSql(model: string, providerModel: string, provider: string) {
return `CASE
WHEN lower(${model}) IN (${[...STEALTH_MODELS].map(sqlString).join(", ")}) THEN 'unknown'
${MODEL_AUTHOR_RULES.map((item) => ` WHEN strpos(lower(${providerModel}), ${sqlString(item.match)}) > 0 THEN ${sqlString(item.author)}`).join("\n")}
${MODEL_AUTHOR_RULES.map((item) => ` WHEN strpos(lower(${model}), ${sqlString(item.match)}) > 0 THEN ${sqlString(item.author)}`).join("\n")}
WHEN ${provider} <> '' AND lower(${provider}) NOT IN (${RETIRED_STAT_PROVIDERS.map(sqlString).join(", ")}) THEN ${provider}
@@ -13,11 +13,8 @@ export const MODEL_AUTHOR_RULES = [
{ match: "qwen", author: "qwen" },
] as const
export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"])
export const STEALTH_MODELS = new Set(["omen-alpha", "union-alpha"])
export const MODEL_NAME_ALIASES: Record<string, string> = {
"deepseek-flash": "deepseek-v4.1-flash",
"opencode-go/union-alpha": "union-alpha",
"opencode/union-alpha": "union-alpha",
"x-preview-f": "ox-alpha",
"xiaomi/mimo-v2.5": "mimo-v2.5",
}
@@ -48,10 +45,7 @@ export function statProvider(
providerModel: string | undefined,
provider: string | undefined,
) {
const normalized = statModel(model, providerModel)
if (STEALTH_MODELS.has(normalized.toLowerCase())) return "unknown"
const modelAuthorValue = modelAuthor(normalized)
const modelAuthorValue = modelAuthor(statModel(model, providerModel))
if (!modelAuthorValue) return undefined
const providerModelAuthor = modelAuthor(providerModel)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -128,7 +128,7 @@ export function createTuiAttention(input: {
return {
async notify(request) {
try {
if (!input.config.attention.notifications && !input.config.attention.sound) return skipped("attention_disabled")
if (!input.config.attention.enabled) return skipped("attention_disabled")
if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed")
const message = normalizeText(request.message, "", MESSAGE_LIMIT)
+12 -3
View File
@@ -253,11 +253,20 @@ export const settings: Setting[] = [
format: (value) => `${value} ms`,
keywords: ["leader key", "shortcut timeout"],
},
{
title: "Attention",
category: "Alerts",
path: ["attention", "enabled"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["alerts"],
},
{
title: "Notifications",
category: "Alerts",
path: ["attention", "notifications"],
default: false,
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["system notifications", "desktop notifications", "alerts"],
@@ -266,10 +275,10 @@ export const settings: Setting[] = [
title: "Sounds",
category: "Alerts",
path: ["attention", "sound"],
default: false,
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["audio", "sound effects", "alerts"],
keywords: ["audio", "sound effects"],
},
{
title: "Volume",
+5 -2
View File
@@ -95,6 +95,7 @@ export const Info = Schema.Struct({
).annotate({ description: "Scrolling behavior" }),
attention: Schema.optional(
Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable attention alerts" }),
notifications: Schema.optional(Schema.Boolean).annotate({ description: "Show system notifications" }),
sound: Schema.optional(Schema.Boolean).annotate({ description: "Play attention sounds" }),
volume: Schema.optional(
@@ -241,6 +242,7 @@ export type Info = Schema.Schema.Type<typeof Info>
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "session" | "tabs"> & {
attention: {
enabled: boolean
notifications: boolean
sound: boolean
volume: number
@@ -283,8 +285,9 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
return {
...input,
attention: {
notifications: input.attention?.notifications ?? false,
sound: input.attention?.sound ?? false,
enabled: input.attention?.enabled ?? false,
notifications: input.attention?.notifications ?? true,
sound: input.attention?.sound ?? true,
volume: input.attention?.volume ?? 0.4,
sound_pack: input.attention?.sound_pack ?? "opencode.default",
sounds: input.attention?.sounds ?? {},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/util",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode/web",
"type": "module",
"license": "MIT",
"version": "2.0.5",
"version": "2.0.4",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "2.0.5",
"version": "2.0.4",
"publisher": "sst-dev",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/update",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"scripts": {
+4 -2
View File
@@ -108,11 +108,12 @@ Set scroll speed and acceleration:
## Attention
Configure notifications and sounds. Both are off by default and can be enabled independently:
Configure notifications and sounds:
```json title="cli.json"
{
"attention": {
"enabled": true,
"notifications": true,
"sound": true,
"volume": 0.4,
@@ -126,7 +127,8 @@ Configure notifications and sounds. Both are off by default and can be enabled i
| Field | Values | Description |
| --------------- | ---------------------- | ---------------------------------------------------------------------------------------------------- |
| `notifications` | boolean | Shows system notifications when the terminal is not focused. |
| `enabled` | boolean | Enables attention alerts. |
| `notifications` | boolean | Shows system notifications. |
| `sound` | boolean | Plays attention sounds. |
| `volume` | number from `0` to `1` | Sets sound volume. |
| `sound_pack` | string | Selects the active sound pack. |