Compare commits

..

18 Commits

Author SHA1 Message Date
starptech 146990cc47 fix(core): refresh console auth before catalog load 2026-07-17 17:33:35 +02:00
Aiden Cline 27e1692848 fix: rm used config
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-17 00:15:16 -05:00
Aiden Cline 73567f3570 feat(core): record title and compaction usage in session totals (#37441) 2026-07-17 00:10:30 -05:00
Aiden Cline 91238441a6 fix(codemode): enforce lexical temporal dead zones (#37358) 2026-07-16 22:26:45 -05:00
Kit Langton faf964691b fix(core): initialize provider state before catalog transforms (#37419) 2026-07-16 22:45:42 -04:00
Kit Langton 4a7f760d25 fix(tui): preserve prompt footer actions (#37180) 2026-07-16 22:08:22 -04:00
James Long f76e18201a fix(tui): derive session surface colors from hues (#37401) 2026-07-16 20:16:13 -04:00
Dustin Deus 331533deff fix(cli): isolate server request traces (#37395) 2026-07-17 00:52:14 +02:00
James Long 309860558d refactor(tui): remove secondary action styles (#37378) 2026-07-16 17:44:11 -04:00
opencode-agent[bot] b4a4ef0b3c refactor(tui): remove legacy keymap layer (#37206)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-16 21:33:15 +00:00
opencode-agent[bot] e916b99742 chore: merge dev into v2 (#37370)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
2026-07-16 15:44:26 -05:00
James Long 88b8883122 feat(tui): resolve action state priority (#37373) 2026-07-16 16:40:34 -04:00
James Long ac1b802820 fix(tui): restore selected action styling, fix subagent list (#37371) 2026-07-16 16:20:16 -04:00
Dustin Deus 5ee4c1082a feat(tui): add message navigation shortcuts (#37362) 2026-07-16 16:18:18 -04:00
starptech 8a70d70006 revert(tui): remove message navigation shortcuts 2026-07-16 21:45:40 +02:00
James Long 91b4634363 fix(tui): remove debug theme toggle (#37359) 2026-07-16 15:31:42 -04:00
starptech 4f60bde502 feat(tui): add message navigation shortcuts 2026-07-16 21:27:13 +02:00
Dax Raad 0d3b6d430e fix(tui): restore expandable tool hover state 2026-07-16 15:00:49 -04:00
254 changed files with 3207 additions and 3773 deletions
+27 -27
View File
@@ -53,7 +53,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -119,7 +119,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.2",
"version": "1.18.3",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -179,7 +179,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -193,7 +193,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -229,7 +229,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -256,7 +256,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -278,7 +278,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -302,7 +302,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -322,7 +322,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.18.2",
"version": "1.18.3",
"bin": {
"opencode": "./bin/opencode",
},
@@ -418,7 +418,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -478,7 +478,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -492,7 +492,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"effect": "catalog:",
},
@@ -504,7 +504,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -536,7 +536,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -552,7 +552,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.83",
},
@@ -584,7 +584,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.18.2",
"version": "1.18.3",
"bin": {
"opencode": "./bin/opencode",
},
@@ -717,7 +717,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/ai": "workspace:*",
@@ -806,7 +806,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -821,7 +821,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -838,7 +838,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -899,7 +899,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -912,7 +912,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -946,7 +946,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -965,7 +965,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -1006,7 +1006,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
@@ -1036,7 +1036,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1087,7 +1087,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.18.2",
"version": "1.18.3",
"description": "",
"type": "module",
"exports": {
+90 -136
View File
@@ -1,17 +1,18 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useNavigate } from "@solidjs/router"
import { createMemo, onCleanup } from "solid-js"
import { useCommand, type CommandOption } from "@/context/command"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
export type CommandPaletteEntry = {
id: string
@@ -24,6 +25,8 @@ export type CommandPaletteEntry = {
path?: string
directory?: string
sessionID?: string
server?: ServerConnection.Key
project?: LocalProject
archived?: number
updated?: number
}
@@ -75,28 +78,25 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const global = useGlobal()
const language = useLanguage()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()()
const serverSync = useServerSync()
const { params, tabs } = useSessionLayout()
const serverCtx = global.ensureServerCtx(serverSDK.server)
const appTabs = useTabs()
const { tabs: sessionTabs } = useSessionLayout()
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const filesOnly = () => props.filesOnly?.() ?? false
const allowedCommands = createMemo(() => {
if (filesOnly()) return []
return command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
return commandPaletteOptions(command.options)
})
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return allowedCommands().map((option) => createCommandEntry(option, category))
return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category))
})
const preferredCommandEntries = createMemo(() => {
const all = allowedCommands()
@@ -105,11 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
})
const tabState = createSessionTabs({
tabs,
tabs: sessionTabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
@@ -140,36 +140,12 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
.map((path) => createCommandPaletteFileEntry(path, category))
})
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return undefined
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const sessions = createSessionEntries({
workspaces,
label: (directory) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
},
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
const sessions = createServerSessionEntries({
server: ServerConnection.key(serverSDK.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) =>
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -191,8 +167,17 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
if (!item.sessionID || !item.server) return
const directory = item.project?.worktree ?? item.directory
if (directory) {
serverCtx.projects.open(directory)
serverCtx.projects.touch(directory)
}
const tab = appTabs.addSessionTab({
server: item.server,
sessionId: item.sessionID,
})
appTabs.select(tab)
return
}
if (!item.path) return
@@ -218,7 +203,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
}
}
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
return {
id: "command:" + option.id,
type: "command",
@@ -230,96 +215,65 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal
}
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
export function createServerSessionEntries(props: {
server: ServerConnection.Key
opened: () => LocalProject[]
stored: () => Project[]
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
untitled: () => string
category: () => string
}) {
const state: {
token: number
inflight: Promise<CommandPaletteEntry[]> | undefined
cached: CommandPaletteEntry[] | undefined
} = { token: 0, inflight: undefined, cached: undefined }
let abort: AbortController | undefined
return (text: string) => {
if (!text.trim()) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as CommandPaletteEntry[]
onCleanup(() => abort?.abort())
return async (text: string): Promise<CommandPaletteEntry[]> => {
const search = text.trim()
if (!search) {
abort?.abort()
return []
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as CommandPaletteEntry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props
.load(directory)
.then((result) =>
(result.data ?? [])
.filter((session) => !!session?.id)
.map((session) => ({
id: session.id,
title: session.title ?? props.untitled(),
description,
directory,
archived: session.time?.archived,
updated: session.time?.updated,
})),
)
.catch(() => [] as SessionEntryInput[])
}),
)
.then((results) => {
if (state.token !== current) return [] as CommandPaletteEntry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, props.category()))
state.cached = next
return next
})
abort?.abort()
const current = new AbortController()
abort = current
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 100)
current.signal.addEventListener(
"abort",
() => {
clearTimeout(timer)
resolve()
},
{ once: true },
)
})
if (current.signal.aborted) return []
const opened = props.opened()
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
return props
.load(search, current.signal)
.then((result) =>
(result.data ?? [])
.filter((session) => !session.time.archived)
.map((session) => {
const project =
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
return {
id: `session:${props.server}:${session.id}`,
type: "session" as const,
title: session.title || props.untitled(),
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
category: props.category(),
directory: session.directory,
sessionID: session.id,
server: props.server,
project,
updated: session.time.updated,
}
}),
)
.catch(() => [] as CommandPaletteEntry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
}
type SessionEntryInput = {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
return {
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
}
}
@@ -5,13 +5,20 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { formatKeybindParts } from "@/context/command"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteCommandEntry,
createCommandPaletteFileEntry,
createCommandPaletteModel,
createServerSessionEntries,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
@@ -30,9 +37,6 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const loadItems = async (text: string) => {
const q = text.trim()
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
@@ -41,16 +45,105 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
const category = palette.language.t("palette.group.files")
return [
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions.filter((entry) => matchesEntry(entry, q)),
...nextSessions,
...files.map((path) => createCommandPaletteFileEntry(path, category)),
]
}
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
return (
<CommandPaletteView
placeholder={palette.language.t("palette.search.placeholder")}
loadItems={loadItems}
highlight={palette.highlight}
select={palette.select}
close={palette.close}
/>
)
}
export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.Any
onSelectSession: (entry: CommandPaletteEntry) => void
}) {
const command = useCommand()
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage()
const serverCtx = global.ensureServerCtx(props.server)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
})
const sessions = createServerSessionEntries({
server: ServerConnection.key(props.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) =>
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
state.cleanup?.()
state.cleanup = undefined
if (item?.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const select = (item: CommandPaletteEntry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") props.onSelectSession(item)
}
const loadItems = async (text: string) => {
const query = text.trim()
if (!query) return commandEntries().slice(0, 5)
return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return (
<CommandPaletteView
placeholder={language.t("palette.search.placeholder.home")}
loadItems={loadItems}
highlight={highlight}
select={select}
close={() => dialog.close()}
/>
)
}
function CommandPaletteView(props: {
placeholder: string
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
highlight: (item: CommandPaletteEntry | undefined) => void
select: (item: CommandPaletteEntry | undefined) => void
close: () => void
}) {
const language = useLanguage()
const tabs = useTabs()
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()])
const openSessions = createMemo(
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
)
createEffect(() => {
query()
@@ -59,7 +152,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
})
createEffect(() => {
palette.highlight(activeEntry())
props.highlight(activeEntry())
})
let resultsRef: HTMLDivElement | undefined
@@ -86,12 +179,12 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
}
if (event.key === "Enter") {
event.preventDefault()
palette.select(activeEntry())
props.select(activeEntry())
return
}
if (event.key === "Escape") {
event.preventDefault()
palette.close()
props.close()
}
}
@@ -105,7 +198,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
autocomplete="off"
spellcheck={false}
appearance="large"
placeholder={palette.language.t("palette.search.placeholder")}
placeholder={props.placeholder}
leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)}
onKeyDown={handleKeyDown}
@@ -117,7 +210,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
when={visibleEntries().length > 0}
fallback={
<div class="command-palette-v2-state">
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
</div>
}
>
@@ -132,9 +225,14 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
<PaletteRow
item={item}
active={activeEntry()?.id === item.id}
language={palette.language}
language={language}
sessionOpen={
item.server && item.sessionID
? openSessions().has(`${item.server}\0${item.sessionID}`)
: false
}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onSelect={() => palette.select(item)}
onSelect={() => props.select(item)}
/>
)}
</For>
@@ -153,13 +251,19 @@ function PaletteRow(props: {
item: CommandPaletteEntry
active: boolean
language: ReturnType<typeof useLanguage>
sessionOpen: boolean
onActive: () => void
onSelect: () => void
}) {
const session = () =>
props.item.server && props.item.directory && props.item.sessionID
? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
: undefined
return (
<button
type="button"
class="command-palette-v2-row"
class="command-palette-v2-row group"
role="option"
aria-selected={props.active}
data-active={props.active ? "" : undefined}
@@ -197,7 +301,25 @@ function PaletteRow(props: {
</Match>
<Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main">
<Icon name="status" class="command-palette-v2-row-icon" />
<div class="relative shrink-0">
<Show when={props.sessionOpen}>
<span
aria-hidden="true"
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 4px)" }}
/>
</Show>
<Show when={session()}>
{(session) => (
<SessionTabAvatar
project={props.item.project}
directory={session().directory}
sessionId={session().sessionID}
server={session().server}
/>
)}
</Show>
</div>
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title}
+1 -41
View File
@@ -8,49 +8,9 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png"
const helpIcon = (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
data-slot="icon-svg"
>
<path
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
fill="var(--v2-icon-icon-base)"
/>
</svg>
)
const triggerClass =
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
// TODO: wire to changelog / seen-state when available
const showPopover = () => true
export function HelpButton() {
const platform = usePlatform()
return (
<a
href="https://opencode.ai"
aria-label="Open the OpenCode website"
data-component="icon-button-v2"
data-size="large"
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
onClick={(event) => {
event.preventDefault()
platform.openLink(event.currentTarget.href)
}}
>
{helpIcon}
</a>
)
}
// can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() {
const settings = useSettings()
@@ -61,7 +21,7 @@ export function TabsInfoPopup() {
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={settings.general.shouldDisplayTabsToast()}>
<div
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
>
<button
@@ -10,7 +10,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast"
import fuzzysort from "fuzzysort"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list"
@@ -20,7 +20,6 @@ const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
+15 -1
View File
@@ -1,5 +1,19 @@
import { describe, expect, test } from "bun:test"
import { resolveKeybindOption, upsertCommandRegistration } from "./command"
import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command"
const paletteOptions: CommandOption[] = [
{ id: "settings.open", title: "Open settings" },
{ id: "session.undo", title: "Undo" },
{ id: "file.open", title: "Open file" },
{ id: "hidden", title: "Hidden", hidden: true },
{ id: "disabled", title: "Disabled", disabled: true },
]
describe("commandPaletteOptions", () => {
test("keeps visible enabled commands", () => {
expect(commandPaletteOptions(paletteOptions).map((option) => option.id)).toEqual(["settings.open", "session.undo"])
})
})
describe("upsertCommandRegistration", () => {
test("replaces keyed registrations", () => {
+9 -2
View File
@@ -11,7 +11,7 @@ import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
@@ -87,6 +87,13 @@ export interface CommandOption {
onHighlight?: () => (() => void) | void
}
export function commandPaletteOptions(options: CommandOption[]) {
return options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith(SUGGESTED_PREFIX) && option.id !== "file.open",
)
}
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
}
@@ -375,7 +382,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
const showPalette = () => {
run("file.open", "palette")
run(PALETTE_ID, "palette")
}
const handleKeyDown = (event: KeyboardEvent) => {
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { hasCustomAgent, resolveAgent } from "./local-agent"
describe("hasCustomAgent", () => {
test("detects explicitly custom agents", () => {
expect(hasCustomAgent([{ native: true }, { native: false }])).toBe(true)
})
test("ignores built-in and unclassified agents", () => {
expect(hasCustomAgent([{ native: true }, {}])).toBe(false)
})
})
describe("resolveAgent", () => {
const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }]
test("uses the requested available agent", () => {
expect(resolveAgent(agents, "custom")?.name).toBe("custom")
})
test("defaults to build", () => {
expect(resolveAgent(agents)?.name).toBe("build")
expect(resolveAgent(agents, "missing")?.name).toBe("build")
})
test("uses the first agent when build is unavailable", () => {
expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom")
})
})
+7
View File
@@ -0,0 +1,7 @@
export function hasCustomAgent(items: Array<{ native?: boolean }>) {
return items.some((item) => item.native === false)
}
export function resolveAgent<T extends { name: string }>(items: T[], name?: string) {
return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0]
}
+7 -4
View File
@@ -4,8 +4,10 @@ import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings"
import { useProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { hasCustomAgent, resolveAgent } from "./local-agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
@@ -62,9 +64,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory)
const models = useModels()
const settings = useSettings()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted(
@@ -107,9 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const pickAgent = (name: string | undefined) => {
const items = list()
if (items.length === 0) return
return items.find((item) => item.name === name) ?? items[0]
return resolveAgent(list(), name)
}
createEffect(() => {
@@ -180,8 +182,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = {
list,
visible: agentsVisible,
current() {
return pickAgent(scope()?.agent ?? store.current)
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
},
set(name: string | undefined) {
const item = pickAgent(name)
+1
View File
@@ -86,6 +86,7 @@ export const dict = {
"command.session.unshare": "إلغاء مشاركة الجلسة",
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
"palette.search.placeholder.home": "البحث في الأوامر والجلسات",
"palette.empty": "لا توجد نتائج",
"palette.group.commands": "الأوامر",
"palette.group.files": "الملفات",
+1
View File
@@ -86,6 +86,7 @@ export const dict = {
"command.session.unshare": "Parar de compartilhar sessão",
"command.session.unshare.description": "Parar de compartilhar esta sessão",
"palette.search.placeholder": "Buscar arquivos, comandos e sessões",
"palette.search.placeholder.home": "Buscar comandos e sessões",
"palette.empty": "Nenhum resultado encontrado",
"palette.group.commands": "Comandos",
"palette.group.files": "Arquivos",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "Zaustavi dijeljenje ove sesije",
"palette.search.placeholder": "Pretraži datoteke, komande i sesije",
"palette.search.placeholder.home": "Pretraži komande i sesije",
"palette.empty": "Nema rezultata",
"palette.group.commands": "Komande",
"palette.group.files": "Datoteke",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "Stop med at dele denne session",
"palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
"palette.search.placeholder.home": "Søg i kommandoer og sessioner",
"palette.empty": "Ingen resultater fundet",
"palette.group.commands": "Kommandoer",
"palette.group.files": "Filer",
+1
View File
@@ -90,6 +90,7 @@ export const dict = {
"command.session.unshare": "Teilen der Sitzung aufheben",
"command.session.unshare.description": "Teilen dieser Sitzung beenden",
"palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen",
"palette.search.placeholder.home": "Befehle und Sitzungen durchsuchen",
"palette.empty": "Keine Ergebnisse gefunden",
"palette.group.commands": "Befehle",
"palette.group.files": "Dateien",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "Stop sharing this session",
"palette.search.placeholder": "Search files, commands, and sessions",
"palette.search.placeholder.home": "Search commands and sessions",
"palette.empty": "No results found",
"palette.group.commands": "Commands",
"palette.group.files": "Files",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "Dejar de compartir esta sesión",
"palette.search.placeholder": "Buscar archivos, comandos y sesiones",
"palette.search.placeholder.home": "Buscar comandos y sesiones",
"palette.empty": "No se encontraron resultados",
"palette.group.commands": "Comandos",
"palette.group.files": "Archivos",
+1
View File
@@ -86,6 +86,7 @@ export const dict = {
"command.session.unshare": "Ne plus partager la session",
"command.session.unshare.description": "Arrêter de partager cette session",
"palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions",
"palette.search.placeholder.home": "Rechercher des commandes et des sessions",
"palette.empty": "Aucun résultat trouvé",
"palette.group.commands": "Commandes",
"palette.group.files": "Fichiers",
+1
View File
@@ -86,6 +86,7 @@ export const dict = {
"command.session.unshare": "セッションの共有を停止",
"command.session.unshare.description": "このセッションの共有を停止",
"palette.search.placeholder": "ファイル、コマンド、セッションを検索",
"palette.search.placeholder.home": "コマンドとセッションを検索",
"palette.empty": "結果が見つかりません",
"palette.group.commands": "コマンド",
"palette.group.files": "ファイル",
+1
View File
@@ -82,6 +82,7 @@ export const dict = {
"command.session.unshare": "세션 공유 중지",
"command.session.unshare.description": "이 세션 공유 중지",
"palette.search.placeholder": "파일, 명령어 및 세션 검색",
"palette.search.placeholder.home": "명령어 및 세션 검색",
"palette.empty": "결과 없음",
"palette.group.commands": "명령어",
"palette.group.files": "파일",
+1
View File
@@ -92,6 +92,7 @@ export const dict = {
"command.session.unshare.description": "Slutt å dele denne sesjonen",
"palette.search.placeholder": "Søk i filer, kommandoer og sesjoner",
"palette.search.placeholder.home": "Søk i kommandoer og sesjoner",
"palette.empty": "Ingen resultater funnet",
"palette.group.commands": "Kommandoer",
"palette.group.files": "Filer",
+1
View File
@@ -86,6 +86,7 @@ export const dict = {
"command.session.unshare": "Przestań udostępniać sesję",
"command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji",
"palette.search.placeholder": "Szukaj plików, poleceń i sesji",
"palette.search.placeholder.home": "Szukaj poleceń i sesji",
"palette.empty": "Brak wyników",
"palette.group.commands": "Polecenia",
"palette.group.files": "Pliki",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "Прекратить публикацию сессии",
"palette.search.placeholder": "Поиск файлов, команд и сессий",
"palette.search.placeholder.home": "Поиск команд и сессий",
"palette.empty": "Ничего не найдено",
"palette.group.commands": "Команды",
"palette.group.files": "Файлы",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "หยุดการแชร์เซสชันนี้",
"palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน",
"palette.search.placeholder.home": "ค้นหาคำสั่งและเซสชัน",
"palette.empty": "ไม่พบผลลัพธ์",
"palette.group.commands": "คำสั่ง",
"palette.group.files": "ไฟล์",
+1
View File
@@ -97,6 +97,7 @@ export const dict = {
"command.session.unshare.description": "Bu oturumun paylaşımını durdur",
"palette.search.placeholder": "Dosya, komut ve oturum ara",
"palette.search.placeholder.home": "Komut ve oturum ara",
"palette.empty": "Sonuç bulunamadı",
"palette.group.commands": "Komutlar",
"palette.group.files": "Dosyalar",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "Припинити поширення цієї сесії",
"palette.search.placeholder": "Пошук файлів, команд і сесій",
"palette.search.placeholder.home": "Пошук команд і сесій",
"palette.empty": "Результатів не знайдено",
"palette.group.commands": "Команди",
"palette.group.files": "Файли",
+1
View File
@@ -120,6 +120,7 @@ export const dict = {
"command.session.unshare.description": "停止分享此会话",
"palette.search.placeholder": "搜索文件、命令和会话",
"palette.search.placeholder.home": "搜索命令和会话",
"palette.empty": "未找到结果",
"palette.group.commands": "命令",
"palette.group.files": "文件",
+1
View File
@@ -97,6 +97,7 @@ export const dict = {
"command.session.unshare.description": "停止分享此工作階段",
"palette.search.placeholder": "搜尋檔案、命令和工作階段",
"palette.search.placeholder.home": "搜尋命令和工作階段",
"palette.empty": "找不到結果",
"palette.group.commands": "命令",
"palette.group.files": "檔案",
+211 -118
View File
@@ -5,6 +5,7 @@ import {
createMemo,
createResource,
createRoot,
createSignal,
For,
Match,
on,
@@ -74,6 +75,23 @@ const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
function containHomeWheel(event: WheelEvent, viewport: HTMLElement) {
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
if (!(event.target instanceof Element)) return
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
if (
scrollable !== viewport &&
scrollable &&
(event.deltaY < 0
? scrollable.scrollTop > 0
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
)
return
event.preventDefault()
}
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
@@ -144,6 +162,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({
@@ -202,6 +221,13 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
function updatePositionCache() {
if (!viewport) return
const header = groups()
.map((group) => headerRefs.get(group.id))
.find((el) => el !== undefined)
if (header && typeof getComputedStyle === "function") {
const top = Number.parseFloat(getComputedStyle(header).top)
if (Number.isFinite(top)) stickyTop = top
}
groups().forEach((group) => {
const el = headerRefs.get(group.id)
if (!el) return
@@ -217,7 +243,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
.slice(index + 1)
.map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined)
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
@@ -271,6 +297,9 @@ export function NewHome() {
const marked = useMarked()
const openSettings = useSettingsCommand()
let focusSessionSearch: (() => void) | undefined
let sessionViewport: HTMLDivElement | undefined
const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
const [state, setState] = createStore({
search: "",
searchFocused: false,
@@ -402,6 +431,34 @@ export function NewHome() {
}
command.register("home", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const conn = focusedServer()
if (!conn) return
const ctx = global.ensureServerCtx(conn)
const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
void dialog.show(() => (
<DialogHomeCommandPaletteV2
server={conn}
onSelectSession={(entry) => {
if (!entry.sessionID || !entry.directory || !entry.server) return
const sessionID = entry.sessionID
const server = entry.server
const directory = entry.project?.worktree ?? entry.directory
ctx.projects.open(directory)
ctx.projects.touch(directory)
void startTransition(() => {
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
tabs.select(tab)
})
}}
/>
))
},
},
{
id: "home.sessions.search.focus",
title: searchPlaceholder(),
@@ -541,127 +598,158 @@ export function NewHome() {
}
return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
<HomeProjectColumn
projects={projects()}
recentlyClosed={recentlyClosed()}
homedir={homedir()}
selected={selection()}
focusServer={focusServer}
selectProject={selectProject}
openNewSession={openProjectNewSession}
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
chooseProject={(conn) => void chooseProject(conn)}
editProject={editProject}
closeProject={(conn, directory) => {
const next = closeHomeProject(
selection(),
ServerConnection.key(conn),
global.ensureServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
<section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
aria-label={language.t("sidebar.project.recentSessions")}
>
<HomeSessionSearch
value={state.search}
placeholder={searchPlaceholder()}
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
showProjectName={!selectedProject()}
server={selection().server}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<ScrollView
class="h-full [container-type:size]"
thumbContainer={sessionThumbTrack}
thumbHoverTarget={sessionHoverTarget}
viewportRef={(el) => {
sessionViewport = el
sessionHeaderOpacity.setViewport(el)
}}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
onWheel={(event) => {
if (!sessionViewport) return
if (event.target instanceof Node && sessionViewport.contains(event.target)) return
containHomeWheel(event, sessionViewport)
}}
>
<div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
<HomeProjectColumn
projects={projects()}
recentlyClosed={recentlyClosed()}
homedir={homedir()}
selected={selection()}
focusServer={focusServer}
selectProject={selectProject}
openNewSession={openProjectNewSession}
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
chooseProject={(conn) => void chooseProject(conn)}
editProject={editProject}
closeProject={(conn, directory) => {
const next = closeHomeProject(
selection(),
ServerConnection.key(conn),
global.ensureServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
onWheel={(event) => {
if (sessionViewport) containHomeWheel(event, sessionViewport)
}}
onInput={(value) => setState("search", value)}
onFocus={() => setState("searchFocused", true)}
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<ScrollView
class="mt-3 -mr-3 min-h-0 flex-1 relative"
viewportRef={sessionHeaderOpacity.setViewport}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
<section
ref={setSessionHoverTarget}
class="min-h-0 min-w-0 flex-1 flex flex-col"
aria-label={language.t("sidebar.project.recentSessions")}
>
<Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
<Show
when={!sessionLoad.isLoading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={language.t("common.loading")} />
</div>
}
<div
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
onWheel={(event) => {
if (sessionViewport) containHomeWheel(event, sessionViewport)
}}
>
<Show
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
>
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
<For each={groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>
{(record) => (
<HomeSessionRow
record={record}
showProjectName={!selectedProject()}
server={selection().server}
openSession={openSession}
archiveSession={archiveSession}
/>
)}
</For>
</div>
</>
)}
</For>
<HomeSessionSearch
value={state.search}
placeholder={searchPlaceholder()}
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
showProjectName={!selectedProject()}
server={selection().server}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
}}
onInput={(value) => setState("search", value)}
onFocus={() => setState("searchFocused", true)}
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
</Show>
</ScrollView>
</section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
</div>
</div>
{/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
ref={setSessionThumbTrack}
data-component="home-session-scroll-track"
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!sessionLoad.isLoading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={language.t("common.loading")} />
</div>
}
>
<Show
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
>
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
<For each={groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>
{(record) => (
<HomeSessionRow
record={record}
showProjectName={!selectedProject()}
server={selection().server}
openSession={openSession}
archiveSession={archiveSession}
/>
)}
</For>
</div>
</>
)}
</For>
</div>
</Show>
</Show>
</div>
</section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
</div>
</ScrollView>
</div>
)
}
@@ -683,6 +771,7 @@ function HomeProjectColumn(props: {
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
onWheel: (event: WheelEvent) => void
}) {
const global = useGlobal()
const dialog = useDialog()
@@ -699,8 +788,12 @@ function HomeProjectColumn(props: {
return (
<aside
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
aria-label={props.language.t("home.projects")}
onWheel={(event) => {
if (event.target === event.currentTarget) return
props.onWheel(event)
}}
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
@@ -1418,7 +1511,7 @@ function HomeSessionGroupHeader(props: {
return (
<div
ref={props.ref}
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
>
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
{props.title}
+1 -2
View File
@@ -1,7 +1,7 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
import { TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
@@ -38,7 +38,6 @@ export default function NewLayout(props: ParentProps) {
</main>
{import.meta.env.DEV && <DebugBar inline />}
<TabsInfoPopup />
<HelpButton />
<ToastRegion v2 />
</div>
)
+1 -2
View File
@@ -54,7 +54,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
import { TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useDirectoryPicker } from "@/components/directory-picker"
import { ServerConnection, useServer } from "@/context/server"
@@ -2396,7 +2396,6 @@ export default function LegacyLayout(props: ParentProps) {
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div>
<TabsInfoPopup />
<HelpButton />
<ToastRegion v2={false} />
</div>
)
+11
View File
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useSearchParams } from "@solidjs/router"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { NewSessionDesignView } from "@/components/session"
@@ -51,6 +52,7 @@ export default function NewSessionPage() {
const comments = useComments()
const language = useLanguage()
const settings = useSettings()
const dialog = useDialog()
const command = useCommand()
const providers = useProviders(() => sdk().directory)
const openProviderSettings = useSettingsDialog("providers")
@@ -77,6 +79,15 @@ export default function NewSessionPage() {
})
command.register("new-session", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const { DialogSelectFile } = await import("@/components/dialog-select-file")
void dialog.show(() => <DialogSelectFile />)
},
},
{
id: "input.focus",
title: language.t("command.input.focus"),
+10
View File
@@ -41,6 +41,7 @@ import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/r
import { NewSessionView, SessionHeader } from "@/components/session"
import { ErrorPage } from "@/pages/error"
import { CommentsProvider, useComments } from "@/context/comments"
import { useCommand } from "@/context/command"
import { DirectoryDataProvider } from "@/pages/directory-layout"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -362,6 +363,7 @@ export default function Page() {
const platform = usePlatform()
const prompt = usePrompt()
const comments = useComments()
const command = useCommand()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
@@ -1098,6 +1100,14 @@ export default function Page() {
review: reviewTab,
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
})
command.register("session-palette", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: () => command.trigger("file.open", "palette"),
},
])
const openReviewFile = createOpenReviewFile({
showAllFiles,
@@ -41,7 +41,7 @@ export function createPromptInputController(input: {
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
loading: agentsQuery.isLoading,
visible: settings.visibility.customAgents(),
visible: local.agent.visible(),
select: local.agent.set,
},
model: {
@@ -1,7 +1,6 @@
import { useCommand, type CommandOption } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local"
import { useSettings } from "@/context/settings"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
import { useSessionLayout } from "./session-layout"
@@ -19,7 +18,6 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
const dialog = useDialog()
const language = useLanguage()
const local = useLocal()
const settings = useSettings()
const { sessionKey } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
const model = input.model ?? local.model
@@ -70,7 +68,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
disabled: !settings.visibility.customAgents(),
disabled: !local.agent.visible(),
onSelect: () => local.agent.move(1),
}),
agentCommand({
@@ -78,7 +76,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
disabled: !settings.visibility.customAgents(),
disabled: !local.agent.visible(),
onSelect: () => local.agent.move(-1),
}),
])
@@ -468,7 +468,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
keybind: "mod+p",
slash: "open",
onSelect: openFile,
}),
@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test"
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
import { createRoot } from "solid-js"
import { createServerSessionEntries } from "@/components/command-palette"
import type { LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
const stored: Project = {
id: "project-1",
name: "Palette project",
worktree: "/workspace/project",
sandboxes: [],
time: { created: 1, updated: 1 },
}
const session: GlobalSession = {
id: "session-1",
slug: "session-1",
projectID: stored.id,
directory: stored.worktree,
title: "Palette session",
version: "1",
time: { created: 1, updated: 2 },
project: { id: stored.id, name: stored.name, worktree: stored.worktree },
}
describe("command palette sessions", () => {
test("uses the home project avatar and cancels superseded searches", async () => {
const server = ServerConnection.Key.make("selected-server")
const opened: LocalProject = {
...stored,
icon: { override: "home-project-avatar" },
expanded: true,
}
const searches: string[] = []
const result = await new Promise<Awaited<ReturnType<ReturnType<typeof createServerSessionEntries>>>>(
(resolve, reject) => {
createRoot((dispose) => {
const search = createServerSessionEntries({
server,
opened: () => [opened],
stored: () => [{ ...stored, icon: { url: "stored-project-avatar" } }],
load: async (text) => {
searches.push(text)
return {
data: [session, { ...session, id: "archived-session", time: { ...session.time, archived: 3 } }],
}
},
untitled: () => "Untitled",
category: () => "Sessions",
})
const first = search("palette")
const second = search("palette session")
Promise.all([first, second])
.then(([cancelled, entries]) => {
expect(cancelled).toEqual([])
resolve(entries)
})
.catch(reject)
.finally(dispose)
})
},
)
expect(searches).toEqual(["palette session"])
expect(result).toHaveLength(1)
expect(getProjectAvatarSource(result[0]?.project?.id, result[0]?.project?.icon)).toBe("home-project-avatar")
expect(result[0]).toMatchObject({
server,
sessionID: session.id,
description: stored.name,
project: { id: stored.id, icon: opened.icon },
})
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -5,7 +5,7 @@ import { ServerProcess } from "../../server-process"
export default Runtime.handler(
Commands.commands.serve,
Effect.fn("cli.serve")(function* (input) {
Effect.fnUntraced(function* (input) {
if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined"))
return yield* ServerProcess.run({
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
+6
View File
@@ -633,6 +633,12 @@ export function RunSubagentSelectBody(props: {
return
}
if (event.name.toLowerCase() === "up" && menu.selected() === 0) {
event.preventDefault()
props.onClose()
return
}
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
})
+45 -64
View File
@@ -23,7 +23,7 @@ import {
movePromptHistory,
pushPromptHistory,
} from "./prompt.shared"
import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
@@ -993,93 +993,83 @@ export function createPromptState(input: PromptInput): PromptState {
return true
}
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: baseBindingsEnabled(),
commands: [
{
name: "prompt.clear",
id: "prompt.clear",
title: "Clear prompt or exit",
category: "Prompt",
group: "Prompt",
run() {
if (requestExit()) return
return false
},
},
],
bindings: input.tuiConfig.keybinds.get("prompt.clear"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt(),
commands: [
{
name: "session.interrupt",
id: "session.interrupt",
title: "Interrupt session",
category: "Session",
group: "Session",
run() {
if (input.onInterrupt()) return
return false
},
},
],
bindings: input.tuiConfig.keybinds.get("session.interrupt"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt() && !visible(),
commands: [
{
name: "prompt.editor",
id: "prompt.editor",
title: "Open editor",
category: "Prompt",
group: "Prompt",
run() {
void openEditor()
},
},
],
bindings: input.tuiConfig.keybinds.get("prompt.editor"),
}))
useBindings(() => ({
Keymap.createLayer(() => ({
priority: 1,
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(),
commands: [
{
name: "prompt.history.previous",
id: "prompt.history.previous",
title: "Previous prompt history",
category: "Prompt",
run(ctx: { event: KeyEvent }) {
return historyCommand(-1, ctx.event)
group: "Prompt",
run(_input: string | undefined, event?: KeyEvent) {
if (!event) return false
return historyCommand(-1, event)
},
},
{
name: "prompt.history.next",
id: "prompt.history.next",
title: "Next prompt history",
category: "Prompt",
run(ctx: { event: KeyEvent }) {
return historyCommand(1, ctx.event)
group: "Prompt",
run(_input: string | undefined, event?: KeyEvent) {
if (!event) return false
return historyCommand(1, event)
},
},
],
bindings: [
...input.tuiConfig.keybinds.get("prompt.history.previous"),
...input.tuiConfig.keybinds.get("prompt.history.next"),
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt() && !visible(),
bindings: [
commands: [
{
key: "!",
desc: "Shell mode",
bind: "!",
title: "Shell mode",
group: "Prompt",
cmd() {
run() {
if (shell()) return false
if (!area || area.isDestroyed) return false
if (area.cursorOffset !== 0) return false
@@ -1089,21 +1079,20 @@ export function createPromptState(input: PromptInput): PromptState {
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt() && shell() && !visible(),
bindings: [
commands: [
{
key: "escape",
desc: "Exit shell mode",
bind: "escape",
title: "Exit shell mode",
group: "Prompt",
cmd: () => setShellMode(false),
run: () => setShellMode(false),
},
{
key: "backspace",
desc: "Exit shell mode",
bind: "backspace",
title: "Exit shell mode",
group: "Prompt",
cmd() {
run() {
if (!area || area.isDestroyed) return false
if (area.cursorOffset !== 0) return false
setShellMode(false)
@@ -1112,32 +1101,31 @@ export function createPromptState(input: PromptInput): PromptState {
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt() && visible(),
commands: [
{
name: "prompt.autocomplete.prev",
id: "prompt.autocomplete.prev",
title: "Previous autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run: () => menu.move(-1),
},
{
name: "prompt.autocomplete.next",
id: "prompt.autocomplete.next",
title: "Next autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run: () => menu.move(1),
},
{
name: "prompt.autocomplete.hide",
id: "prompt.autocomplete.hide",
title: "Hide autocomplete",
category: "Autocomplete",
group: "Autocomplete",
run: cancelAutocomplete,
},
{
name: "prompt.autocomplete.select",
id: "prompt.autocomplete.select",
title: "Select autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
if (mode() === "slash" && options().length === 0) {
hide()
@@ -1147,9 +1135,9 @@ export function createPromptState(input: PromptInput): PromptState {
},
},
{
name: "prompt.autocomplete.complete",
id: "prompt.autocomplete.complete",
title: "Complete autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
if (mode() === "slash" && options().length === 0) {
hide()
@@ -1164,13 +1152,6 @@ export function createPromptState(input: PromptInput): PromptState {
},
},
],
bindings: [
"prompt.autocomplete.prev",
"prompt.autocomplete.next",
"prompt.autocomplete.hide",
"prompt.autocomplete.select",
"prompt.autocomplete.complete",
].flatMap((command) => input.tuiConfig.keybinds.get(command)),
}))
const onKeyDown = (event: KeyEvent) => {
+4 -6
View File
@@ -24,12 +24,11 @@
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
// two-press pattern where the first press shows a hint and the second press
// within 5 seconds actually fires the action.
import { CliRenderEvents, type CliRenderer, type KeyEvent, type Renderable, type TreeSitterClient } from "@opentui/core"
import type { Keymap } from "@opentui/keymap"
import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core"
import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { OpencodeKeymapProvider } from "@opencode-ai/tui/keymap"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
@@ -82,7 +81,6 @@ type RunFooterOptions = {
first: boolean
history?: RunPrompt[]
theme: RunTheme
keymap: Keymap<Renderable, KeyEvent>
tuiConfig: RunTuiConfig
diffStyle: RunDiffStyle
onPermissionReply: (input: PermissionReply) => void | Promise<void>
@@ -305,8 +303,8 @@ export class RunFooter implements FooterApi {
const footer = this
void render(
() =>
createComponent(OpencodeKeymapProvider, {
keymap: options.keymap,
createComponent(Keymap.Provider, {
config: options.tuiConfig,
get children() {
return createComponent(RunFooterView, {
directory: options.directory,
+29 -107
View File
@@ -27,14 +27,8 @@ import { RunPromptBody, createPromptState } from "./footer.prompt"
import { RunPermissionBody } from "./footer.permission"
import { RunQuestionBody } from "./footer.question"
import { footerWidthPolicy } from "./footer.width"
import {
OPENCODE_BASE_MODE,
formatKeyBindings,
formatKeySequence,
useBindings,
useKeymapSelector,
type OpenTuiKeymap,
} from "@opencode-ai/tui/keymap"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import type {
FooterPromptRoute,
FooterQueuedPrompt,
@@ -177,75 +171,15 @@ export function RunFooterView(props: RunFooterViewProps) {
const current = route()
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
})
const command = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] })
.get("command.palette.show")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const subagentShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] })
.get("session.child.first")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const queuedShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.queued_prompts"] })
.get("session.queued_prompts")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const backgroundShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.background"] })
.get("session.background")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const subagentInterruptShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["subagent.interrupt"] })
.get("subagent.interrupt")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const interrupt = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
.get("session.interrupt")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const variantCycle = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
keymap.getCommandBindings({ visibility: "registered", commands: ["variant.cycle"] }).get("variant.cycle"),
props.tuiConfig,
) ?? "",
)
const clearShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap.getCommandBindings({ visibility: "registered", commands: ["prompt.clear"] }).get("prompt.clear")?.[0]
?.sequence,
props.tuiConfig,
) ?? "",
)
const shortcuts = Keymap.useShortcuts()
const command = () => shortcuts.get("command.palette.show") ?? ""
const subagentShortcut = () => shortcuts.get("session.child.first") ?? ""
const queuedShortcut = () => shortcuts.get("session.queued_prompts") ?? ""
const backgroundShortcut = () => shortcuts.get("session.background") ?? ""
const subagentInterruptShortcut = () => shortcuts.get("subagent.interrupt") ?? ""
const interrupt = () => shortcuts.get("session.interrupt") ?? ""
const variantCycle = () => shortcuts.all("variant.cycle") ?? ""
const clearShortcut = () => shortcuts.get("prompt.clear") ?? ""
const busy = createMemo(() => props.state().phase === "running")
const armed = createMemo(() => props.state().interrupt > 0)
const exiting = createMemo(() => props.state().exit > 0)
@@ -504,74 +438,62 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onRequestExit?.(undefined)
})
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
commands: [
{
name: "command.palette.show",
id: "command.palette.show",
title: "Open command palette",
category: "Prompt",
group: "Prompt",
run: openCommand,
},
{
name: "variant.cycle",
id: "variant.cycle",
title: "Cycle model variant",
category: "Model",
group: "Model",
run: props.onCycle,
},
],
bindings: [
...props.tuiConfig.keybinds.get("command.palette.show"),
...props.tuiConfig.keybinds.get("variant.cycle"),
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
priority: 1,
commands: [
{
name: "session.background",
id: "session.background",
title: "Background subagents",
category: "Session",
group: "Session",
run: () => props.onBackground?.(),
},
],
bindings: props.tuiConfig.keybinds.get("session.background"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
commands: [
{
name: "session.child.first",
id: "session.child.first",
title: "View subagents",
category: "Session",
group: "Session",
run: openSubagentMenu,
},
],
bindings: props.tuiConfig.keybinds.get("session.child.first"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
commands: [
{
name: "session.queued_prompts",
id: "session.queued_prompts",
title: "Manage queued prompts",
category: "Session",
group: "Session",
run: openQueuedMenu,
},
],
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled:
active().type === "prompt" &&
route().type === "subagent" &&
@@ -580,9 +502,10 @@ export function RunFooterView(props: RunFooterViewProps) {
priority: 1,
commands: [
{
name: "subagent.interrupt",
id: "subagent.interrupt",
title: "Interrupt subagent",
category: "Session",
group: "Session",
bind: "ctrl+d",
run: () => {
const current = selectedTab()
if (current?.status !== "running") {
@@ -593,7 +516,6 @@ export function RunFooterView(props: RunFooterViewProps) {
},
},
],
bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }],
}))
createEffect(() => {
@@ -10,9 +10,7 @@
// back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Global } from "@opencode-ai/core/global"
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { isDefaultTitle } from "@opencode-ai/tui/util/session"
import { Locale } from "@opencode-ai/tui/util/locale"
import { resolveInteractiveStdin } from "./runtime.stdin"
@@ -167,8 +165,6 @@ function queueSplash(
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
const source = resolveInteractiveStdin()
const footerTask = import("./footer")
let unregisterKeymap: (() => void) | undefined
try {
const renderer = await createCliRenderer({
stdin: source.stdin,
@@ -187,8 +183,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
})
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
renderer.setBackgroundColor(theme.background)
const keymap = createDefaultOpenTuiKeymap(renderer)
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
const state: SplashState = {
entry: false,
exit: false,
@@ -233,7 +227,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
history: input.history,
theme,
wrote,
keymap,
tuiConfig,
diffStyle: tuiConfig.diff_style ?? "auto",
onPermissionReply: input.onPermissionReply,
@@ -333,7 +326,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
footer.close()
await footer.idle().catch(() => {})
footer.destroy()
unregisterKeymap?.()
shutdown(renderer)
if (!wroteExit) {
process.stdout.write("\n")
@@ -391,7 +383,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
close,
}
} catch (error) {
unregisterKeymap?.()
source.cleanup?.()
throw error
}
+5 -4
View File
@@ -24,13 +24,14 @@ export type Options = {
readonly port?: number
}
export const run = Effect.fn("cli.server-process.run")((options: Options) =>
processEffect(options).pipe(
// 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) {
return yield* processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(NodeServices.layer),
),
)
)
})
const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
+39 -50
View File
@@ -1,10 +1,9 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { testRender } from "@opentui/solid"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import { resolve } from "@opencode-ai/tui/config/v1"
import { expect, test } from "bun:test"
import { createComponent, createSignal } from "solid-js"
import { createSignal } from "solid-js"
import { RunFooterView } from "../src/mini/footer.view"
import { RUN_THEME_FALLBACK } from "../src/mini/theme"
import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types"
@@ -42,52 +41,43 @@ test("down opens subagents from an empty prompt", async () => {
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
{ terminalSuspend: true },
)
let offKeymap: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
return createComponent(OpencodeKeymapProvider, {
keymap,
get children() {
return (
<RunFooterView
directory="/tmp"
findFiles={async () => []}
agents={() => []}
references={() => []}
commands={() => []}
providers={() => undefined}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
state={state}
view={view}
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
agent="opencode"
onSubmit={() => true}
onPermissionReply={() => {}}
onQuestionReply={() => {}}
onQuestionReject={() => {}}
onCycle={() => {}}
onInterrupt={() => false}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
)
},
})
return (
<Keymap.Provider config={config}>
<RunFooterView
directory="/tmp"
findFiles={async () => []}
agents={() => []}
references={() => []}
commands={() => []}
providers={() => undefined}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
state={state}
view={view}
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
agent="opencode"
onSubmit={() => true}
onPermissionReply={() => {}}
onQuestionReply={() => {}}
onQuestionReject={() => {}}
onCycle={() => {}}
onInterrupt={() => false}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</Keymap.Provider>
)
}
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
@@ -100,7 +90,6 @@ test("down opens subagents from an empty prompt", async () => {
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
app.renderer.destroy()
}
})
@@ -803,6 +803,16 @@ export type SessionRevertCommitted = {
data: { sessionID: string; to: string }
}
export type SessionUsageRecorded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.usage.recorded"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo }
}
export type ModelsDevRefreshed = {
id: string
created: number
@@ -2216,6 +2226,7 @@ export type SessionEventDurable =
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionUsageRecorded
export type SessionMessagesResponse = {
data: Array<SessionMessageInfo>
+1 -1
View File
@@ -50,7 +50,7 @@ ultimate source of truth.
- [x] Direct function declarations are hoisted in program and block statement lists.
- [x] Parameter defaults observe a temporal dead zone for later parameters.
- [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations.
- [ ] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and
- [x] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
temporal dead zone.
- [ ] Hoist function declarations accepted directly in switch cases.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.18.2",
"version": "1.18.3",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+157 -113
View File
@@ -144,6 +144,20 @@ const collectPatternNames = (pattern: AstNode, out: Array<string> = []): Array<s
return out
}
const loopDeclaration = (left: AstNode, statement: "for...of" | "for...in") => {
if (left.type !== "VariableDeclaration") return undefined
const declarations = getArray(left, "declarations")
if (declarations.length !== 1) {
throw new InterpreterRuntimeError(`${statement} supports one declared binding.`, left)
}
const kind = getString(left, "kind")
return {
pattern: getNode(asNode(declarations[0], "declarations[0]"), "id"),
mutable: kind !== "const",
lexical: kind !== "var",
}
}
export class Interpreter<R> {
private scopes: ScopeStack
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
@@ -207,6 +221,7 @@ export class Interpreter<R> {
// Keep top-level declarations separate so they can shadow builtins.
this.scopes.push()
return Effect.gen(function* () {
self.predeclareLexical(program.body)
self.hoistFunctions(program.body)
let value: unknown = undefined
for (const [index, statement] of program.body.entries()) {
@@ -303,6 +318,7 @@ export class Interpreter<R> {
const self = this
return Effect.gen(function* () {
const body = getArray(node, "body")
self.predeclareLexical(body)
self.hoistFunctions(body)
for (const statementValue of body) {
@@ -343,6 +359,25 @@ export class Interpreter<R> {
}
}
private predeclareLexical(statements: Array<unknown>): void {
for (const statementValue of statements) {
if (!isRecord(statementValue) || statementValue.type !== "VariableDeclaration") continue
const statement = statementValue as AstNode
const kind = getString(statement, "kind")
if (kind === "var") continue
for (const declarationValue of getArray(statement, "declarations")) {
const declaration = asNode(declarationValue, "declarations")
for (const name of collectPatternNames(getNode(declaration, "id"))) {
this.scopes.reserve(name, kind !== "const", declaration)
}
}
}
}
private predeclarePattern(pattern: AstNode, mutable: boolean, node: AstNode): void {
for (const name of collectPatternNames(pattern)) this.scopes.reserve(name, mutable, node)
}
private evaluateIfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
const testNode = getNode(node, "test")
const consequentNode = getNode(node, "consequent")
@@ -359,7 +394,6 @@ export class Interpreter<R> {
private evaluateSwitchStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
const self = this
this.scopes.push()
return Effect.gen(function* () {
const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant"))
if (containsOpaqueReference(discriminant)) {
@@ -369,39 +403,43 @@ export class Interpreter<R> {
"InvalidDataValue",
)
}
const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`))
let defaultIndex: number | undefined
let selected: number | undefined
for (const [index, branch] of cases.entries()) {
const test = getOptionalNode(branch, "test")
if (!test) {
defaultIndex = index
continue
self.scopes.push()
return yield* Effect.gen(function* () {
const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`))
self.predeclareLexical(cases.flatMap((branch) => getArray(branch, "consequent")))
let defaultIndex: number | undefined
let selected: number | undefined
for (const [index, branch] of cases.entries()) {
const test = getOptionalNode(branch, "test")
if (!test) {
defaultIndex = index
continue
}
const candidate = yield* self.evaluateExpression(test)
if (containsOpaqueReference(candidate)) {
throw new InterpreterRuntimeError(
"Switch case values must be data values in CodeMode.",
test,
"InvalidDataValue",
)
}
if (candidate === discriminant) {
selected = index
break
}
}
const candidate = yield* self.evaluateExpression(test)
if (containsOpaqueReference(candidate)) {
throw new InterpreterRuntimeError(
"Switch case values must be data values in CodeMode.",
test,
"InvalidDataValue",
)
const start = selected ?? defaultIndex
if (start === undefined) return { kind: "none" } satisfies StatementResult
for (let index = start; index < cases.length; index += 1) {
for (const statementValue of getArray(cases[index]!, "consequent")) {
const result = yield* self.evaluateStatement(asNode(statementValue, "consequent"))
if (result.kind === "break") return { kind: "none" } satisfies StatementResult
if (result.kind === "return" || result.kind === "continue") return result
}
}
if (candidate === discriminant) {
selected = index
break
}
}
const start = selected ?? defaultIndex
if (start === undefined) return { kind: "none" } satisfies StatementResult
for (let index = start; index < cases.length; index += 1) {
for (const statementValue of getArray(cases[index]!, "consequent")) {
const result = yield* self.evaluateStatement(asNode(statementValue, "consequent"))
if (result.kind === "break") return { kind: "none" } satisfies StatementResult
if (result.kind === "return" || result.kind === "continue") return result
}
}
return { kind: "none" } satisfies StatementResult
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
return { kind: "none" } satisfies StatementResult
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
})
}
private evaluateWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
@@ -465,6 +503,10 @@ export class Interpreter<R> {
const updateNode = getOptionalNode(node, "update")
const bodyNode = getNode(node, "body")
if (initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var") {
self.predeclareLexical([initNode])
}
if (initNode) {
if (initNode.type === "VariableDeclaration") {
yield* self.evaluateVariableDeclaration(initNode)
@@ -478,21 +520,18 @@ export class Interpreter<R> {
? Array.from(self.scopes.current().keys())
: []
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
const iterationScope =
perIterationBindings.length > 0
? new Map(
perIterationBindings.map((name): [string, Binding] => [name, { ...self.scopes.current().get(name)! }]),
)
: undefined
if (iterationScope) self.scopes.push(iterationScope)
const result = yield* self.evaluateStatement(bodyNode).pipe(
Effect.ensuring(
Effect.sync(() => {
if (iterationScope) self.scopes.pop()
}),
),
const nextIteration = () => {
if (perIterationBindings.length === 0) return
const current = self.scopes.current()
self.scopes.pop()
self.scopes.push(
new Map(perIterationBindings.map((name): [string, Binding] => [name, { ...current.get(name)! }])),
)
}
nextIteration()
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
const result = yield* self.evaluateStatement(bodyNode)
if (result.kind === "return") {
return result
@@ -502,13 +541,7 @@ export class Interpreter<R> {
return { kind: "none" } satisfies StatementResult
}
if (iterationScope) {
const loopScope = self.scopes.current()
for (const name of perIterationBindings) {
loopScope.set(name, { ...iterationScope.get(name)! })
}
}
nextIteration()
if (updateNode) {
yield* self.evaluateExpression(updateNode)
}
@@ -527,9 +560,13 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("for await...of is not supported.", node)
}
const left = getNode(node, "left")
const declared = loopDeclaration(left, "for...of")
if (declared?.lexical) this.scopes.push()
const self = this
return Effect.gen(function* () {
const left = getNode(node, "left")
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
const right = yield* self.evaluateExpression(getNode(node, "right"))
const body = getNode(node, "body")
@@ -538,40 +575,34 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node)
}
let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
let assignment: AstNode | undefined
if (left.type === "VariableDeclaration") {
const declarations = getArray(left, "declarations")
if (declarations.length !== 1) {
throw new InterpreterRuntimeError("for...of supports one declared binding.", left)
}
const declarator = asNode(declarations[0], "declarations[0]")
declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
} else if (
left.type === "Identifier" ||
left.type === "MemberExpression" ||
left.type === "ArrayPattern" ||
left.type === "ObjectPattern"
if (
left.type !== "VariableDeclaration" &&
(left.type === "Identifier" ||
left.type === "MemberExpression" ||
left.type === "ArrayPattern" ||
left.type === "ObjectPattern")
) {
assignment = left
} else {
} else if (left.type !== "VariableDeclaration") {
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
}
for (const value of iterable) {
if (declaration) {
self.scopes.push()
yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left)
} else if (assignment) {
yield* self.assignPattern(assignment, value, left)
}
const result = yield* self.evaluateStatement(body).pipe(
const result = yield* Effect.gen(function* () {
if (declared) {
self.scopes.push()
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical)
} else if (assignment) {
yield* self.assignPattern(assignment, value, left)
}
return yield* self.evaluateStatement(body)
}).pipe(
Effect.ensuring(
Effect.sync(() => {
if (declaration) self.scopes.pop()
if (declared) self.scopes.pop()
}),
),
)
@@ -581,7 +612,7 @@ export class Interpreter<R> {
}
if (result.kind === "break") {
return { kind: "none" }
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "continue") {
@@ -589,8 +620,14 @@ export class Interpreter<R> {
}
}
return { kind: "none" }
})
return { kind: "none" } satisfies StatementResult
}).pipe(
Effect.ensuring(
Effect.sync(() => {
if (declared?.lexical) self.scopes.pop()
}),
),
)
}
private enumerableKeys(value: unknown): Array<string> | undefined {
@@ -607,9 +644,13 @@ export class Interpreter<R> {
}
private evaluateForInStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
const left = getNode(node, "left")
const declared = loopDeclaration(left, "for...in")
if (declared?.lexical) this.scopes.push()
const self = this
return Effect.gen(function* () {
const left = getNode(node, "left")
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
const right = yield* self.evaluateExpression(getNode(node, "right"))
const body = getNode(node, "body")
@@ -621,35 +662,28 @@ export class Interpreter<R> {
)
}
let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
let assignmentName: string | undefined
if (left.type === "VariableDeclaration") {
const declarations = getArray(left, "declarations")
if (declarations.length !== 1) {
throw new InterpreterRuntimeError("for...in supports one declared binding.", left)
}
const declarator = asNode(declarations[0], "declarations[0]")
declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
} else if (left.type === "Identifier") {
if (left.type === "Identifier") {
assignmentName = getString(left, "name")
} else {
} else if (left.type !== "VariableDeclaration") {
throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
}
for (const key of keys) {
if (declaration) {
self.scopes.push()
yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left)
} else if (assignmentName) {
self.scopes.set(assignmentName, key, left)
}
const result = yield* self.evaluateStatement(body).pipe(
const result = yield* Effect.gen(function* () {
if (declared) {
self.scopes.push()
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
yield* self.declarePattern(declared.pattern, key, declared.mutable, left, declared.lexical)
} else if (assignmentName) {
self.scopes.set(assignmentName, key, left)
}
return yield* self.evaluateStatement(body)
}).pipe(
Effect.ensuring(
Effect.sync(() => {
if (declaration) self.scopes.pop()
if (declared) self.scopes.pop()
}),
),
)
@@ -659,7 +693,7 @@ export class Interpreter<R> {
}
if (result.kind === "break") {
return { kind: "none" }
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "continue") {
@@ -667,8 +701,14 @@ export class Interpreter<R> {
}
}
return { kind: "none" }
})
return { kind: "none" } satisfies StatementResult
}).pipe(
Effect.ensuring(
Effect.sync(() => {
if (declared?.lexical) self.scopes.pop()
}),
),
)
}
private evaluateBreakStatement(node: AstNode): StatementResult {
@@ -752,7 +792,7 @@ export class Interpreter<R> {
const init = getOptionalNode(declaration, "init")
const value = init ? yield* self.evaluateExpression(init) : undefined
yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration)
yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration, kind !== "var")
}
})
}
@@ -762,17 +802,20 @@ export class Interpreter<R> {
value: unknown,
mutable: boolean,
node: AstNode,
initialize = false,
): Effect.Effect<void, unknown, R> {
const self = this
return Effect.gen(function* () {
if (pattern.type === "Identifier") {
self.scopes.declare(getString(pattern, "name"), value, mutable, node)
const name = getString(pattern, "name")
if (initialize) self.scopes.initialize(name, value, node)
else self.scopes.declare(name, value, mutable, node)
return
}
if (pattern.type === "AssignmentPattern") {
const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value
yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node)
yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node, initialize)
return
}
@@ -794,7 +837,7 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(value as SafeObject)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
}
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property)
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize)
continue
}
@@ -808,6 +851,7 @@ export class Interpreter<R> {
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
mutable,
property,
initialize,
)
}
return
@@ -823,10 +867,10 @@ export class Interpreter<R> {
if (item === null) continue
const element = asNode(item, `elements[${index}]`)
if (element.type === "RestElement") {
yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element)
yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element, initialize)
break
}
yield* self.declarePattern(element, items[index], mutable, pattern)
yield* self.declarePattern(element, items[index], mutable, pattern, initialize)
}
return
}
@@ -1577,10 +1621,10 @@ export class Interpreter<R> {
}
for (const [index, parameter] of fn.parameters.entries()) {
if (parameter.type === "RestElement") {
yield* invocation.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter)
yield* invocation.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter, true)
break
}
yield* invocation.declarePattern(parameter, args[index], true, parameter)
yield* invocation.declarePattern(parameter, args[index], true, parameter, true)
}
if (fn.body.type === "BlockStatement") {
+22 -4
View File
@@ -7,14 +7,28 @@ export class ScopeStack {
this.scopes = scopes
}
declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
reserve(name: string, mutable: boolean, node: AstNode): void {
const scope = this.current()
const existing = scope.get(name)
if (existing && existing.initialized !== false) {
if (scope.has(name)) {
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
}
scope.set(name, { mutable, value: undefined, initialized: false })
}
initialize(name: string, value: unknown, node: AstNode): void {
const binding = this.current().get(name)
if (!binding || binding.initialized !== false) {
throw new InterpreterRuntimeError(`Identifier '${name}' has not been reserved for initialization.`, node)
}
binding.value = value
binding.initialized = true
}
declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
const scope = this.current()
if (scope.has(name)) {
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
}
scope.set(name, { mutable, value, initialized: true })
}
@@ -39,6 +53,10 @@ export class ScopeStack {
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
}
if (binding.initialized === false) {
throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError")
}
if (!binding.mutable) {
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError")
}
@@ -0,0 +1,188 @@
/*
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/language/statements/let/global-use-before-initialization-in-prior-statement.js
* - test/language/statements/let/block-local-use-before-initialization-in-prior-statement.js
* - test/language/statements/const/global-use-before-initialization-in-prior-statement.js
* - test/language/statements/const/block-local-use-before-initialization-in-prior-statement.js
* - test/language/statements/let/block-local-use-before-initialization-in-declaration-statement.js
* - test/language/statements/const/block-local-use-before-initialization-in-declaration-statement.js
* - test/language/statements/let/block-local-closure-set-before-initialization.js
* - test/language/statements/for-of/head-let-bound-names-fordecl-tdz.js
* - test/language/statements/for-in/head-let-bound-names-fordecl-tdz.js
* - test/language/statements/let/syntax/let-iteration-variable-is-freshly-allocated-for-each-iteration-single-let-binding.js
* - test/language/statements/let/syntax/let-iteration-variable-is-freshly-allocated-for-each-iteration-multi-let-binding.js
* - test/language/statements/for-of/head-let-fresh-binding-per-iteration.js
* - test/language/statements/for-in/head-let-fresh-binding-per-iteration.js
* - test/language/statements/for/scope-head-lex-open.js
* - test/language/statements/for/scope-body-lex-open.js
* - test/language/statements/switch/scope-lex-open-case.js
* - test/language/statements/switch/scope-lex-close-case.js
* - test/language/statements/function/dflt-params-ref-prior.js
* - test/language/statements/function/dflt-params-ref-later.js
* - test/language/statements/function/dflt-params-ref-self.js
*
* Copyright (C) 2011, 2014, 2016 the V8 project authors. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
describe("Test262 lexical temporal dead zones", () => {
test("program and block bindings exist before initialization", async () => {
expect(
await value(`
const errors = []
try { programLet; let programLet } catch (error) { errors.push(error.name) }
try { programConst; const programConst = 1 } catch (error) { errors.push(error.name) }
try { { blockLet; let blockLet } } catch (error) { errors.push(error.name) }
try { { blockConst; const blockConst = 1 } } catch (error) { errors.push(error.name) }
return errors
`),
).toEqual(["ReferenceError", "ReferenceError", "ReferenceError", "ReferenceError"])
})
test("self-initialization and closure assignment observe the TDZ", async () => {
expect(
await value(`
const errors = []
try { { let item = item + 1 } } catch (error) { errors.push(error.name) }
try { { const item = item + 1 } } catch (error) { errors.push(error.name) }
try {
{
function assign() { item = 1 }
assign()
let item
}
} catch (error) { errors.push(error.name) }
return errors
`),
).toEqual(["ReferenceError", "ReferenceError", "ReferenceError"])
})
test("for-of and for-in bound names are in the head TDZ", async () => {
expect(
await value(`
const errors = []
try { let item = [1]; for (let item of item) {} } catch (error) { errors.push(error.name) }
try { let item = { value: 1 }; for (let item in item) {} } catch (error) { errors.push(error.name) }
return errors
`),
).toEqual(["ReferenceError", "ReferenceError"])
})
})
describe("Test262 lexical loop environments", () => {
test("classic for creates fresh single and multiple bindings", async () => {
expect(
await value(`
const single = []
for (let index = 0; index < 5; ++index) single.push(() => index)
const left = []
const right = []
for (let first = 0, second = 10; first < 5; ++first, ++second) {
left.push(() => first)
right.push(() => second)
}
return [
single.map((read) => read()),
left.map((read) => read()),
right.map((read) => read()),
]
`),
).toEqual([
[0, 1, 2, 3, 4],
[0, 1, 2, 3, 4],
[10, 11, 12, 13, 14],
])
})
test("for-of and for-in create fresh bindings", async () => {
expect(
await value(`
const values = []
for (let item of [1, 2, 3]) values.push(() => item)
const keys = {}
for (let key in { first: 1, second: 2, third: 3 }) keys[key] = () => key
return [values.map((read) => read()), keys.first(), keys.second(), keys.third()]
`),
).toEqual([[1, 2, 3], "first", "second", "third"])
})
test("classic for separates declaration and per-iteration environments", async () => {
expect(
await value(`
let before
let testRead
let bodyRead
let updateRead
let run = true
for (
let item = "outside", ignored = before = () => item;
run && (item = "inside", testRead = () => item);
updateRead = () => item
) bodyRead = () => item, run = false
return [before(), testRead(), bodyRead(), updateRead()]
`),
).toEqual(["outside", "inside", "inside", "inside"])
})
})
describe("Test262 switch and parameter environments", () => {
test("switch creates its lexical environment after the discriminant", async () => {
expect(
await value(`
let item = "outside"
let discriminantRead
let selectorRead
let statementRead
switch ((discriminantRead = () => item, null)) {
case (selectorRead = () => item, null):
statementRead = () => item
let item = "inside"
}
return [discriminantRead(), selectorRead(), statementRead()]
`),
).toEqual(["outside", "inside", "inside"])
})
test("all switch cases share one lexical environment that closes afterward", async () => {
expect(
await value(`
let item = "outside"
let firstRead
let secondRead
switch (null) {
case null:
let item = "inside"
firstRead = () => item
case null:
secondRead = () => item
}
return [firstRead(), secondRead(), item]
`),
).toEqual(["inside", "inside", "outside"])
})
test("parameter defaults see prior bindings but not self or later bindings", async () => {
expect(
await value(`
function prior(first, second = first, third = second) { return [first, second, third] }
function later(first = second, second) { return first }
function self(item = item) { return item }
function failure(run) {
try { return run() } catch (error) { return error.name }
}
return [prior(3), failure(later), failure(self)]
`),
).toEqual([[3, 3, 3], "ReferenceError", "ReferenceError"])
})
})
+63 -10
View File
@@ -99,12 +99,66 @@ describe("H4: typeof on an undeclared identifier is 'undefined'", () => {
})
})
describe("CodeMode lexical scope integration", () => {
test("keeps self, cross, and destructuring defaults in the TDZ", async () => {
expect(
await value(`
const outer = 1
const errors = []
try { const first = second, second = 2 } catch (error) { errors.push(error.name) }
try { const [first = second, second = 2] = [] } catch (error) { errors.push(error.name) }
return errors
`),
).toEqual(["ReferenceError", "ReferenceError"])
})
test("keeps typeof and constant assignment inside the TDZ", async () => {
expect(
await value(`
const errors = []
try { { errors.push(typeof item); let item } } catch (error) { errors.push(error.name) }
try { { constant = 1; const constant = 2 } } catch (error) { errors.push(error.name) }
return errors
`),
).toEqual(["ReferenceError", "ReferenceError"])
})
test("shadows builtins from the start of the program scope", async () => {
expect(
await value(`
let observed
try { observed = typeof Promise } catch (error) { observed = error.name }
const Promise = 1
return observed
`),
).toBe("ReferenceError")
})
test("keeps classic for initializers inside the header TDZ", async () => {
expect(
await value(`
let index = 1
try { for (let index = index; index < 2; index++) {} } catch (error) { return error.name }
`),
).toBe("ReferenceError")
})
test("removes loop scopes when per-iteration initialization fails", async () => {
expect(
await value(`
const value = "outer"
try { for (let [value] of [1]) {} } catch {}
return value
`),
).toBe("outer")
})
})
describe("unary void", () => {
test("evaluates its operand and returns undefined", async () => {
expect(await value(`let count = 0; const result = void (count += 1); return [count, result === undefined]`)).toEqual([
1,
true,
])
expect(
await value(`let count = 0; const result = void (count += 1); return [count, result === undefined]`),
).toEqual([1, true])
})
test("discards opaque values", async () => {
@@ -137,12 +191,11 @@ describe("property deletion", () => {
})
test("deleting an array index creates a hole without changing its length", async () => {
expect(await value(`const values = [1, 2, 3]; const removed = delete values[1]; return [removed, values.length, 1 in values, values]`)).toEqual([
true,
3,
false,
[1, null, 3],
])
expect(
await value(
`const values = [1, 2, 3]; const removed = delete values[1]; return [removed, values.length, 1 in values, values]`,
),
).toEqual([true, 3, false, [1, null, 3]])
})
test("array length is not configurable", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.18.2",
"version": "1.18.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.18.2",
"version": "1.18.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.2",
"version": "1.18.3",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
-1
View File
@@ -9,7 +9,6 @@ export class Keep extends Schema.Class<Keep>("ConfigV2.Compaction.Keep")({
export class Info extends Schema.Class<Info>("ConfigV2.Compaction")({
auto: Schema.Boolean.pipe(Schema.optional),
prune: Schema.Boolean.pipe(Schema.optional),
keep: Keep.pipe(Schema.optional),
buffer: NonNegativeInt.pipe(Schema.optional),
}) {}
+38 -19
View File
@@ -168,6 +168,12 @@ export interface Interface extends State.Transformable<Draft> {
readonly resolve: (
connection: IntegrationConnection.Info,
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
/** Resolves with a refresh implementation that may not be materialized in the registry yet. */
readonly resolveWithRefresh: (
connection: IntegrationConnection.Info,
methodID: MethodID,
refresh: NonNullable<OAuthImplementation["refresh"]>,
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
/** Runs a key method and stores the resulting credential. */
readonly key: (input: {
/** Integration receiving the credential. */
@@ -656,6 +662,34 @@ const layer = Layer.effect(
return CommandAttempt.make({ attemptID, time })
})
const resolveConnection = Effect.fnUntraced(function* (
connection: IntegrationConnection.Info,
fallback?: {
readonly methodID: MethodID
readonly refresh: NonNullable<OAuthImplementation["refresh"]>
},
) {
if (connection.type === "env") {
const key = process.env[connection.name]
return key ? Credential.Key.make({ type: "key", key }) : undefined
}
const credential = yield* credentials.get(connection.id)
if (!credential) return undefined
if (credential.value.type === "key") return credential.value
const implementation = state
.get()
.integrations.get(credential.integrationID)
?.implementations.get(credential.value.methodID)
const refresh =
implementation?.refresh ?? (fallback?.methodID === credential.value.methodID ? fallback.refresh : undefined)
if (!refresh) return credential.value
const now = yield* Clock.currentTimeMillis
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
const value = yield* authorize(refresh(credential.value))
yield* credentials.update(credential.id, { value })
return value
})
return Service.of({
transform: state.transform,
reload: state.reload,
@@ -675,25 +709,10 @@ const layer = Layer.effect(
const entry = state.get().integrations.get(id)
return resolveConnections(entry, yield* credentials.list(id))[0]
}),
resolve: Effect.fn("Integration.connection.resolve")(function* (connection) {
if (connection.type === "env") {
const key = process.env[connection.name]
return key ? Credential.Key.make({ type: "key", key }) : undefined
}
const credential = yield* credentials.get(connection.id)
if (!credential) return undefined
if (credential.value.type === "key") return credential.value
const implementation = state
.get()
.integrations.get(credential.integrationID)
?.implementations.get(credential.value.methodID)
if (!implementation?.refresh) return credential.value
const now = yield* Clock.currentTimeMillis
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
const value = yield* authorize(implementation.refresh(credential.value))
yield* credentials.update(credential.id, { value })
return value
}),
resolve: Effect.fn("Integration.connection.resolve")((connection) => resolveConnection(connection)),
resolveWithRefresh: Effect.fn("Integration.connection.resolveWithRefresh")((connection, methodID, refresh) =>
resolveConnection(connection, { methodID, refresh }),
),
key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state
.get()
+1 -1
View File
@@ -176,6 +176,7 @@ export const OpenAIPlugin = define({
draft.method.update(browser)
draft.method.update(headless)
})
yield* load()
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (!ProviderV2.isAISDK(item.provider.package)) continue
@@ -213,7 +214,6 @@ export const OpenAIPlugin = define({
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh().pipe(Effect.forkScoped)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
+29 -8
View File
@@ -2,7 +2,7 @@ import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
import type { CredentialOAuth, CredentialValue } from "@opencode-ai/sdk/v2/types"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { EventV2 } from "../../event"
import { Credential } from "../../credential"
@@ -79,24 +79,40 @@ function oauth(http: HttpClient.HttpClient) {
label: (credential) => {
return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined
},
} satisfies IntegrationOAuthMethodRegistration
} satisfies Integration.OAuthImplementation
}
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Integration.Service | Scope.Scope>({
id: "opencode.provider.opencode",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const http = yield* HttpClient.HttpClient
const integrations = yield* Integration.Service
const method = oauth(http)
const coreCredential = (credential: CredentialOAuth) =>
Credential.OAuth.make({ ...credential, methodID: Integration.MethodID.make(credential.methodID) })
const registration = {
...method,
refresh: (credential: CredentialOAuth) => method.refresh(coreCredential(credential)),
label: (credential: CredentialOAuth) => method.label(coreCredential(credential)),
} satisfies IntegrationOAuthMethodRegistration
const loading = Semaphore.makeUnsafe(1)
let connected = false
let remoteConfigRequired = false
let providers: typeof ConfigV1.Info.Type.provider | undefined
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
const connection = yield* integrations.connection.active(Integration.ID.make("opencode"))
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
? yield* integrations.connection
.resolveWithRefresh(connection, method.method.id, method.refresh)
.pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
connected = connection !== undefined
// Console credentials require its provider overlay; only legacy Zen `sk-` keys may use the models.dev fallback.
remoteConfigRequired =
connection !== undefined &&
(credential === undefined || credential.type === "oauth" || credential.key.startsWith("oc_sk_"))
providers = credential
? yield* fetchProviders(http, credential).pipe(
Effect.catch((cause) =>
@@ -110,11 +126,11 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
draft.update("opencode", (integration) => {
integration.name = "OpenCode"
})
draft.method.update(oauth(http))
draft.method.update(registration)
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
})
connected = (yield* ctx.integration.connection.active("opencode")) !== undefined
yield* load()
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) {
catalog.provider.update(providerID, (provider) => {
@@ -175,6 +191,12 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
const item = catalog.provider.get(ProviderV2.ID.opencode)
if (!item) return
if (remoteConfigRequired && providers?.[ProviderV2.ID.opencode] === undefined) {
catalog.provider.update(item.provider.id, (provider) => {
provider.disabled = true
})
return
}
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
catalog.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" }
@@ -194,7 +216,6 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
}),
})
+5 -6
View File
@@ -210,12 +210,11 @@ export interface Interface {
*/
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
/**
* Durable, ordered session log read. Replays public durable
* session events after the exclusive `after` cursor, emits a `Synced`
* marker at the captured replay watermark, then continues live when `follow`
* is set.
* The marker's seq may exceed the last emitted event because non-public
* durable events share the aggregate's sequence space.
* Durable, ordered session log read. Replays durable session events after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
* replay watermark, then continues live when `follow` is set.
* The marker's seq may exceed the last emitted event because other durable
* events share the aggregate's sequence space.
*/
readonly log: (input: {
sessionID: SessionSchema.ID
+33 -8
View File
@@ -14,6 +14,8 @@ import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error"
import { Token } from "../util/token"
import type { ModelV2 } from "../model"
import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 8_000
@@ -70,6 +72,7 @@ export type AutoInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly model: Model
readonly cost: ModelV2.Info["cost"]
}
export type ManualInput = {
@@ -81,6 +84,7 @@ export type ManualInput = {
type Plan = {
readonly session: SessionSchema.Info
readonly model: Model
readonly cost: ModelV2.Info["cost"]
readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string
readonly recent: string
@@ -240,6 +244,16 @@ const make = (dependencies: Dependencies) => {
const chunks: string[] = []
let failure: SessionError.Error | undefined
let usage: SessionUsage.Recorded | undefined
const recordUsage = Effect.suspend(() =>
usage
? dependencies.events.publish(SessionEvent.UsageRecorded, {
sessionID: plan.session.id,
source: "compaction",
...usage,
})
: Effect.void,
)
yield* dependencies.llm
.stream(
LLM.request({
@@ -263,6 +277,10 @@ const make = (dependencies: Dependencies) => {
text: event.text,
})
}
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, plan.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
}),
Effect.catchTag("LLM.Error", (error) =>
@@ -271,16 +289,21 @@ const make = (dependencies: Dependencies) => {
}),
),
Effect.onInterrupt(() =>
plan.reason === "auto"
? failed({
sessionID: plan.session.id,
reason: plan.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: plan.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
recordUsage.pipe(
Effect.andThen(
plan.reason === "auto"
? failed({
sessionID: plan.session.id,
reason: plan.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: plan.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
),
),
),
)
yield* recordUsage
const summary = chunks.join("")
if (failure || !summary.trim()) {
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
@@ -305,6 +328,7 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
model: input.model,
cost: input.cost,
reason: "auto",
...content,
})
@@ -353,6 +377,7 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
model: resolved.model,
cost: resolved.cost,
reason: "manual",
inputID: input.inputID,
...content,
@@ -142,6 +142,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return Effect.gen(function* () {
yield* SessionEvent.All.match(event, {
"session.usage.updated": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return adapter.appendMessage(
SessionMessage.AgentSelected.make({
+2 -1
View File
@@ -609,6 +609,7 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* events.project(SessionEvent.InputPromoted, (event) =>
Effect.gen(function* () {
@@ -781,7 +782,7 @@ const layer = Layer.effectDiscard(
yield* InstructionState.reset(db, event.data.sessionID)
}),
)
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe(
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed, SessionEvent.UsageRecorded]).pipe(
Stream.runForEach((event) => {
if (
event.type === SessionEvent.Step.Failed.type &&
+5 -29
View File
@@ -2,11 +2,9 @@ export * as SessionRunnerLLM from "./llm"
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission"
import { QuestionTool } from "../../tool/question"
import { ToolOutputStore } from "../../tool-output-store"
@@ -28,30 +26,7 @@ import { llmClient } from "../../effect/app-node-platform"
import { StepFailedError } from "../error"
import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry"
type StepTokens = {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) {
const context = tokens.input + tokens.cache.read + tokens.cache.write
const tier = costs
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
if (!cost) return Money.USD.zero
return Money.USD.make(
(tokens.input * cost.input +
(tokens.output + tokens.reasoning) * cost.output +
tokens.cache.read * cost.cache.read +
tokens.cache.write * cost.cache.write) /
1_000_000,
)
}
import { SessionUsage } from "../usage"
const layer = Layer.effect(
Service,
@@ -127,7 +102,7 @@ const layer = Layer.effect(
const agent = loaded.agent
const resolved = loaded.model
const model = resolved.model
const compactionInput = { session, messages: loaded.messages, model }
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
@@ -216,7 +191,7 @@ const layer = Layer.effect(
)
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
cost: calculateCost(resolved.cost, settlement.tokens),
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
tokens: settlement.tokens,
})
@@ -258,7 +233,8 @@ const layer = Layer.effect(
recoverOverflow &&
!publisher.hasRetryEvidence() &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(recoverOverflow({ session, messages: loaded.messages, model }))).status === "completed"
(yield* restore(recoverOverflow({ session, messages: loaded.messages, model, cost: resolved.cost })))
.status === "completed"
)
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
@@ -1,4 +1,4 @@
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/ai"
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Effect } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
@@ -9,6 +9,7 @@ import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { AgentV2 } from "../../agent"
import { Snapshot } from "../../snapshot"
import { SessionUsage } from "../usage"
type Input = {
readonly sessionID: SessionSchema.ID
@@ -19,20 +20,6 @@ type Input = {
readonly assistantMessageID?: SessionMessage.ID
}
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
const tokens = (usage: Usage | undefined) => {
const reasoning = safe(usage?.reasoningTokens)
const read = safe(usage?.cacheReadInputTokens)
const write = safe(usage?.cacheWriteInputTokens)
return {
input: safe(usage?.nonCachedInputTokens),
output: safe(usage?.visibleOutputTokens),
reasoning,
cache: { read, write },
}
}
const record = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
@@ -77,7 +64,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
let stepSettlement:
| {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
readonly tokens: ReturnType<typeof tokens>
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
| undefined
@@ -251,7 +238,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const publishStepFailure = Effect.fnUntraced(function* (usage?: {
readonly cost: Money.USD
readonly tokens: ReturnType<typeof tokens>
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}) {
if (stepFailed || stepFailure === undefined) return
const assistantMessageID = yield* startAssistant()
@@ -429,7 +416,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
case "step-finish":
yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
stepSettlement = { finish: event.reason, tokens: SessionUsage.tokens(event.usage) }
if (event.reason === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)
+18 -1
View File
@@ -1,7 +1,7 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { Context, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "../agent"
import { Database } from "../database/database"
import { EventV2 } from "../event"
@@ -12,6 +12,7 @@ import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { SessionUsage } from "./usage"
const MAX_LENGTH = 100
@@ -51,6 +52,16 @@ const make = (dependencies: Dependencies) => {
if (!resolved) return
const chunks: string[] = []
let failed = false
let usage: SessionUsage.Recorded | undefined
const recordUsage = Effect.suspend(() =>
usage
? dependencies.events.publish(SessionEvent.UsageRecorded, {
sessionID: session.id,
source: "title",
...usage,
})
: Effect.void,
)
const streamed = yield* dependencies.llm
.stream(
LLM.request({
@@ -65,11 +76,17 @@ const make = (dependencies: Dependencies) => {
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, resolved.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
}),
Effect.as(true),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
)
yield* recordUsage
if (!streamed || failed) return
const title = chunks
.join("")
+55
View File
@@ -0,0 +1,55 @@
export * as SessionUsage from "./usage"
import type { Usage } from "@opencode-ai/ai"
import { Money } from "@opencode-ai/schema/money"
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
import type { ModelV2 } from "../model"
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
input: safe(usage?.nonCachedInputTokens),
output: safe(usage?.visibleOutputTokens),
reasoning: safe(usage?.reasoningTokens),
cache: {
read: safe(usage?.cacheReadInputTokens),
write: safe(usage?.cacheWriteInputTokens),
},
})
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
export function calculateCost(costs: ModelV2.Info["cost"], usage: TokenUsage.Info) {
const context = usage.input + usage.cache.read + usage.cache.write
const tier = costs
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
if (!cost) return Money.USD.zero
return Money.USD.make(
(usage.input * cost.input +
(usage.output + usage.reasoning) * cost.output +
usage.cache.read * cost.cache.read +
usage.cache.write * cost.cache.write) /
1_000_000,
)
}
export type Recorded = { readonly tokens: TokenUsage.Info; readonly cost: Money.USD }
export const record = (usage: Usage | undefined, costs: ModelV2.Info["cost"]): Recorded => {
const normalized = tokens(usage)
return { tokens: normalized, cost: calculateCost(costs, normalized) }
}
export const add = (a: Recorded, b: Recorded): Recorded => ({
cost: Money.USD.make(a.cost + b.cost),
tokens: {
input: a.tokens.input + b.tokens.input,
output: a.tokens.output + b.tokens.output,
reasoning: a.tokens.reasoning + b.tokens.reasoning,
cache: {
read: a.tokens.cache.read + b.tokens.cache.read,
write: a.tokens.cache.write + b.tokens.cache.write,
},
},
})
-2
View File
@@ -754,7 +754,6 @@ describe("Config", () => {
})
expect(documents[0]?.info.compaction).toEqual({
auto: true,
prune: false,
keep: { tokens: 2000 },
buffer: 10000,
})
@@ -968,7 +967,6 @@ describe("Config", () => {
})
expect(documents[0]?.info.compaction).toEqual({
auto: true,
prune: undefined,
keep: { tokens: 2000 },
buffer: 10000,
})
+1
View File
@@ -188,6 +188,7 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe
connection: {
active: unusedIntegration,
resolve: unusedIntegration,
resolveWithRefresh: unusedIntegration,
key: unusedIntegration,
update: unusedIntegration,
remove: unusedIntegration,
@@ -29,20 +29,6 @@ function required<T>(value: T | undefined): T {
return value
}
function eventually<A>(
effect: Effect.Effect<A>,
predicate: (value: A) => boolean,
remaining = 1000,
): Effect.Effect<A, Error> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* eventually(effect, predicate, remaining - 1)
})
}
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
@@ -219,12 +205,8 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const eligible = required(
yield* eventually(
catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")),
(model) => model?.cost.length === 0,
),
)
const eligible = required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")))
expect(eligible.cost).toEqual([])
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe(
false,
@@ -260,9 +242,6 @@ describe("OpenAIPlugin", () => {
value: Credential.Key.make({ type: "key", key: "sk-test" }),
})
yield* addPlugin()
// The connection refresh is asynchronous; give it time to settle before
// asserting nothing was filtered.
yield* Effect.promise(() => Bun.sleep(25))
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true)
@@ -10,6 +10,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -20,9 +21,11 @@ const addPlugin = Effect.fn(function* () {
const host = yield* PluginHost.make(plugin)
const events = yield* EventV2.Service
const integration = yield* Integration.Service
yield* OpencodePlugin.effect(host).pipe(
Effect.provideService(EventV2.Service, events),
Effect.provideService(Integration.Service, integration),
yield* State.batch(
OpencodePlugin.effect(host).pipe(
Effect.provideService(EventV2.Service, events),
Effect.provideService(Integration.Service, integration),
),
)
})
@@ -77,6 +80,50 @@ const cost = (input: number, output = 0) => [
},
]
const fableID = ModelV2.ID.make("claude-fable-5")
const seedFallback = Effect.fnUntraced(function* () {
const catalog = yield* Catalog.Service
const provider = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
integrationID: Integration.ID.make("opencode"),
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://opencode.ai/zen/v1" },
})
const model = ModelV2.Info.make({
...ModelV2.Info.empty(provider.id, fableID),
modelID: fableID,
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
cost: cost(1, 1),
})
yield* catalog.transform((draft) => {
draft.provider.update(provider.id, (current) => Object.assign(current, provider))
draft.model.update(provider.id, model.id, (current) => Object.assign(current, model))
})
})
const remoteConfig = (origin: string) => ({
config: {
enterprise: { url: origin },
provider: {
opencode: {
name: "OpenCode",
npm: "@ai-sdk/openai-compatible",
api: `${origin}/inference/openai/v1`,
env: ["OPENCODE_API_KEY"],
options: { apiKey: "{env:OPENCODE_API_KEY}" },
models: {
"claude-fable-5": {
name: "Claude Fable 5",
provider: { npm: "@ai-sdk/anthropic", api: `${origin}/inference/anthropic/v1` },
cost: { input: 1, output: 1 },
},
},
},
},
},
})
describe("OpencodePlugin", () => {
it.effect("registers account and service account methods", () =>
Effect.gen(function* () {
@@ -283,6 +330,176 @@ describe("OpencodePlugin", () => {
),
)
it.live("refreshes expired OAuth before loading the Console catalog", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: Array<{ path: string; authorization: string | null }> = []
const server = Bun.serve({
port: 0,
fetch: (request) => {
const path = new URL(request.url).pathname
requests.push({ path, authorization: request.headers.get("authorization") })
if (path === "/auth/device/token")
return Response.json({
access_token: "fresh-access",
refresh_token: "fresh-refresh",
expires_in: 3600,
})
if (path === "/api/config") {
if (request.headers.get("authorization") !== "Bearer fresh-access")
return new Response("Unauthorized", { status: 401 })
return Response.json(remoteConfig(new URL(request.url).origin))
}
return new Response("Not found", { status: 404 })
},
})
return { requests, server }
}),
({ requests, server }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* seedFallback()
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "expired-access",
refresh: "expired-refresh",
expires: 1,
metadata: { server: server.url.origin },
}),
})
yield* addPlugin()
expect(requests).toEqual([
{ path: "/auth/device/token", authorization: null },
{ path: "/api/config", authorization: "Bearer fresh-access" },
])
expect((yield* credentials.list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
type: "oauth",
access: "fresh-access",
refresh: "fresh-refresh",
})
expect(
required(yield* (yield* Catalog.Service).model.get(ProviderV2.ID.opencode, fableID)).settings?.baseURL,
).toBe(`${server.url.origin}/inference/anthropic/v1`)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("does not expose the legacy fallback when Console OAuth config is unavailable", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: () => new Response("Unavailable", { status: 503 }),
}),
),
(server) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* seedFallback()
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "access",
refresh: "refresh",
expires: Date.now() + 60 * 60 * 1000,
metadata: { server: server.url.origin },
}),
})
yield* addPlugin()
expect((yield* catalog.provider.available()).some((provider) => provider.id === ProviderV2.ID.opencode)).toBe(
false,
)
expect(
(yield* catalog.model.available()).some(
(model) => model.providerID === ProviderV2.ID.opencode && model.id === fableID,
),
).toBe(false)
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.live("does not expose the legacy fallback when Console service-account config is unavailable", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: () => new Response("Unavailable", { status: 503 }),
}),
),
(server) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* seedFallback()
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "oc_sk_0123456789ab_secret",
metadata: { server: server.url.origin },
}),
})
yield* addPlugin()
expect((yield* catalog.provider.available()).some((provider) => provider.id === ProviderV2.ID.opencode)).toBe(
false,
)
expect(
(yield* catalog.model.available()).some(
(model) => model.providerID === ProviderV2.ID.opencode && model.id === fableID,
),
).toBe(false)
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.live("keeps the legacy fallback for an API key when remote provider config is unavailable", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: () => new Response("Unauthorized", { status: 401 }),
}),
),
(server) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* seedFallback()
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "sk-legacy-key",
metadata: { server: server.url.origin },
}),
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).disabled).toBeUndefined()
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, fableID)).settings?.baseURL).toBe(
"https://opencode.ai/zen/v1",
)
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.effect("uses a public key and disables paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
+35 -2
View File
@@ -21,6 +21,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -31,17 +32,44 @@ const model = Model.make({
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
})
const cost = [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: {
read: Money.USDPerMillionTokens.make(0.1),
write: Money.USDPerMillionTokens.make(0.5),
},
},
]
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual summary" }))
return Stream.make(
LLMEvent.textDelta({ id: "summary", text: "manual summary" }),
LLMEvent.stepFinish({
index: 0,
reason: "stop",
usage: {
inputTokens: 15,
outputTokens: 6,
nonCachedInputTokens: 10,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
reasoningTokens: 2,
},
}),
LLMEvent.finish({
reason: "stop",
}),
)
},
generate: () => Effect.die("unused"),
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
})
const it = testEffect(
AppNodeBuilder.build(
@@ -169,6 +197,10 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
])
expect(yield* store.get(sessionID)).toMatchObject({
cost: 0.0000233,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } },
})
expect(
yield* db
.select({ type: EventTable.type })
@@ -179,6 +211,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
.pipe(Effect.orDie),
).toEqual([
{ type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) },
{ type: EventV2.versionedType(SessionEvent.UsageRecorded.type, 1) },
{ type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1) },
])
}),
+3 -2
View File
@@ -42,6 +42,7 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator
import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionUsage } from "@opencode-ai/core/session/usage"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
@@ -165,7 +166,7 @@ const recoveryModel = Model.make({
test("calculates step cost using the matching context tier", () => {
expect(
SessionRunnerLLM.calculateCost(
SessionUsage.calculateCost(
[
{
input: Money.USDPerMillionTokens.make(1),
@@ -192,7 +193,7 @@ test("calculates step cost using the matching context tier", () => {
test("does not apply an ineligible tier without base pricing", () => {
expect(
SessionRunnerLLM.calculateCost(
SessionUsage.calculateCost(
[
{
tier: { type: "context", size: 100 },
+33 -3
View File
@@ -20,7 +20,8 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { DateTime, Effect, Layer, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
let requests: LLMRequest[] = []
@@ -29,16 +30,43 @@ const model = Model.make({
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
})
const cost = [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: {
read: Money.USDPerMillionTokens.make(0.1),
write: Money.USDPerMillionTokens.make(0.5),
},
},
]
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }))
return Stream.make(
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
LLMEvent.stepFinish({
index: 0,
reason: "stop",
usage: {
inputTokens: 15,
outputTokens: 6,
nonCachedInputTokens: 10,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
reasoningTokens: 2,
},
}),
LLMEvent.finish({
reason: "stop",
}),
)
},
generate: () => Effect.die("unused"),
})
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
})
const it = testEffect(
AppNodeBuilder.build(
@@ -130,6 +158,8 @@ it.effect("generates a title from the sole user message and renames the session"
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
const renamed = yield* store.get(sessionID)
expect(renamed?.title).toBe("Generated Title")
expect(renamed?.tokens).toEqual({ input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } })
expect(renamed?.cost).toBeCloseTo(0.0000233)
}),
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+2 -1
View File
@@ -382,7 +382,8 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
function App() {
const wslServers = useWslServers()
const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
() =>
!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading && !wslServers.isLoading,
)
const servers = createMemo(() => {
const data = initializationData(sidecar)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.2",
"version": "1.18.3",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.2",
"version": "1.18.3",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.18.2",
"version": "1.18.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.18.2",
"version": "1.18.3",
"$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": "1.18.2",
"version": "1.18.3",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.2",
"version": "1.18.3",
"name": "opencode",
"type": "module",
"license": "MIT",
+4 -2
View File
@@ -277,7 +277,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
},
}
}),
"azure-cognitive-services": Effect.fnUntraced(function* () {
"azure-cognitive-services": Effect.fnUntraced(function* (provider: Info) {
const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
return {
autoload: false,
@@ -285,7 +285,9 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
},
options: {
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
baseURL: resourceName
? `https://${resourceName}.cognitiveservices.azure.com/openai${provider.options?.useDeploymentBasedUrls ? "" : "/v1"}`
: undefined,
},
}
}),
@@ -1,11 +1,10 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import {
RUN_COMMAND_PANEL_ROWS,
RUN_SUBAGENT_PANEL_ROWS,
@@ -174,15 +173,9 @@ async function renderFooter(
)
const state = footerState(input.state)
const config = input.tuiConfig ?? tuiConfig
let offKeymap: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
return (
<OpencodeKeymapProvider keymap={keymap}>
<Keymap.Provider config={config}>
<RunFooterView
directory="/tmp"
findFiles={async () => []}
@@ -215,7 +208,7 @@ async function renderFooter(
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</OpencodeKeymapProvider>
</Keymap.Provider>
)
}
@@ -233,8 +226,6 @@ async function renderFooter(
cleanup() {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
offKeymap = undefined
app.renderer.destroy()
},
}
@@ -641,6 +632,42 @@ test("direct subagent panel renders active subagents", async () => {
}
})
test("direct subagent panel closes when moving up from the first item", async () => {
const [tabs] = createSignal([
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
subagent({ sessionID: "s-2", label: "General", description: "Write migration plan" }),
])
const [current] = createSignal<string | undefined>()
let closed = 0
const app = await testRender(
() => (
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
<RunSubagentSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
tabs={tabs}
current={current}
onClose={() => closed++}
onSelect={() => {}}
/>
</box>
),
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
)
try {
await app.renderOnce()
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_UP")
expect(closed).toBe(0)
app.mockInput.pressKey("ARROW_UP")
expect(closed).toBe(1)
} finally {
app.renderer.destroy()
}
})
test("direct queued prompt panel renders pending prompt actions", async () => {
const [prompts] = createSignal([
{ messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } },
@@ -967,14 +994,9 @@ test("direct footer shows editable prompts and additional queued work while runn
permissions: [],
questions: [],
})
let offKeymap: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
return (
<OpencodeKeymapProvider keymap={keymap}>
<Keymap.Provider config={tuiConfig}>
<RunFooterView
directory="/tmp"
findFiles={async () => []}
@@ -1013,7 +1035,7 @@ test("direct footer shows editable prompts and additional queued work while runn
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</OpencodeKeymapProvider>
</Keymap.Provider>
)
}
@@ -1049,7 +1071,7 @@ test("direct footer shows editable prompts and additional queued work while runn
expect(frame).toContain("3 queued")
expect(frame).toContain("ctrl+b background")
expect(frame).toContain("ctrl+x q 3 queued")
expect(frame).toContain("ctrl+x down subagents")
expect(frame).toContain(" subagents")
expect(frame).toContain("ctrl+p cmd")
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
expect(frame).toContain("subagents · ctrl+p cmd")
@@ -1063,7 +1085,6 @@ test("direct footer shows editable prompts and additional queued work while runn
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
app.renderer.destroy()
}
})
@@ -1087,7 +1108,7 @@ test("direct footer always offers backgrounding for a foreground subagent", asyn
const frame = app.captureCharFrame()
expect(frame).toContain("GPT-5")
expect(frame).toContain("xhigh · ctrl+b background · ctrl+x down subagents · ctrl+p cmd")
expect(frame).toContain("xhigh · ctrl+b background · subagents · ctrl+p cmd")
expect(frame).toContain("ctrl+b background")
expect(frame).not.toContain("queued")
} finally {
@@ -1115,7 +1136,7 @@ test("direct footer hides the subagent hint when only completed subagents remain
expect(frame).toContain("GPT-5")
expect(frame).toContain("xhigh · ctrl+p cmd")
expect(frame).not.toContain("ctrl+x down subagents")
expect(frame).not.toContain(" subagents")
} finally {
app.cleanup()
}
@@ -1233,15 +1254,9 @@ test.skip("direct custom answer submits through keymap return binding", async ()
],
} satisfies QuestionRequest
const questions: unknown[] = []
let off: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
return (
<OpencodeKeymapProvider keymap={keymap}>
<Keymap.Provider config={tuiConfig}>
<RunQuestionBody
request={question}
theme={RUN_THEME_FALLBACK.footer}
@@ -1250,7 +1265,7 @@ test.skip("direct custom answer submits through keymap return binding", async ()
}}
onReject={() => {}}
/>
</OpencodeKeymapProvider>
</Keymap.Provider>
)
}
@@ -1275,7 +1290,6 @@ test.skip("direct custom answer submits through keymap return binding", async ()
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
off?.()
app.renderer.destroy()
}
})
@@ -1283,15 +1297,9 @@ test.skip("direct custom answer submits through keymap return binding", async ()
test("direct permission rejection submits through keymap return binding", async () => {
let text = ""
const submits: string[] = []
let off: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
return (
<OpencodeKeymapProvider keymap={keymap}>
<Keymap.Provider config={tuiConfig}>
<RejectField
theme={RUN_THEME_FALLBACK.footer}
text=""
@@ -1304,7 +1312,7 @@ test("direct permission rejection submits through keymap return binding", async
}}
onCancel={() => {}}
/>
</OpencodeKeymapProvider>
</Keymap.Provider>
)
}
@@ -1328,7 +1336,6 @@ test("direct permission rejection submits through keymap return binding", async
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
off?.()
app.renderer.destroy()
}
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"scripts": {
+3 -3
View File
@@ -19,7 +19,7 @@ import type {
ShellInfo,
SkillInfo,
} from "@opencode-ai/client"
import type { Renderable } from "@opentui/core"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { JSX } from "@opentui/solid"
interface LocationCollection<Value> {
@@ -139,8 +139,8 @@ export interface KeymapCommand {
}
/** Promotes the command in discovery UI. */
readonly suggested?: boolean | (() => boolean)
/** Executes the command. Return false to let keymap dispatch continue. */
readonly run: (input?: string) => void | false | Promise<void>
/** Executes the command. Keyboard dispatch includes its event; programmatic dispatch does not. Return false to continue. */
readonly run: (input?: string, event?: KeyEvent) => void | false | Promise<void>
}
export interface KeymapLayer {
+17 -1
View File
@@ -86,6 +86,18 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const UsageRecorded = Event.durable({
type: "session.usage.recorded",
...options,
schema: {
...Base,
source: Schema.Literals(["title", "compaction"]),
cost: Money.USD,
tokens: TokenUsage.Info,
},
})
export type UsageRecorded = typeof UsageRecorded.Type
export const UsageUpdated = Event.ephemeral({
type: "session.usage.updated",
schema: {
@@ -569,8 +581,10 @@ export const Definitions = Event.inventory(
RevertEvent.Committed,
)
// UsageRecorded is durable but internal: excluded from Definitions so it never reaches the public manifest.
export const DurableDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "durable"),
UsageRecorded,
)
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
@@ -578,6 +592,8 @@ export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
.annotate({ identifier: "Session.Event.Durable" })
export type DurableEvent = typeof Durable.Type
export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe(
Schema.toTaggedUnion("type"),
)
export type Event = typeof All.Type
export type Type = Event["type"]
+11 -3
View File
@@ -104,6 +104,7 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.usage.recorded.1",
"session.forked.2",
"session.input.promoted.1",
"session.input.admitted.1",
@@ -139,9 +140,16 @@ describe("public event manifest", () => {
"session.revert.committed.1",
].toSorted(),
)
expect(SessionEvent.DurableDefinitions).toEqual(
SessionEvent.Definitions.filter((definition) => definition.durability === "durable"),
)
expect(SessionEvent.DurableDefinitions).toEqual([
...SessionEvent.Definitions.filter((definition) => definition.durability === "durable"),
SessionEvent.UsageRecorded,
])
expect(SessionEvent.UsageRecorded.durability).toBe("durable")
expect(EventManifest.Durable.get("session.usage.recorded.1")).toBe(SessionEvent.UsageRecorded)
expect(SessionEvent.Definitions).not.toContain(SessionEvent.UsageRecorded)
expect(EventManifest.Definitions).not.toContain(SessionEvent.UsageRecorded)
expect(EventManifest.ServerDefinitions).not.toContain(SessionEvent.UsageRecorded)
expect(EventManifest.Latest.has("session.usage.recorded")).toBe(false)
expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral")
expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral")
expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.18.2",
"version": "1.18.3",
"private": true,
"type": "module",
"license": "MIT",
+5 -1
View File
@@ -10,6 +10,7 @@ import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerRe
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { authorizedRequest } from "./middleware/authorization"
import { withoutParentSpan } from "./request-tracing"
import { createRoutes } from "./routes"
import { ServerInfo } from "./server-info"
import { Status } from "./service-status"
@@ -39,7 +40,10 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
})
const bound = yield* listen(options)
const application = yield* Ref.make(Option.none<App>())
yield* bound.http.serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger)
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
yield* bound.http
.serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger)
.pipe(withoutParentSpan)
if (options.service) yield* options.service.onListen(bound.http.address)
const parentScope = yield* Scope.Scope
+6
View File
@@ -0,0 +1,6 @@
import { Context, Effect, Scope, Tracer } from "effect"
export const withoutParentSpan = <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) =>
effect.pipe(
Effect.updateContext((context: Context.Context<Scope.Scope>) => Context.omit(Tracer.ParentSpan)(context)),
)
@@ -0,0 +1,48 @@
import { expect, test } from "bun:test"
import { Effect, Option, Scope, Tracer } from "effect"
import { HttpMiddleware, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { withoutParentSpan } from "../src/request-tracing"
test("requests ignore ambient parents and continue inbound trace context", async () => {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const app = Effect.gen(function* () {
yield* Scope.Scope
return yield* HttpMiddleware.tracer(Effect.succeed(HttpServerResponse.empty()))
})
const request = (traceparent?: string) =>
app.pipe(
Effect.provideService(
HttpServerRequest.HttpServerRequest,
HttpServerRequest.fromWeb(
new Request("http://localhost/trace", {
headers: traceparent === undefined ? undefined : { traceparent },
}),
),
),
withoutParentSpan,
)
await Effect.runPromise(
Effect.gen(function* () {
yield* request()
yield* request()
yield* request("00-11111111111111111111111111111111-2222222222222222-01")
yield* Effect.yieldNow
}).pipe(Effect.withSpan("fixture.lifecycle"), Effect.provideService(Tracer.Tracer, tracer), Effect.scoped),
)
const requests = spans.filter((span) => span.kind === "server")
expect(requests).toHaveLength(3)
expect(requests[0]?.traceId).not.toBe(requests[1]?.traceId)
expect(Option.getOrUndefined(requests[0]?.parent ?? Option.none())).toBeUndefined()
expect(Option.getOrUndefined(requests[1]?.parent ?? Option.none())).toBeUndefined()
expect(requests[2]?.traceId).toBe("11111111111111111111111111111111")
expect(Option.getOrUndefined(requests[2]?.parent ?? Option.none())?.spanId).toBe("2222222222222222")
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.18.2",
"version": "1.18.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"scripts": {

Some files were not shown because too many files have changed in this diff Show More