Compare commits

..

1 Commits

Author SHA1 Message Date
James Long 4778e6e031 fix(core): stream shell progress tail 2026-07-16 20:25:00 +00:00
146 changed files with 1963 additions and 2682 deletions
+27 -27
View File
@@ -53,7 +53,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@corvu/drawer": "catalog:", "@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0", "@dnd-kit/abstract": "0.5.0",
@@ -119,7 +119,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.18.3", "version": "1.18.2",
"bin": { "bin": {
"opencode2": "./bin/opencode2.cjs", "opencode2": "./bin/opencode2.cjs",
}, },
@@ -179,7 +179,7 @@
}, },
"packages/codemode": { "packages/codemode": {
"name": "@opencode-ai/codemode", "name": "@opencode-ai/codemode",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"acorn": "8.15.0", "acorn": "8.15.0",
"effect": "catalog:", "effect": "catalog:",
@@ -193,7 +193,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -229,7 +229,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -256,7 +256,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.82", "@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -278,7 +278,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -302,7 +302,7 @@
}, },
"packages/console/support": { "packages/console/support": {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
@@ -322,7 +322,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.18.3", "version": "1.18.2",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -418,7 +418,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"effect": "catalog:", "effect": "catalog:",
@@ -478,7 +478,7 @@
}, },
"packages/effect-drizzle-sqlite": { "packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -492,7 +492,7 @@
}, },
"packages/effect-sqlite-node": { "packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node", "name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -504,7 +504,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@hono/standard-validator": "catalog:", "@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -536,7 +536,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -552,7 +552,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83",
}, },
@@ -584,7 +584,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.18.3", "version": "1.18.2",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -717,7 +717,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/ai": "workspace:*", "@opencode-ai/ai": "workspace:*",
@@ -806,7 +806,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -821,7 +821,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@opencode-ai/server", "name": "@opencode-ai/server",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -838,7 +838,7 @@
}, },
"packages/session-ui": { "packages/session-ui": {
"name": "@opencode-ai/session-ui", "name": "@opencode-ai/session-ui",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -899,7 +899,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -912,7 +912,7 @@
}, },
"packages/stats/app": { "packages/stats/app": {
"name": "@opencode-ai/stats-app", "name": "@opencode-ai/stats-app",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
@@ -946,7 +946,7 @@
}, },
"packages/stats/core": { "packages/stats/core": {
"name": "@opencode-ai/stats-core", "name": "@opencode-ai/stats-core",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@aws-sdk/client-athena": "3.933.0", "@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0", "@planetscale/database": "1.19.0",
@@ -965,7 +965,7 @@
}, },
"packages/stats/server": { "packages/stats/server": {
"name": "@opencode-ai/stats-server", "name": "@opencode-ai/stats-server",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@aws-sdk/client-firehose": "3.933.0", "@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@@ -1006,7 +1006,7 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -1036,7 +1036,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
@@ -1087,7 +1087,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.3", "version": "1.18.2",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+136 -90
View File
@@ -1,18 +1,17 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path" 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 { useDialog } from "@opencode-ai/ui/context/dialog"
import { useNavigate } from "@solidjs/router"
import { createMemo, onCleanup } from "solid-js" import { createMemo, onCleanup } from "solid-js"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout, type LocalProject } from "@/context/layout" import { useLayout } from "@/context/layout"
import { ServerConnection } from "@/context/server" import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync"
import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
export type CommandPaletteEntry = { export type CommandPaletteEntry = {
id: string id: string
@@ -25,8 +24,6 @@ export type CommandPaletteEntry = {
path?: string path?: string
directory?: string directory?: string
sessionID?: string sessionID?: string
server?: ServerConnection.Key
project?: LocalProject
archived?: number archived?: number
updated?: number updated?: number
} }
@@ -78,25 +75,28 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) { export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
const command = useCommand() const command = useCommand()
const global = useGlobal()
const language = useLanguage() const language = useLanguage()
const layout = useLayout()
const file = useFile() const file = useFile()
const dialog = useDialog() const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()() const serverSDK = useServerSDK()()
const serverCtx = global.ensureServerCtx(serverSDK.server) const serverSync = useServerSync()
const appTabs = useTabs() const { params, tabs } = useSessionLayout()
const { tabs: sessionTabs } = useSessionLayout()
const openFile = createCommandPaletteFileOpener(props.onOpenFile) const openFile = createCommandPaletteFileOpener(props.onOpenFile)
const state = { cleanup: undefined as (() => void) | void, committed: false } const state = { cleanup: undefined as (() => void) | void, committed: false }
const filesOnly = () => props.filesOnly?.() ?? false const filesOnly = () => props.filesOnly?.() ?? false
const allowedCommands = createMemo(() => { const allowedCommands = createMemo(() => {
if (filesOnly()) return [] if (filesOnly()) return []
return commandPaletteOptions(command.options) return command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
}) })
const commandEntries = createMemo(() => { const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands") const category = language.t("palette.group.commands")
return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category)) return allowedCommands().map((option) => createCommandEntry(option, category))
}) })
const preferredCommandEntries = createMemo(() => { const preferredCommandEntries = createMemo(() => {
const all = allowedCommands() 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 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 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") const category = language.t("palette.group.commands")
return sorted.map((option) => createCommandPaletteCommandEntry(option, category)) return sorted.map((option) => createCommandEntry(option, category))
}) })
const tabState = createSessionTabs({ const tabState = createSessionTabs({
tabs: sessionTabs, tabs,
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}) })
@@ -140,12 +140,36 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
.map((path) => createCommandPaletteFileEntry(path, category)) .map((path) => createCommandPaletteFileEntry(path, category))
}) })
const sessions = createServerSessionEntries({ const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
server: ServerConnection.key(serverSDK.server), const project = createMemo(() => {
opened: serverCtx.projects.list, const directory = projectDirectory()
stored: () => serverCtx.sync.data.project, if (!directory) return undefined
load: (search, signal) => return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), })
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 }),
untitled: () => language.t("command.session.new"), untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"), category: () => language.t("command.category.session"),
}) })
@@ -167,17 +191,8 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
return return
} }
if (item.type === "session") { if (item.type === "session") {
if (!item.sessionID || !item.server) return if (!item.directory || !item.sessionID) return
const directory = item.project?.worktree ?? item.directory navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
if (directory) {
serverCtx.projects.open(directory)
serverCtx.projects.touch(directory)
}
const tab = appTabs.addSessionTab({
server: item.server,
sessionId: item.sessionID,
})
appTabs.select(tab)
return return
} }
if (!item.path) return if (!item.path) return
@@ -203,7 +218,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
} }
} }
export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
return { return {
id: "command:" + option.id, id: "command:" + option.id,
type: "command", type: "command",
@@ -215,65 +230,96 @@ export function createCommandPaletteCommandEntry(option: CommandOption, category
} }
} }
export function createServerSessionEntries(props: { function createSessionEntries(props: {
server: ServerConnection.Key workspaces: () => string[]
opened: () => LocalProject[] label: (directory: string) => string
stored: () => Project[] load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
untitled: () => string untitled: () => string
category: () => string category: () => string
}) { }) {
let abort: AbortController | undefined const state: {
token: number
inflight: Promise<CommandPaletteEntry[]> | undefined
cached: CommandPaletteEntry[] | undefined
} = { token: 0, inflight: undefined, cached: undefined }
onCleanup(() => abort?.abort()) return (text: string) => {
if (!text.trim()) {
return async (text: string): Promise<CommandPaletteEntry[]> => { state.token += 1
const search = text.trim() state.inflight = undefined
if (!search) { state.cached = undefined
abort?.abort() return [] as CommandPaletteEntry[]
return []
} }
abort?.abort() if (state.cached) return state.cached
const current = new AbortController() if (state.inflight) return state.inflight
abort = current
await new Promise<void>((resolve) => { const current = state.token
const timer = setTimeout(resolve, 100) const dirs = props.workspaces()
current.signal.addEventListener( if (dirs.length === 0) return [] as CommandPaletteEntry[]
"abort",
() => { state.inflight = Promise.all(
clearTimeout(timer) dirs.map((directory) => {
resolve() const description = props.label(directory)
}, return props
{ once: true }, .load(directory)
) .then((result) =>
}) (result.data ?? [])
if (current.signal.aborted) return [] .filter((session) => !!session?.id)
const opened = props.opened() .map((session) => ({
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : []))) id: session.id,
const stored = props.stored().map((project) => ({ ...project, expanded: false })) title: session.title ?? props.untitled(),
const storedByID = new Map(stored.map((project) => [project.id, project] as const)) description,
return props directory,
.load(search, current.signal) archived: session.time?.archived,
.then((result) => updated: session.time?.updated,
(result.data ?? []) })),
.filter((session) => !session.time.archived) )
.map((session) => { .catch(() => [] as SessionEntryInput[])
const project = }),
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID) )
return { .then((results) => {
id: `session:${props.server}:${session.id}`, if (state.token !== current) return [] as CommandPaletteEntry[]
type: "session" as const, const seen = new Set<string>()
title: session.title || props.untitled(), const next = results
description: project ? displayName(project) : session.project?.name || getFilename(session.directory), .flat()
category: props.category(), .filter((item) => {
directory: session.directory, const key = `${item.directory}:${item.id}`
sessionID: session.id, if (seen.has(key)) return false
server: props.server, seen.add(key)
project, return true
updated: session.time.updated, })
} .map((item) => createSessionEntry(item, props.category()))
}), state.cached = next
) return next
})
.catch(() => [] as CommandPaletteEntry[]) .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,20 +5,13 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" import { formatKeybindParts } from "@/context/command"
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" 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 { getRelativeTime } from "@/utils/time"
import { import {
createCommandPaletteCommandEntry,
createCommandPaletteFileEntry, createCommandPaletteFileEntry,
createCommandPaletteModel, createCommandPaletteModel,
createServerSessionEntries,
uniqueCommandPaletteEntries, uniqueCommandPaletteEntries,
type CommandPaletteEntry, type CommandPaletteEntry,
} from "./command-palette" } from "./command-palette"
@@ -37,6 +30,9 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) { export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props) const palette = createCommandPaletteModel(props)
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const loadItems = async (text: string) => { const loadItems = async (text: string) => {
const q = text.trim() const q = text.trim()
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
@@ -45,105 +41,16 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
const category = palette.language.t("palette.group.files") const category = palette.language.t("palette.group.files")
return [ return [
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)), ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions, ...nextSessions.filter((entry) => matchesEntry(entry, q)),
...files.map((path) => createCommandPaletteFileEntry(path, category)), ...files.map((path) => createCommandPaletteFileEntry(path, category)),
] ]
} }
return ( const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
<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. // Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? [])) const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const groupedEntries = createMemo(() => groups(visibleEntries())) const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()]) const activeEntry = createMemo(() => visibleEntries()[active()])
const openSessions = createMemo(
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
)
createEffect(() => { createEffect(() => {
query() query()
@@ -152,7 +59,7 @@ function CommandPaletteView(props: {
}) })
createEffect(() => { createEffect(() => {
props.highlight(activeEntry()) palette.highlight(activeEntry())
}) })
let resultsRef: HTMLDivElement | undefined let resultsRef: HTMLDivElement | undefined
@@ -179,12 +86,12 @@ function CommandPaletteView(props: {
} }
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault() event.preventDefault()
props.select(activeEntry()) palette.select(activeEntry())
return return
} }
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
props.close() palette.close()
} }
} }
@@ -198,7 +105,7 @@ function CommandPaletteView(props: {
autocomplete="off" autocomplete="off"
spellcheck={false} spellcheck={false}
appearance="large" appearance="large"
placeholder={props.placeholder} placeholder={palette.language.t("palette.search.placeholder")}
leadingIcon={<Icon name="magnifying-glass" />} leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)} onInput={(event) => setQuery(event.currentTarget.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -210,7 +117,7 @@ function CommandPaletteView(props: {
when={visibleEntries().length > 0} when={visibleEntries().length > 0}
fallback={ fallback={
<div class="command-palette-v2-state"> <div class="command-palette-v2-state">
{entries.loading ? language.t("common.loading") : language.t("palette.empty")} {entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
</div> </div>
} }
> >
@@ -225,14 +132,9 @@ function CommandPaletteView(props: {
<PaletteRow <PaletteRow
item={item} item={item}
active={activeEntry()?.id === item.id} active={activeEntry()?.id === item.id}
language={language} language={palette.language}
sessionOpen={
item.server && item.sessionID
? openSessions().has(`${item.server}\0${item.sessionID}`)
: false
}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onSelect={() => props.select(item)} onSelect={() => palette.select(item)}
/> />
)} )}
</For> </For>
@@ -251,19 +153,13 @@ function PaletteRow(props: {
item: CommandPaletteEntry item: CommandPaletteEntry
active: boolean active: boolean
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
sessionOpen: boolean
onActive: () => void onActive: () => void
onSelect: () => 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 ( return (
<button <button
type="button" type="button"
class="command-palette-v2-row group" class="command-palette-v2-row"
role="option" role="option"
aria-selected={props.active} aria-selected={props.active}
data-active={props.active ? "" : undefined} data-active={props.active ? "" : undefined}
@@ -301,25 +197,7 @@ function PaletteRow(props: {
</Match> </Match>
<Match when={props.item.type === "session"}> <Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main"> <div class="command-palette-v2-row-main">
<div class="relative shrink-0"> <Icon name="status" class="command-palette-v2-row-icon" />
<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"> <div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}> <span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title} {props.item.title}
+41 -1
View File
@@ -8,9 +8,49 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png" import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.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 // TODO: wire to changelog / seen-state when available
const showPopover = () => true 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 // can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() { export function TabsInfoPopup() {
const settings = useSettings() const settings = useSettings()
@@ -21,7 +61,7 @@ export function TabsInfoPopup() {
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right"> <Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={settings.general.shouldDisplayTabsToast()}> <Show when={settings.general.shouldDisplayTabsToast()}>
<div <div
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)]" 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)]"
aria-label="Introducing Tabs. Organize your work and active sessions with tabs" aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
> >
<button <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 { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command" import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
@@ -20,6 +20,7 @@ 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 IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette" const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt" type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
+1 -15
View File
@@ -1,19 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command" import { resolveKeybindOption, upsertCommandRegistration } 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", () => { describe("upsertCommandRegistration", () => {
test("replaces keyed registrations", () => { test("replaces keyed registrations", () => {
+2 -9
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 IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette" const PALETTE_ID = "command.palette"
export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p" const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested." const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"]) const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
@@ -87,13 +87,6 @@ export interface CommandOption {
onHighlight?: () => (() => void) | void 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) { export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when) return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
} }
@@ -382,7 +375,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
} }
const showPalette = () => { const showPalette = () => {
run(PALETTE_ID, "palette") run("file.open", "palette")
} }
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -1,29 +0,0 @@
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
@@ -1,7 +0,0 @@
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]
}
+4 -7
View File
@@ -4,10 +4,8 @@ import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js" import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { hasCustomAgent, resolveAgent } from "./local-agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
@@ -64,11 +62,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory) const providers = useProviders(() => sdk().directory)
const models = useModels() const models = useModels()
const settings = useSettings()
const id = createMemo(() => params.id || undefined) const id = createMemo(() => params.id || undefined)
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) 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 connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted( const [saved, setSaved, , savedReady] = persisted(
@@ -111,7 +107,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
const pickAgent = (name: string | undefined) => { const pickAgent = (name: string | undefined) => {
return resolveAgent(list(), name) const items = list()
if (items.length === 0) return
return items.find((item) => item.name === name) ?? items[0]
} }
createEffect(() => { createEffect(() => {
@@ -182,9 +180,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = { const agent = {
list, list,
visible: agentsVisible,
current() { current() {
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build") return pickAgent(scope()?.agent ?? store.current)
}, },
set(name: string | undefined) { set(name: string | undefined) {
const item = pickAgent(name) const item = pickAgent(name)
-1
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "إلغاء مشاركة الجلسة", "command.session.unshare": "إلغاء مشاركة الجلسة",
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة", "command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات", "palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
"palette.search.placeholder.home": "البحث في الأوامر والجلسات",
"palette.empty": "لا توجد نتائج", "palette.empty": "لا توجد نتائج",
"palette.group.commands": "الأوامر", "palette.group.commands": "الأوامر",
"palette.group.files": "الملفات", "palette.group.files": "الملفات",
-1
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "Parar de compartilhar sessão", "command.session.unshare": "Parar de compartilhar sessão",
"command.session.unshare.description": "Parar de compartilhar esta sessão", "command.session.unshare.description": "Parar de compartilhar esta sessão",
"palette.search.placeholder": "Buscar arquivos, comandos e sessões", "palette.search.placeholder": "Buscar arquivos, comandos e sessões",
"palette.search.placeholder.home": "Buscar comandos e sessões",
"palette.empty": "Nenhum resultado encontrado", "palette.empty": "Nenhum resultado encontrado",
"palette.group.commands": "Comandos", "palette.group.commands": "Comandos",
"palette.group.files": "Arquivos", "palette.group.files": "Arquivos",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Zaustavi dijeljenje ove sesije", "command.session.unshare.description": "Zaustavi dijeljenje ove sesije",
"palette.search.placeholder": "Pretraži datoteke, komande i sesije", "palette.search.placeholder": "Pretraži datoteke, komande i sesije",
"palette.search.placeholder.home": "Pretraži komande i sesije",
"palette.empty": "Nema rezultata", "palette.empty": "Nema rezultata",
"palette.group.commands": "Komande", "palette.group.commands": "Komande",
"palette.group.files": "Datoteke", "palette.group.files": "Datoteke",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Stop med at dele denne session", "command.session.unshare.description": "Stop med at dele denne session",
"palette.search.placeholder": "Søg i filer, kommandoer og sessioner", "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.empty": "Ingen resultater fundet",
"palette.group.commands": "Kommandoer", "palette.group.commands": "Kommandoer",
"palette.group.files": "Filer", "palette.group.files": "Filer",
-1
View File
@@ -90,7 +90,6 @@ export const dict = {
"command.session.unshare": "Teilen der Sitzung aufheben", "command.session.unshare": "Teilen der Sitzung aufheben",
"command.session.unshare.description": "Teilen dieser Sitzung beenden", "command.session.unshare.description": "Teilen dieser Sitzung beenden",
"palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen", "palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen",
"palette.search.placeholder.home": "Befehle und Sitzungen durchsuchen",
"palette.empty": "Keine Ergebnisse gefunden", "palette.empty": "Keine Ergebnisse gefunden",
"palette.group.commands": "Befehle", "palette.group.commands": "Befehle",
"palette.group.files": "Dateien", "palette.group.files": "Dateien",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Stop sharing this session", "command.session.unshare.description": "Stop sharing this session",
"palette.search.placeholder": "Search files, commands, and sessions", "palette.search.placeholder": "Search files, commands, and sessions",
"palette.search.placeholder.home": "Search commands and sessions",
"palette.empty": "No results found", "palette.empty": "No results found",
"palette.group.commands": "Commands", "palette.group.commands": "Commands",
"palette.group.files": "Files", "palette.group.files": "Files",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Dejar de compartir esta sesión", "command.session.unshare.description": "Dejar de compartir esta sesión",
"palette.search.placeholder": "Buscar archivos, comandos y sesiones", "palette.search.placeholder": "Buscar archivos, comandos y sesiones",
"palette.search.placeholder.home": "Buscar comandos y sesiones",
"palette.empty": "No se encontraron resultados", "palette.empty": "No se encontraron resultados",
"palette.group.commands": "Comandos", "palette.group.commands": "Comandos",
"palette.group.files": "Archivos", "palette.group.files": "Archivos",
-1
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "Ne plus partager la session", "command.session.unshare": "Ne plus partager la session",
"command.session.unshare.description": "Arrêter de partager cette 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": "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.empty": "Aucun résultat trouvé",
"palette.group.commands": "Commandes", "palette.group.commands": "Commandes",
"palette.group.files": "Fichiers", "palette.group.files": "Fichiers",
-1
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "セッションの共有を停止", "command.session.unshare": "セッションの共有を停止",
"command.session.unshare.description": "このセッションの共有を停止", "command.session.unshare.description": "このセッションの共有を停止",
"palette.search.placeholder": "ファイル、コマンド、セッションを検索", "palette.search.placeholder": "ファイル、コマンド、セッションを検索",
"palette.search.placeholder.home": "コマンドとセッションを検索",
"palette.empty": "結果が見つかりません", "palette.empty": "結果が見つかりません",
"palette.group.commands": "コマンド", "palette.group.commands": "コマンド",
"palette.group.files": "ファイル", "palette.group.files": "ファイル",
-1
View File
@@ -82,7 +82,6 @@ export const dict = {
"command.session.unshare": "세션 공유 중지", "command.session.unshare": "세션 공유 중지",
"command.session.unshare.description": "이 세션 공유 중지", "command.session.unshare.description": "이 세션 공유 중지",
"palette.search.placeholder": "파일, 명령어 및 세션 검색", "palette.search.placeholder": "파일, 명령어 및 세션 검색",
"palette.search.placeholder.home": "명령어 및 세션 검색",
"palette.empty": "결과 없음", "palette.empty": "결과 없음",
"palette.group.commands": "명령어", "palette.group.commands": "명령어",
"palette.group.files": "파일", "palette.group.files": "파일",
-1
View File
@@ -92,7 +92,6 @@ export const dict = {
"command.session.unshare.description": "Slutt å dele denne sesjonen", "command.session.unshare.description": "Slutt å dele denne sesjonen",
"palette.search.placeholder": "Søk i filer, kommandoer og sesjoner", "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.empty": "Ingen resultater funnet",
"palette.group.commands": "Kommandoer", "palette.group.commands": "Kommandoer",
"palette.group.files": "Filer", "palette.group.files": "Filer",
-1
View File
@@ -86,7 +86,6 @@ export const dict = {
"command.session.unshare": "Przestań udostępniać sesję", "command.session.unshare": "Przestań udostępniać sesję",
"command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji", "command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji",
"palette.search.placeholder": "Szukaj plików, poleceń i sesji", "palette.search.placeholder": "Szukaj plików, poleceń i sesji",
"palette.search.placeholder.home": "Szukaj poleceń i sesji",
"palette.empty": "Brak wyników", "palette.empty": "Brak wyników",
"palette.group.commands": "Polecenia", "palette.group.commands": "Polecenia",
"palette.group.files": "Pliki", "palette.group.files": "Pliki",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Прекратить публикацию сессии", "command.session.unshare.description": "Прекратить публикацию сессии",
"palette.search.placeholder": "Поиск файлов, команд и сессий", "palette.search.placeholder": "Поиск файлов, команд и сессий",
"palette.search.placeholder.home": "Поиск команд и сессий",
"palette.empty": "Ничего не найдено", "palette.empty": "Ничего не найдено",
"palette.group.commands": "Команды", "palette.group.commands": "Команды",
"palette.group.files": "Файлы", "palette.group.files": "Файлы",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "หยุดการแชร์เซสชันนี้", "command.session.unshare.description": "หยุดการแชร์เซสชันนี้",
"palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน", "palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน",
"palette.search.placeholder.home": "ค้นหาคำสั่งและเซสชัน",
"palette.empty": "ไม่พบผลลัพธ์", "palette.empty": "ไม่พบผลลัพธ์",
"palette.group.commands": "คำสั่ง", "palette.group.commands": "คำสั่ง",
"palette.group.files": "ไฟล์", "palette.group.files": "ไฟล์",
-1
View File
@@ -97,7 +97,6 @@ export const dict = {
"command.session.unshare.description": "Bu oturumun paylaşımını durdur", "command.session.unshare.description": "Bu oturumun paylaşımını durdur",
"palette.search.placeholder": "Dosya, komut ve oturum ara", "palette.search.placeholder": "Dosya, komut ve oturum ara",
"palette.search.placeholder.home": "Komut ve oturum ara",
"palette.empty": "Sonuç bulunamadı", "palette.empty": "Sonuç bulunamadı",
"palette.group.commands": "Komutlar", "palette.group.commands": "Komutlar",
"palette.group.files": "Dosyalar", "palette.group.files": "Dosyalar",
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.unshare.description": "Припинити поширення цієї сесії", "command.session.unshare.description": "Припинити поширення цієї сесії",
"palette.search.placeholder": "Пошук файлів, команд і сесій", "palette.search.placeholder": "Пошук файлів, команд і сесій",
"palette.search.placeholder.home": "Пошук команд і сесій",
"palette.empty": "Результатів не знайдено", "palette.empty": "Результатів не знайдено",
"palette.group.commands": "Команди", "palette.group.commands": "Команди",
"palette.group.files": "Файли", "palette.group.files": "Файли",
-1
View File
@@ -120,7 +120,6 @@ export const dict = {
"command.session.unshare.description": "停止分享此会话", "command.session.unshare.description": "停止分享此会话",
"palette.search.placeholder": "搜索文件、命令和会话", "palette.search.placeholder": "搜索文件、命令和会话",
"palette.search.placeholder.home": "搜索命令和会话",
"palette.empty": "未找到结果", "palette.empty": "未找到结果",
"palette.group.commands": "命令", "palette.group.commands": "命令",
"palette.group.files": "文件", "palette.group.files": "文件",
-1
View File
@@ -97,7 +97,6 @@ export const dict = {
"command.session.unshare.description": "停止分享此工作階段", "command.session.unshare.description": "停止分享此工作階段",
"palette.search.placeholder": "搜尋檔案、命令和工作階段", "palette.search.placeholder": "搜尋檔案、命令和工作階段",
"palette.search.placeholder.home": "搜尋命令和工作階段",
"palette.empty": "找不到結果", "palette.empty": "找不到結果",
"palette.group.commands": "命令", "palette.group.commands": "命令",
"palette.group.files": "檔案", "palette.group.files": "檔案",
+119 -212
View File
@@ -5,7 +5,6 @@ import {
createMemo, createMemo,
createResource, createResource,
createRoot, createRoot,
createSignal,
For, For,
Match, Match,
on, on,
@@ -75,23 +74,6 @@ const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12 const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16 const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 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 SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT = 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" "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"
@@ -162,7 +144,6 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
let content: HTMLDivElement | undefined let content: HTMLDivElement | undefined
let positionFrame: number | undefined let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined let resizeObserver: ResizeObserver | undefined
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>() const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>() const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({ const [state, setState] = createStore({
@@ -221,13 +202,6 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
function updatePositionCache() { function updatePositionCache() {
if (!viewport) return 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) => { groups().forEach((group) => {
const el = headerRefs.get(group.id) const el = headerRefs.get(group.id)
if (!el) return if (!el) return
@@ -243,7 +217,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
.slice(index + 1) .slice(index + 1)
.map((item) => headerOffsets.get(item.id)) .map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined) .find((offset) => offset !== undefined)
const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity = const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE)) nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
@@ -297,9 +271,6 @@ export function NewHome() {
const marked = useMarked() const marked = useMarked()
const openSettings = useSettingsCommand() const openSettings = useSettingsCommand()
let focusSessionSearch: (() => void) | undefined let focusSessionSearch: (() => void) | undefined
let sessionViewport: HTMLDivElement | undefined
const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
const [state, setState] = createStore({ const [state, setState] = createStore({
search: "", search: "",
searchFocused: false, searchFocused: false,
@@ -431,34 +402,6 @@ export function NewHome() {
} }
command.register("home", () => [ 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", id: "home.sessions.search.focus",
title: searchPlaceholder(), title: searchPlaceholder(),
@@ -598,158 +541,127 @@ export function NewHome() {
} }
return ( return (
<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"> <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">
<ScrollView <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">
class="h-full [container-type:size]" <HomeProjectColumn
thumbContainer={sessionThumbTrack} projects={projects()}
thumbHoverTarget={sessionHoverTarget} recentlyClosed={recentlyClosed()}
viewportRef={(el) => { homedir={homedir()}
sessionViewport = el selected={selection()}
sessionHeaderOpacity.setViewport(el) focusServer={focusServer}
}} selectProject={selectProject}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)} openNewSession={openProjectNewSession}
onWheel={(event) => { openRecentProject={(conn, directory) => addProjects(conn, [directory])}
if (!sessionViewport) return chooseProject={(conn) => void chooseProject(conn)}
if (event.target instanceof Node && sessionViewport.contains(event.target)) return editProject={editProject}
containHomeWheel(event, sessionViewport) closeProject={(conn, directory) => {
}} const next = closeHomeProject(
> selection(),
<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"> ServerConnection.key(conn),
<HomeProjectColumn global.ensureServerCtx(conn).projects,
projects={projects()} directory,
recentlyClosed={recentlyClosed()} )
homedir={homedir()} if (next) setSelection(next)
selected={selection()} }}
focusServer={focusServer} clearNotifications={clearNotifications}
selectProject={selectProject} unseenCount={unseenCount}
openNewSession={openProjectNewSession} openSettings={openSettings}
openRecentProject={(conn, directory) => addProjects(conn, [directory])} openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
chooseProject={(conn) => void chooseProject(conn)} language={language}
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)
}}
/>
<section <section
ref={setSessionHoverTarget} class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
class="min-h-0 min-w-0 flex-1 flex flex-col" aria-label={language.t("sidebar.project.recentSessions")}
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
}}
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)}
> >
<div <Show when={groups().length > 0 && newSessionProject()}>
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" <div class="pointer-events-none absolute top-3 right-3 z-20 flex">
onWheel={(event) => { <ButtonV2
if (sessionViewport) containHomeWheel(event, sessionViewport) 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>
}
> >
<HomeSessionSearch <Show
value={state.search} when={groups().length > 0}
placeholder={searchPlaceholder()} fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
open={searchOpen()} >
loading={sessionLoad.isLoading} <div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
results={searchResults()} <For each={groups()}>
showProjectName={!selectedProject()} {(group, index) => (
server={selection().server} <>
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })} <HomeSessionGroupHeader
bindFocus={(focus) => { title={group.title}
focusSessionSearch = focus titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
}} ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
onInput={(value) => setState("search", value)} elevated={index() === 0}
onFocus={() => setState("searchFocused", true)} />
onClose={closeSearch} <div
onSelect={selectSearchSession} class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
/> >
<Show when={groups().length > 0 && newSessionProject()}> <For each={group.sessions}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]"> {(record) => (
<ButtonV2 <HomeSessionRow
data-action="home-new-session" record={record}
variant="ghost-muted" showProjectName={!selectedProject()}
size="normal" server={selection().server}
icon="edit" openSession={openSession}
class="pointer-events-auto h-7 px-2 [font-weight:530]" archiveSession={archiveSession}
onClick={openNewSession} />
> )}
{language.t("command.session.new")} </For>
</ButtonV2> </div>
</>
)}
</For>
</div> </div>
</Show> </Show>
</div> </Show>
{/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */} </ScrollView>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]"> </section>
<div <HomeUtilityNav
ref={setSessionThumbTrack} class="flex lg:hidden"
data-component="home-session-scroll-track" openSettings={openSettings}
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]" openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
/> language={language}
</div> />
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]"> </div>
<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> </div>
) )
} }
@@ -771,7 +683,6 @@ function HomeProjectColumn(props: {
openSettings: () => void openSettings: () => void
openHelp: () => void openHelp: () => void
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
onWheel: (event: WheelEvent) => void
}) { }) {
const global = useGlobal() const global = useGlobal()
const dialog = useDialog() const dialog = useDialog()
@@ -788,12 +699,8 @@ function HomeProjectColumn(props: {
return ( return (
<aside <aside
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]" class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
aria-label={props.language.t("home.projects")} 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="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> <div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
@@ -1511,7 +1418,7 @@ function HomeSessionGroupHeader(props: {
return ( return (
<div <div
ref={props.ref} ref={props.ref}
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"}`} 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"}`}
> >
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}> <div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
{props.title} {props.title}
+2 -1
View File
@@ -1,7 +1,7 @@
import { createEffect, Suspense, type ParentProps } from "solid-js" import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar" import { DebugBar } from "@/components/debug-bar"
import { TabsInfoPopup } from "@/components/help-button" import { HelpButton, TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click" import { setNavigate } from "@/utils/notification-click"
@@ -38,6 +38,7 @@ export default function NewLayout(props: ParentProps) {
</main> </main>
{import.meta.env.DEV && <DebugBar inline />} {import.meta.env.DEV && <DebugBar inline />}
<TabsInfoPopup /> <TabsInfoPopup />
<HelpButton />
<ToastRegion v2 /> <ToastRegion v2 />
</div> </div>
) )
+2 -1
View File
@@ -54,7 +54,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar" import { DebugBar } from "@/components/debug-bar"
import { TabsInfoPopup } from "@/components/help-button" import { HelpButton, TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useDirectoryPicker } from "@/components/directory-picker" import { useDirectoryPicker } from "@/components/directory-picker"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServer } from "@/context/server"
@@ -2396,6 +2396,7 @@ export default function LegacyLayout(props: ParentProps) {
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />} {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div> </div>
<TabsInfoPopup /> <TabsInfoPopup />
<HelpButton />
<ToastRegion v2={false} /> <ToastRegion v2={false} />
</div> </div>
) )
-11
View File
@@ -3,7 +3,6 @@ import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web" import { Portal } from "solid-js/web"
import { useSearchParams } from "@solidjs/router" import { useSearchParams } from "@solidjs/router"
import { Tooltip } from "@opencode-ai/ui/tooltip" 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 { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { NewSessionDesignView } from "@/components/session" import { NewSessionDesignView } from "@/components/session"
@@ -52,7 +51,6 @@ export default function NewSessionPage() {
const comments = useComments() const comments = useComments()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const dialog = useDialog()
const command = useCommand() const command = useCommand()
const providers = useProviders(() => sdk().directory) const providers = useProviders(() => sdk().directory)
const openProviderSettings = useSettingsDialog("providers") const openProviderSettings = useSettingsDialog("providers")
@@ -79,15 +77,6 @@ export default function NewSessionPage() {
}) })
command.register("new-session", () => [ 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", id: "input.focus",
title: language.t("command.input.focus"), title: language.t("command.input.focus"),
-10
View File
@@ -41,7 +41,6 @@ import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/r
import { NewSessionView, SessionHeader } from "@/components/session" import { NewSessionView, SessionHeader } from "@/components/session"
import { ErrorPage } from "@/pages/error" import { ErrorPage } from "@/pages/error"
import { CommentsProvider, useComments } from "@/context/comments" import { CommentsProvider, useComments } from "@/context/comments"
import { useCommand } from "@/context/command"
import { DirectoryDataProvider } from "@/pages/directory-layout" import { DirectoryDataProvider } from "@/pages/directory-layout"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -363,7 +362,6 @@ export default function Page() {
const platform = usePlatform() const platform = usePlatform()
const prompt = usePrompt() const prompt = usePrompt()
const comments = useComments() const comments = useComments()
const command = useCommand()
const terminal = useTerminal() const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation() const location = useLocation()
@@ -1100,14 +1098,6 @@ export default function Page() {
review: reviewTab, review: reviewTab,
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id, 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({ const openReviewFile = createOpenReviewFile({
showAllFiles, showAllFiles,
@@ -41,7 +41,7 @@ export function createPromptInputController(input: {
options: local.agent.list().map((agent) => agent.name), options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "", current: local.agent.current()?.name ?? "",
loading: agentsQuery.isLoading, loading: agentsQuery.isLoading,
visible: local.agent.visible(), visible: settings.visibility.customAgents(),
select: local.agent.set, select: local.agent.set,
}, },
model: { model: {
@@ -1,6 +1,7 @@
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local" import { useLocal, type ModelSelection } from "@/context/local"
import { useSettings } from "@/context/settings"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom" import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
import { useSessionLayout } from "./session-layout" import { useSessionLayout } from "./session-layout"
@@ -18,6 +19,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const local = useLocal() const local = useLocal()
const settings = useSettings()
const { sessionKey } = useSessionLayout() const { sessionKey } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey) const sessionOwnership = createSessionOwnership(sessionKey)
const model = input.model ?? local.model const model = input.model ?? local.model
@@ -68,7 +70,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
description: language.t("command.agent.cycle.description"), description: language.t("command.agent.cycle.description"),
keybind: "mod+.", keybind: "mod+.",
slash: "agent", slash: "agent",
disabled: !local.agent.visible(), disabled: !settings.visibility.customAgents(),
onSelect: () => local.agent.move(1), onSelect: () => local.agent.move(1),
}), }),
agentCommand({ agentCommand({
@@ -76,7 +78,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
title: language.t("command.agent.cycle.reverse"), title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"), description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.", keybind: "shift+mod+.",
disabled: !local.agent.visible(), disabled: !settings.visibility.customAgents(),
onSelect: () => local.agent.move(-1), onSelect: () => local.agent.move(-1),
}), }),
]) ])
@@ -468,7 +468,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "file.open", id: "file.open",
title: language.t("command.file.open"), title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"), description: language.t("palette.search.placeholder"),
keybind: "mod+p", keybind: "mod+k,mod+p",
slash: "open", slash: "open",
onSelect: openFile, onSelect: openFile,
}), }),
@@ -1,76 +0,0 @@
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", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.18.3", "version": "1.18.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
+1 -1
View File
@@ -5,7 +5,7 @@ import { ServerProcess } from "../../server-process"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.serve, Commands.commands.serve,
Effect.fnUntraced(function* (input) { Effect.fn("cli.serve")(function* (input) {
if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined")) if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined"))
return yield* ServerProcess.run({ return yield* ServerProcess.run({
mode: input.service ? "service" : input.stdio ? "stdio" : "default", mode: input.service ? "service" : input.stdio ? "stdio" : "default",
-6
View File
@@ -633,12 +633,6 @@ export function RunSubagentSelectBody(props: {
return return
} }
if (event.name.toLowerCase() === "up" && menu.selected() === 0) {
event.preventDefault()
props.onClose()
return
}
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
}) })
+64 -45
View File
@@ -23,7 +23,7 @@ import {
movePromptHistory, movePromptHistory,
pushPromptHistory, pushPromptHistory,
} from "./prompt.shared" } from "./prompt.shared"
import { Keymap } from "@opencode-ai/tui/context/keymap" import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor" import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme" import type { RunFooterTheme } from "./theme"
@@ -993,83 +993,93 @@ export function createPromptState(input: PromptInput): PromptState {
return true return true
} }
Keymap.createLayer(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: baseBindingsEnabled(), enabled: baseBindingsEnabled(),
commands: [ commands: [
{ {
id: "prompt.clear", name: "prompt.clear",
title: "Clear prompt or exit", title: "Clear prompt or exit",
group: "Prompt", category: "Prompt",
run() { run() {
if (requestExit()) return if (requestExit()) return
return false return false
}, },
}, },
], ],
bindings: input.tuiConfig.keybinds.get("prompt.clear"),
})) }))
Keymap.createLayer(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt(), enabled: input.prompt(),
commands: [ commands: [
{ {
id: "session.interrupt", name: "session.interrupt",
title: "Interrupt session", title: "Interrupt session",
group: "Session", category: "Session",
run() { run() {
if (input.onInterrupt()) return if (input.onInterrupt()) return
return false return false
}, },
}, },
], ],
bindings: input.tuiConfig.keybinds.get("session.interrupt"),
})) }))
Keymap.createLayer(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(), enabled: input.prompt() && !visible(),
commands: [ commands: [
{ {
id: "prompt.editor", name: "prompt.editor",
title: "Open editor", title: "Open editor",
group: "Prompt", category: "Prompt",
run() { run() {
void openEditor() void openEditor()
}, },
}, },
], ],
bindings: input.tuiConfig.keybinds.get("prompt.editor"),
})) }))
Keymap.createLayer(() => ({ useBindings(() => ({
priority: 1, priority: 1,
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(), enabled: input.prompt() && !visible(),
commands: [ commands: [
{ {
id: "prompt.history.previous", name: "prompt.history.previous",
title: "Previous prompt history", title: "Previous prompt history",
group: "Prompt", category: "Prompt",
run(_input: string | undefined, event?: KeyEvent) { run(ctx: { event: KeyEvent }) {
if (!event) return false return historyCommand(-1, ctx.event)
return historyCommand(-1, event)
}, },
}, },
{ {
id: "prompt.history.next", name: "prompt.history.next",
title: "Next prompt history", title: "Next prompt history",
group: "Prompt", category: "Prompt",
run(_input: string | undefined, event?: KeyEvent) { run(ctx: { event: KeyEvent }) {
if (!event) return false return historyCommand(1, ctx.event)
return historyCommand(1, event)
}, },
}, },
], ],
bindings: [
...input.tuiConfig.keybinds.get("prompt.history.previous"),
...input.tuiConfig.keybinds.get("prompt.history.next"),
],
})) }))
Keymap.createLayer(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(), enabled: input.prompt() && !visible(),
commands: [ bindings: [
{ {
bind: "!", key: "!",
title: "Shell mode", desc: "Shell mode",
group: "Prompt", group: "Prompt",
run() { cmd() {
if (shell()) return false if (shell()) return false
if (!area || area.isDestroyed) return false if (!area || area.isDestroyed) return false
if (area.cursorOffset !== 0) return false if (area.cursorOffset !== 0) return false
@@ -1079,20 +1089,21 @@ export function createPromptState(input: PromptInput): PromptState {
], ],
})) }))
Keymap.createLayer(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && shell() && !visible(), enabled: input.prompt() && shell() && !visible(),
commands: [ bindings: [
{ {
bind: "escape", key: "escape",
title: "Exit shell mode", desc: "Exit shell mode",
group: "Prompt", group: "Prompt",
run: () => setShellMode(false), cmd: () => setShellMode(false),
}, },
{ {
bind: "backspace", key: "backspace",
title: "Exit shell mode", desc: "Exit shell mode",
group: "Prompt", group: "Prompt",
run() { cmd() {
if (!area || area.isDestroyed) return false if (!area || area.isDestroyed) return false
if (area.cursorOffset !== 0) return false if (area.cursorOffset !== 0) return false
setShellMode(false) setShellMode(false)
@@ -1101,31 +1112,32 @@ export function createPromptState(input: PromptInput): PromptState {
], ],
})) }))
Keymap.createLayer(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && visible(), enabled: input.prompt() && visible(),
commands: [ commands: [
{ {
id: "prompt.autocomplete.prev", name: "prompt.autocomplete.prev",
title: "Previous autocomplete item", title: "Previous autocomplete item",
group: "Autocomplete", category: "Autocomplete",
run: () => menu.move(-1), run: () => menu.move(-1),
}, },
{ {
id: "prompt.autocomplete.next", name: "prompt.autocomplete.next",
title: "Next autocomplete item", title: "Next autocomplete item",
group: "Autocomplete", category: "Autocomplete",
run: () => menu.move(1), run: () => menu.move(1),
}, },
{ {
id: "prompt.autocomplete.hide", name: "prompt.autocomplete.hide",
title: "Hide autocomplete", title: "Hide autocomplete",
group: "Autocomplete", category: "Autocomplete",
run: cancelAutocomplete, run: cancelAutocomplete,
}, },
{ {
id: "prompt.autocomplete.select", name: "prompt.autocomplete.select",
title: "Select autocomplete item", title: "Select autocomplete item",
group: "Autocomplete", category: "Autocomplete",
run() { run() {
if (mode() === "slash" && options().length === 0) { if (mode() === "slash" && options().length === 0) {
hide() hide()
@@ -1135,9 +1147,9 @@ export function createPromptState(input: PromptInput): PromptState {
}, },
}, },
{ {
id: "prompt.autocomplete.complete", name: "prompt.autocomplete.complete",
title: "Complete autocomplete item", title: "Complete autocomplete item",
group: "Autocomplete", category: "Autocomplete",
run() { run() {
if (mode() === "slash" && options().length === 0) { if (mode() === "slash" && options().length === 0) {
hide() hide()
@@ -1152,6 +1164,13 @@ 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) => { const onKeyDown = (event: KeyEvent) => {
+6 -4
View File
@@ -24,11 +24,12 @@
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a // 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 // two-press pattern where the first press shows a hint and the second press
// within 5 seconds actually fires the action. // within 5 seconds actually fires the action.
import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core" import { CliRenderEvents, type CliRenderer, type KeyEvent, type Renderable, type TreeSitterClient } from "@opentui/core"
import type { Keymap } from "@opentui/keymap"
import { render } from "@opentui/solid" import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js" import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { Keymap } from "@opencode-ai/tui/context/keymap" import { OpencodeKeymapProvider } from "@opencode-ai/tui/keymap"
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command" import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent" import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt" import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
@@ -81,6 +82,7 @@ type RunFooterOptions = {
first: boolean first: boolean
history?: RunPrompt[] history?: RunPrompt[]
theme: RunTheme theme: RunTheme
keymap: Keymap<Renderable, KeyEvent>
tuiConfig: RunTuiConfig tuiConfig: RunTuiConfig
diffStyle: RunDiffStyle diffStyle: RunDiffStyle
onPermissionReply: (input: PermissionReply) => void | Promise<void> onPermissionReply: (input: PermissionReply) => void | Promise<void>
@@ -303,8 +305,8 @@ export class RunFooter implements FooterApi {
const footer = this const footer = this
void render( void render(
() => () =>
createComponent(Keymap.Provider, { createComponent(OpencodeKeymapProvider, {
config: options.tuiConfig, keymap: options.keymap,
get children() { get children() {
return createComponent(RunFooterView, { return createComponent(RunFooterView, {
directory: options.directory, directory: options.directory,
+107 -29
View File
@@ -27,8 +27,14 @@ import { RunPromptBody, createPromptState } from "./footer.prompt"
import { RunPermissionBody } from "./footer.permission" import { RunPermissionBody } from "./footer.permission"
import { RunQuestionBody } from "./footer.question" import { RunQuestionBody } from "./footer.question"
import { footerWidthPolicy } from "./footer.width" import { footerWidthPolicy } from "./footer.width"
import { Keymap } from "@opencode-ai/tui/context/keymap" import {
OPENCODE_BASE_MODE,
formatKeyBindings,
formatKeySequence,
useBindings,
useKeymapSelector,
type OpenTuiKeymap,
} from "@opencode-ai/tui/keymap"
import type { import type {
FooterPromptRoute, FooterPromptRoute,
FooterQueuedPrompt, FooterQueuedPrompt,
@@ -171,15 +177,75 @@ export function RunFooterView(props: RunFooterViewProps) {
const current = route() const current = route()
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
}) })
const shortcuts = Keymap.useShortcuts() const command = useKeymapSelector(
const command = () => shortcuts.get("command.palette.show") ?? "" (keymap: OpenTuiKeymap) =>
const subagentShortcut = () => shortcuts.get("session.child.first") ?? "" formatKeySequence(
const queuedShortcut = () => shortcuts.get("session.queued_prompts") ?? "" keymap
const backgroundShortcut = () => shortcuts.get("session.background") ?? "" .getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] })
const subagentInterruptShortcut = () => shortcuts.get("subagent.interrupt") ?? "" .get("command.palette.show")?.[0]?.sequence,
const interrupt = () => shortcuts.get("session.interrupt") ?? "" props.tuiConfig,
const variantCycle = () => shortcuts.all("variant.cycle") ?? "" ) ?? "",
const clearShortcut = () => shortcuts.get("prompt.clear") ?? "" )
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 busy = createMemo(() => props.state().phase === "running") const busy = createMemo(() => props.state().phase === "running")
const armed = createMemo(() => props.state().interrupt > 0) const armed = createMemo(() => props.state().interrupt > 0)
const exiting = createMemo(() => props.state().exit > 0) const exiting = createMemo(() => props.state().exit > 0)
@@ -438,62 +504,74 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onRequestExit?.(undefined) props.onRequestExit?.(undefined)
}) })
Keymap.createLayer(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(), enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
commands: [ commands: [
{ {
id: "command.palette.show", name: "command.palette.show",
title: "Open command palette", title: "Open command palette",
group: "Prompt", category: "Prompt",
run: openCommand, run: openCommand,
}, },
{ {
id: "variant.cycle", name: "variant.cycle",
title: "Cycle model variant", title: "Cycle model variant",
group: "Model", category: "Model",
run: props.onCycle, run: props.onCycle,
}, },
], ],
bindings: [
...props.tuiConfig.keybinds.get("command.palette.show"),
...props.tuiConfig.keybinds.get("variant.cycle"),
],
})) }))
Keymap.createLayer(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground, enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
priority: 1, priority: 1,
commands: [ commands: [
{ {
id: "session.background", name: "session.background",
title: "Background subagents", title: "Background subagents",
group: "Session", category: "Session",
run: () => props.onBackground?.(), run: () => props.onBackground?.(),
}, },
], ],
bindings: props.tuiConfig.keybinds.get("session.background"),
})) }))
Keymap.createLayer(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0, enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
commands: [ commands: [
{ {
id: "session.child.first", name: "session.child.first",
title: "View subagents", title: "View subagents",
group: "Session", category: "Session",
run: openSubagentMenu, run: openSubagentMenu,
}, },
], ],
bindings: props.tuiConfig.keybinds.get("session.child.first"),
})) }))
Keymap.createLayer(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0, enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
commands: [ commands: [
{ {
id: "session.queued_prompts", name: "session.queued_prompts",
title: "Manage queued prompts", title: "Manage queued prompts",
group: "Session", category: "Session",
run: openQueuedMenu, run: openQueuedMenu,
}, },
], ],
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
})) }))
Keymap.createLayer(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: enabled:
active().type === "prompt" && active().type === "prompt" &&
route().type === "subagent" && route().type === "subagent" &&
@@ -502,10 +580,9 @@ export function RunFooterView(props: RunFooterViewProps) {
priority: 1, priority: 1,
commands: [ commands: [
{ {
id: "subagent.interrupt", name: "subagent.interrupt",
title: "Interrupt subagent", title: "Interrupt subagent",
group: "Session", category: "Session",
bind: "ctrl+d",
run: () => { run: () => {
const current = selectedTab() const current = selectedTab()
if (current?.status !== "running") { if (current?.status !== "running") {
@@ -516,6 +593,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}, },
}, },
], ],
bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }],
})) }))
createEffect(() => { createEffect(() => {
@@ -10,7 +10,9 @@
// back to the usual two-press exit sequence through RunFooter.requestExit(). // back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path" import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core" import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { isDefaultTitle } from "@opencode-ai/tui/util/session" import { isDefaultTitle } from "@opencode-ai/tui/util/session"
import { Locale } from "@opencode-ai/tui/util/locale" import { Locale } from "@opencode-ai/tui/util/locale"
import { resolveInteractiveStdin } from "./runtime.stdin" import { resolveInteractiveStdin } from "./runtime.stdin"
@@ -165,6 +167,8 @@ function queueSplash(
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> { export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
const source = resolveInteractiveStdin() const source = resolveInteractiveStdin()
const footerTask = import("./footer") const footerTask = import("./footer")
let unregisterKeymap: (() => void) | undefined
try { try {
const renderer = await createCliRenderer({ const renderer = await createCliRenderer({
stdin: source.stdin, stdin: source.stdin,
@@ -183,6 +187,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
}) })
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig]) const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
renderer.setBackgroundColor(theme.background) renderer.setBackgroundColor(theme.background)
const keymap = createDefaultOpenTuiKeymap(renderer)
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
const state: SplashState = { const state: SplashState = {
entry: false, entry: false,
exit: false, exit: false,
@@ -227,6 +233,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
history: input.history, history: input.history,
theme, theme,
wrote, wrote,
keymap,
tuiConfig, tuiConfig,
diffStyle: tuiConfig.diff_style ?? "auto", diffStyle: tuiConfig.diff_style ?? "auto",
onPermissionReply: input.onPermissionReply, onPermissionReply: input.onPermissionReply,
@@ -326,6 +333,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
footer.close() footer.close()
await footer.idle().catch(() => {}) await footer.idle().catch(() => {})
footer.destroy() footer.destroy()
unregisterKeymap?.()
shutdown(renderer) shutdown(renderer)
if (!wroteExit) { if (!wroteExit) {
process.stdout.write("\n") process.stdout.write("\n")
@@ -383,6 +391,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
close, close,
} }
} catch (error) { } catch (error) {
unregisterKeymap?.()
source.cleanup?.() source.cleanup?.()
throw error throw error
} }
+4 -5
View File
@@ -24,14 +24,13 @@ export type Options = {
readonly port?: number readonly port?: number
} }
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace. export const run = Effect.fn("cli.server-process.run")((options: Options) =>
export const run = Effect.fnUntraced(function* (options: Options) { processEffect(options).pipe(
return yield* processEffect(options).pipe(
Effect.provide(Updater.layer), Effect.provide(Updater.layer),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(NodeServices.layer), Effect.provide(NodeServices.layer),
) ),
}) )
const processEffect = Effect.fnUntraced(function* (options: Options) { const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home)) if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
+50 -39
View File
@@ -1,9 +1,10 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Keymap } from "@opencode-ai/tui/context/keymap" import { testRender, useRenderer } from "@opentui/solid"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { resolve } from "@opencode-ai/tui/config/v1" import { resolve } from "@opencode-ai/tui/config/v1"
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { createSignal } from "solid-js" import { createComponent, createSignal } from "solid-js"
import { RunFooterView } from "../src/mini/footer.view" import { RunFooterView } from "../src/mini/footer.view"
import { RUN_THEME_FALLBACK } from "../src/mini/theme" import { RUN_THEME_FALLBACK } from "../src/mini/theme"
import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types" import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types"
@@ -41,43 +42,52 @@ test("down opens subagents from an empty prompt", async () => {
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } }, { keybinds: { editor_open: "none", session_queued_prompts: "none" } },
{ terminalSuspend: true }, { terminalSuspend: true },
) )
let offKeymap: (() => void) | undefined
function Harness() { function Harness() {
return ( const renderer = useRenderer()
<Keymap.Provider config={config}> const keymap = createDefaultOpenTuiKeymap(renderer)
<RunFooterView offKeymap = registerOpencodeKeymap(keymap, renderer, config)
directory="/tmp"
findFiles={async () => []} return createComponent(OpencodeKeymapProvider, {
agents={() => []} keymap,
references={() => []} get children() {
commands={() => []} return (
providers={() => undefined} <RunFooterView
currentModel={() => undefined} directory="/tmp"
variants={() => []} findFiles={async () => []}
currentVariant={() => undefined} agents={() => []}
state={state} references={() => []}
view={view} commands={() => []}
subagent={subagents} providers={() => undefined}
theme={() => RUN_THEME_FALLBACK} currentModel={() => undefined}
tuiConfig={config} variants={() => []}
agent="opencode" currentVariant={() => undefined}
onSubmit={() => true} state={state}
onPermissionReply={() => {}} view={view}
onQuestionReply={() => {}} subagent={subagents}
onQuestionReject={() => {}} theme={() => RUN_THEME_FALLBACK}
onCycle={() => {}} tuiConfig={config}
onInterrupt={() => false} agent="opencode"
onEditorOpen={async () => undefined} onSubmit={() => true}
onInputClear={() => {}} onPermissionReply={() => {}}
onExit={() => {}} onQuestionReply={() => {}}
onModelSelect={() => {}} onQuestionReject={() => {}}
onVariantSelect={() => {}} onCycle={() => {}}
onRows={() => {}} onInterrupt={() => false}
onLayout={() => {}} onEditorOpen={async () => undefined}
onStatus={() => {}} onInputClear={() => {}}
onQueuedRemove={async () => true} onExit={() => {}}
/> onModelSelect={() => {}}
</Keymap.Provider> onVariantSelect={() => {}}
) onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
)
},
})
} }
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true }) const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
@@ -90,6 +100,7 @@ test("down opens subagents from an empty prompt", async () => {
} finally { } finally {
app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur() app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
app.renderer.destroy() app.renderer.destroy()
} }
}) })
@@ -803,16 +803,6 @@ export type SessionRevertCommitted = {
data: { sessionID: string; to: string } 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 = { export type ModelsDevRefreshed = {
id: string id: string
created: number created: number
@@ -2226,7 +2216,6 @@ export type SessionEventDurable =
| SessionRevertStaged | SessionRevertStaged
| SessionRevertCleared | SessionRevertCleared
| SessionRevertCommitted | SessionRevertCommitted
| SessionUsageRecorded
export type SessionMessagesResponse = { export type SessionMessagesResponse = {
data: Array<SessionMessageInfo> 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] Direct function declarations are hoisted in program and block statement lists.
- [x] Parameter defaults observe a temporal dead zone for later parameters. - [x] Parameter defaults observe a temporal dead zone for later parameters.
- [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations. - [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations.
- [x] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and - [ ] 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 loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
temporal dead zone. temporal dead zone.
- [ ] Hoist function declarations accepted directly in switch cases. - [ ] Hoist function declarations accepted directly in switch cases.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode", "name": "@opencode-ai/codemode",
"version": "1.18.3", "version": "1.18.2",
"description": "Effect-native confined code execution over schema-described tools", "description": "Effect-native confined code execution over schema-described tools",
"private": true, "private": true,
"type": "module", "type": "module",
+113 -157
View File
@@ -144,20 +144,6 @@ const collectPatternNames = (pattern: AstNode, out: Array<string> = []): Array<s
return out 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> { export class Interpreter<R> {
private scopes: ScopeStack private scopes: ScopeStack
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R> private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
@@ -221,7 +207,6 @@ export class Interpreter<R> {
// Keep top-level declarations separate so they can shadow builtins. // Keep top-level declarations separate so they can shadow builtins.
this.scopes.push() this.scopes.push()
return Effect.gen(function* () { return Effect.gen(function* () {
self.predeclareLexical(program.body)
self.hoistFunctions(program.body) self.hoistFunctions(program.body)
let value: unknown = undefined let value: unknown = undefined
for (const [index, statement] of program.body.entries()) { for (const [index, statement] of program.body.entries()) {
@@ -318,7 +303,6 @@ export class Interpreter<R> {
const self = this const self = this
return Effect.gen(function* () { return Effect.gen(function* () {
const body = getArray(node, "body") const body = getArray(node, "body")
self.predeclareLexical(body)
self.hoistFunctions(body) self.hoistFunctions(body)
for (const statementValue of body) { for (const statementValue of body) {
@@ -359,25 +343,6 @@ 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> { private evaluateIfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
const testNode = getNode(node, "test") const testNode = getNode(node, "test")
const consequentNode = getNode(node, "consequent") const consequentNode = getNode(node, "consequent")
@@ -394,6 +359,7 @@ export class Interpreter<R> {
private evaluateSwitchStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> { private evaluateSwitchStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
const self = this const self = this
this.scopes.push()
return Effect.gen(function* () { return Effect.gen(function* () {
const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant")) const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant"))
if (containsOpaqueReference(discriminant)) { if (containsOpaqueReference(discriminant)) {
@@ -403,43 +369,39 @@ export class Interpreter<R> {
"InvalidDataValue", "InvalidDataValue",
) )
} }
self.scopes.push() const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`))
return yield* Effect.gen(function* () { let defaultIndex: number | undefined
const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`)) let selected: number | undefined
self.predeclareLexical(cases.flatMap((branch) => getArray(branch, "consequent"))) for (const [index, branch] of cases.entries()) {
let defaultIndex: number | undefined const test = getOptionalNode(branch, "test")
let selected: number | undefined if (!test) {
for (const [index, branch] of cases.entries()) { defaultIndex = index
const test = getOptionalNode(branch, "test") continue
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 start = selected ?? defaultIndex const candidate = yield* self.evaluateExpression(test)
if (start === undefined) return { kind: "none" } satisfies StatementResult if (containsOpaqueReference(candidate)) {
for (let index = start; index < cases.length; index += 1) { throw new InterpreterRuntimeError(
for (const statementValue of getArray(cases[index]!, "consequent")) { "Switch case values must be data values in CodeMode.",
const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) test,
if (result.kind === "break") return { kind: "none" } satisfies StatementResult "InvalidDataValue",
if (result.kind === "return" || result.kind === "continue") return result )
}
} }
return { kind: "none" } satisfies StatementResult if (candidate === discriminant) {
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) 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())))
} }
private evaluateWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> { private evaluateWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
@@ -503,10 +465,6 @@ export class Interpreter<R> {
const updateNode = getOptionalNode(node, "update") const updateNode = getOptionalNode(node, "update")
const bodyNode = getNode(node, "body") const bodyNode = getNode(node, "body")
if (initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var") {
self.predeclareLexical([initNode])
}
if (initNode) { if (initNode) {
if (initNode.type === "VariableDeclaration") { if (initNode.type === "VariableDeclaration") {
yield* self.evaluateVariableDeclaration(initNode) yield* self.evaluateVariableDeclaration(initNode)
@@ -520,18 +478,21 @@ export class Interpreter<R> {
? Array.from(self.scopes.current().keys()) ? Array.from(self.scopes.current().keys())
: [] : []
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) { while (testNode ? yield* self.evaluateExpression(testNode) : true) {
const result = yield* self.evaluateStatement(bodyNode) 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()
}),
),
)
if (result.kind === "return") { if (result.kind === "return") {
return result return result
@@ -541,7 +502,13 @@ export class Interpreter<R> {
return { kind: "none" } satisfies StatementResult return { kind: "none" } satisfies StatementResult
} }
nextIteration() if (iterationScope) {
const loopScope = self.scopes.current()
for (const name of perIterationBindings) {
loopScope.set(name, { ...iterationScope.get(name)! })
}
}
if (updateNode) { if (updateNode) {
yield* self.evaluateExpression(updateNode) yield* self.evaluateExpression(updateNode)
} }
@@ -560,13 +527,9 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("for await...of is not supported.", node) 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 const self = this
return Effect.gen(function* () { return Effect.gen(function* () {
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left) const left = getNode(node, "left")
const right = yield* self.evaluateExpression(getNode(node, "right")) const right = yield* self.evaluateExpression(getNode(node, "right"))
const body = getNode(node, "body") const body = getNode(node, "body")
@@ -575,34 +538,40 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node) 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 let assignment: AstNode | undefined
if ( if (left.type === "VariableDeclaration") {
left.type !== "VariableDeclaration" && const declarations = getArray(left, "declarations")
(left.type === "Identifier" || if (declarations.length !== 1) {
left.type === "MemberExpression" || throw new InterpreterRuntimeError("for...of supports one declared binding.", left)
left.type === "ArrayPattern" || }
left.type === "ObjectPattern")
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"
) { ) {
assignment = left assignment = left
} else if (left.type !== "VariableDeclaration") { } else {
throw new InterpreterRuntimeError("Unsupported for...of binding.", left) throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
} }
for (const value of iterable) { for (const value of iterable) {
const result = yield* Effect.gen(function* () { if (declaration) {
if (declared) { self.scopes.push()
self.scopes.push() yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left)
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left) } else if (assignment) {
yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical) yield* self.assignPattern(assignment, value, left)
} else if (assignment) { }
yield* self.assignPattern(assignment, value, left)
} const result = yield* self.evaluateStatement(body).pipe(
return yield* self.evaluateStatement(body)
}).pipe(
Effect.ensuring( Effect.ensuring(
Effect.sync(() => { Effect.sync(() => {
if (declared) self.scopes.pop() if (declaration) self.scopes.pop()
}), }),
), ),
) )
@@ -612,7 +581,7 @@ export class Interpreter<R> {
} }
if (result.kind === "break") { if (result.kind === "break") {
return { kind: "none" } satisfies StatementResult return { kind: "none" }
} }
if (result.kind === "continue") { if (result.kind === "continue") {
@@ -620,14 +589,8 @@ export class Interpreter<R> {
} }
} }
return { kind: "none" } satisfies StatementResult return { kind: "none" }
}).pipe( })
Effect.ensuring(
Effect.sync(() => {
if (declared?.lexical) self.scopes.pop()
}),
),
)
} }
private enumerableKeys(value: unknown): Array<string> | undefined { private enumerableKeys(value: unknown): Array<string> | undefined {
@@ -644,13 +607,9 @@ export class Interpreter<R> {
} }
private evaluateForInStatement(node: AstNode): Effect.Effect<StatementResult, unknown, 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 const self = this
return Effect.gen(function* () { return Effect.gen(function* () {
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left) const left = getNode(node, "left")
const right = yield* self.evaluateExpression(getNode(node, "right")) const right = yield* self.evaluateExpression(getNode(node, "right"))
const body = getNode(node, "body") const body = getNode(node, "body")
@@ -662,28 +621,35 @@ export class Interpreter<R> {
) )
} }
let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
let assignmentName: string | undefined let assignmentName: string | undefined
if (left.type === "Identifier") { 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") {
assignmentName = getString(left, "name") assignmentName = getString(left, "name")
} else if (left.type !== "VariableDeclaration") { } else {
throw new InterpreterRuntimeError("Unsupported for...in binding.", left) throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
} }
for (const key of keys) { for (const key of keys) {
const result = yield* Effect.gen(function* () { if (declaration) {
if (declared) { self.scopes.push()
self.scopes.push() yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left)
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left) } else if (assignmentName) {
yield* self.declarePattern(declared.pattern, key, declared.mutable, left, declared.lexical) self.scopes.set(assignmentName, key, left)
} else if (assignmentName) { }
self.scopes.set(assignmentName, key, left)
} const result = yield* self.evaluateStatement(body).pipe(
return yield* self.evaluateStatement(body)
}).pipe(
Effect.ensuring( Effect.ensuring(
Effect.sync(() => { Effect.sync(() => {
if (declared) self.scopes.pop() if (declaration) self.scopes.pop()
}), }),
), ),
) )
@@ -693,7 +659,7 @@ export class Interpreter<R> {
} }
if (result.kind === "break") { if (result.kind === "break") {
return { kind: "none" } satisfies StatementResult return { kind: "none" }
} }
if (result.kind === "continue") { if (result.kind === "continue") {
@@ -701,14 +667,8 @@ export class Interpreter<R> {
} }
} }
return { kind: "none" } satisfies StatementResult return { kind: "none" }
}).pipe( })
Effect.ensuring(
Effect.sync(() => {
if (declared?.lexical) self.scopes.pop()
}),
),
)
} }
private evaluateBreakStatement(node: AstNode): StatementResult { private evaluateBreakStatement(node: AstNode): StatementResult {
@@ -792,7 +752,7 @@ export class Interpreter<R> {
const init = getOptionalNode(declaration, "init") const init = getOptionalNode(declaration, "init")
const value = init ? yield* self.evaluateExpression(init) : undefined const value = init ? yield* self.evaluateExpression(init) : undefined
yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration, kind !== "var") yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration)
} }
}) })
} }
@@ -802,20 +762,17 @@ export class Interpreter<R> {
value: unknown, value: unknown,
mutable: boolean, mutable: boolean,
node: AstNode, node: AstNode,
initialize = false,
): Effect.Effect<void, unknown, R> { ): Effect.Effect<void, unknown, R> {
const self = this const self = this
return Effect.gen(function* () { return Effect.gen(function* () {
if (pattern.type === "Identifier") { if (pattern.type === "Identifier") {
const name = getString(pattern, "name") self.scopes.declare(getString(pattern, "name"), value, mutable, node)
if (initialize) self.scopes.initialize(name, value, node)
else self.scopes.declare(name, value, mutable, node)
return return
} }
if (pattern.type === "AssignmentPattern") { if (pattern.type === "AssignmentPattern") {
const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value
yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node, initialize) yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node)
return return
} }
@@ -837,7 +794,7 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(value as SafeObject)) { for (const [key, item] of Object.entries(value as SafeObject)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
} }
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize) yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property)
continue continue
} }
@@ -851,7 +808,6 @@ export class Interpreter<R> {
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key), self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
mutable, mutable,
property, property,
initialize,
) )
} }
return return
@@ -867,10 +823,10 @@ export class Interpreter<R> {
if (item === null) continue if (item === null) continue
const element = asNode(item, `elements[${index}]`) const element = asNode(item, `elements[${index}]`)
if (element.type === "RestElement") { if (element.type === "RestElement") {
yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element, initialize) yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element)
break break
} }
yield* self.declarePattern(element, items[index], mutable, pattern, initialize) yield* self.declarePattern(element, items[index], mutable, pattern)
} }
return return
} }
@@ -1621,10 +1577,10 @@ export class Interpreter<R> {
} }
for (const [index, parameter] of fn.parameters.entries()) { for (const [index, parameter] of fn.parameters.entries()) {
if (parameter.type === "RestElement") { if (parameter.type === "RestElement") {
yield* invocation.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter, true) yield* invocation.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter)
break break
} }
yield* invocation.declarePattern(parameter, args[index], true, parameter, true) yield* invocation.declarePattern(parameter, args[index], true, parameter)
} }
if (fn.body.type === "BlockStatement") { if (fn.body.type === "BlockStatement") {
+4 -22
View File
@@ -7,28 +7,14 @@ export class ScopeStack {
this.scopes = scopes this.scopes = scopes
} }
reserve(name: string, 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: 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 { declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
const scope = this.current() const scope = this.current()
if (scope.has(name)) {
const existing = scope.get(name)
if (existing && existing.initialized !== false) {
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node) throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
} }
scope.set(name, { mutable, value, initialized: true }) scope.set(name, { mutable, value, initialized: true })
} }
@@ -53,10 +39,6 @@ export class ScopeStack {
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") 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) { if (!binding.mutable) {
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError") throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError")
} }
@@ -1,188 +0,0 @@
/*
* 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"])
})
})
+10 -63
View File
@@ -99,66 +99,12 @@ 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", () => { describe("unary void", () => {
test("evaluates its operand and returns undefined", async () => { test("evaluates its operand and returns undefined", async () => {
expect( expect(await value(`let count = 0; const result = void (count += 1); return [count, result === undefined]`)).toEqual([
await value(`let count = 0; const result = void (count += 1); return [count, result === undefined]`), 1,
).toEqual([1, true]) true,
])
}) })
test("discards opaque values", async () => { test("discards opaque values", async () => {
@@ -191,11 +137,12 @@ describe("property deletion", () => {
}) })
test("deleting an array index creates a hole without changing its length", async () => { test("deleting an array index creates a hole without changing its length", async () => {
expect( expect(await value(`const values = [1, 2, 3]; const removed = delete values[1]; return [removed, values.length, 1 in values, values]`)).toEqual([
await value( true,
`const values = [1, 2, 3]; const removed = delete values[1]; return [removed, values.length, 1 in values, values]`, 3,
), false,
).toEqual([true, 3, false, [1, null, 3]]) [1, null, 3],
])
}) })
test("array length is not configurable", async () => { test("array length is not configurable", async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.18.3", "version": "1.18.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.18.3", "version": "1.18.2",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.18.3", "version": "1.18.2",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.18.3", "version": "1.18.2",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.18.3", "version": "1.18.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.18.3", "version": "1.18.2",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1
View File
@@ -9,6 +9,7 @@ export class Keep extends Schema.Class<Keep>("ConfigV2.Compaction.Keep")({
export class Info extends Schema.Class<Info>("ConfigV2.Compaction")({ export class Info extends Schema.Class<Info>("ConfigV2.Compaction")({
auto: Schema.Boolean.pipe(Schema.optional), auto: Schema.Boolean.pipe(Schema.optional),
prune: Schema.Boolean.pipe(Schema.optional),
keep: Keep.pipe(Schema.optional), keep: Keep.pipe(Schema.optional),
buffer: NonNegativeInt.pipe(Schema.optional), buffer: NonNegativeInt.pipe(Schema.optional),
}) {} }) {}
+19 -38
View File
@@ -168,12 +168,6 @@ export interface Interface extends State.Transformable<Draft> {
readonly resolve: ( readonly resolve: (
connection: IntegrationConnection.Info, connection: IntegrationConnection.Info,
) => Effect.Effect<Credential.Value | undefined, AuthorizationError> ) => 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. */ /** Runs a key method and stores the resulting credential. */
readonly key: (input: { readonly key: (input: {
/** Integration receiving the credential. */ /** Integration receiving the credential. */
@@ -662,34 +656,6 @@ const layer = Layer.effect(
return CommandAttempt.make({ attemptID, time }) 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({ return Service.of({
transform: state.transform, transform: state.transform,
reload: state.reload, reload: state.reload,
@@ -709,10 +675,25 @@ const layer = Layer.effect(
const entry = state.get().integrations.get(id) const entry = state.get().integrations.get(id)
return resolveConnections(entry, yield* credentials.list(id))[0] return resolveConnections(entry, yield* credentials.list(id))[0]
}), }),
resolve: Effect.fn("Integration.connection.resolve")((connection) => resolveConnection(connection)), resolve: Effect.fn("Integration.connection.resolve")(function* (connection) {
resolveWithRefresh: Effect.fn("Integration.connection.resolveWithRefresh")((connection, methodID, refresh) => if (connection.type === "env") {
resolveConnection(connection, { methodID, refresh }), 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
}),
key: Effect.fn("Integration.connection.key")(function* (input) { key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state const method = state
.get() .get()
+1 -1
View File
@@ -176,7 +176,6 @@ export const OpenAIPlugin = define({
draft.method.update(browser) draft.method.update(browser)
draft.method.update(headless) draft.method.update(headless)
}) })
yield* load()
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (!ProviderV2.isAISDK(item.provider.package)) continue if (!ProviderV2.isAISDK(item.provider.package)) continue
@@ -214,6 +213,7 @@ export const OpenAIPlugin = define({
Stream.runForEach(refresh), Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
) )
yield* refresh().pipe(Effect.forkScoped)
yield* ctx.aisdk.hook( yield* ctx.aisdk.hook(
"sdk", "sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
+8 -29
View File
@@ -2,7 +2,7 @@ import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect" import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import type { CredentialOAuth, CredentialValue } from "@opencode-ai/sdk/v2/types" import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { Credential } from "../../credential" import { Credential } from "../../credential"
@@ -79,40 +79,24 @@ function oauth(http: HttpClient.HttpClient) {
label: (credential) => { label: (credential) => {
return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined
}, },
} satisfies Integration.OAuthImplementation } satisfies IntegrationOAuthMethodRegistration
} }
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Integration.Service | Scope.Scope>({ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
id: "opencode.provider.opencode", id: "opencode.provider.opencode",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const http = yield* HttpClient.HttpClient 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) const loading = Semaphore.makeUnsafe(1)
let connected = false let connected = false
let remoteConfigRequired = false
let providers: typeof ConfigV1.Info.Type.provider | undefined let providers: typeof ConfigV1.Info.Type.provider | undefined
const load = Effect.fn("OpencodePlugin.load")(function* () { const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* integrations.connection.active(Integration.ID.make("opencode")) const connection = yield* ctx.integration.connection.active("opencode")
const credential = connection const credential = connection
? yield* integrations.connection ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
.resolveWithRefresh(connection, method.method.id, method.refresh)
.pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined : undefined
connected = connection !== 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 providers = credential
? yield* fetchProviders(http, credential).pipe( ? yield* fetchProviders(http, credential).pipe(
Effect.catch((cause) => Effect.catch((cause) =>
@@ -126,11 +110,11 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | I
draft.update("opencode", (integration) => { draft.update("opencode", (integration) => {
integration.name = "OpenCode" integration.name = "OpenCode"
}) })
draft.method.update(registration) draft.method.update(oauth(http))
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } }) draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
}) })
yield* load() connected = (yield* ctx.integration.connection.active("opencode")) !== undefined
yield* ctx.catalog.transform((catalog) => { yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) { for (const [providerID, item] of Object.entries(providers ?? {})) {
catalog.provider.update(providerID, (provider) => { catalog.provider.update(providerID, (provider) => {
@@ -191,12 +175,6 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | I
const item = catalog.provider.get(ProviderV2.ID.opencode) const item = catalog.provider.get(ProviderV2.ID.opencode)
if (!item) return 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) const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
catalog.provider.update(item.provider.id, (provider) => { catalog.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" } if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" }
@@ -216,6 +194,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | I
Stream.runForEach(refresh), Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
) )
yield* refresh()
}), }),
}) })
+6 -5
View File
@@ -210,11 +210,12 @@ export interface Interface {
*/ */
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError> readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
/** /**
* Durable, ordered session log read. Replays durable session events after * Durable, ordered session log read. Replays public durable
* the exclusive `after` cursor, emits a `Synced` marker at the captured * session events after the exclusive `after` cursor, emits a `Synced`
* replay watermark, then continues live when `follow` is set. * marker at the captured replay watermark, then continues live when `follow`
* The marker's seq may exceed the last emitted event because other durable * is set.
* events share the aggregate's sequence space. * The marker's seq may exceed the last emitted event because non-public
* durable events share the aggregate's sequence space.
*/ */
readonly log: (input: { readonly log: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
+8 -33
View File
@@ -14,8 +14,6 @@ import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error" import { toSessionError } from "./to-session-error"
import { Token } from "../util/token" import { Token } from "../util/token"
import type { ModelV2 } from "../model"
import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000 const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 8_000 const DEFAULT_KEEP_TOKENS = 8_000
@@ -72,7 +70,6 @@ export type AutoInput = {
readonly session: SessionSchema.Info readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[] readonly messages: readonly SessionMessage.Info[]
readonly model: Model readonly model: Model
readonly cost: ModelV2.Info["cost"]
} }
export type ManualInput = { export type ManualInput = {
@@ -84,7 +81,6 @@ export type ManualInput = {
type Plan = { type Plan = {
readonly session: SessionSchema.Info readonly session: SessionSchema.Info
readonly model: Model readonly model: Model
readonly cost: ModelV2.Info["cost"]
readonly reason: SessionMessage.Compaction["reason"] readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string readonly prompt: string
readonly recent: string readonly recent: string
@@ -244,16 +240,6 @@ const make = (dependencies: Dependencies) => {
const chunks: string[] = [] const chunks: string[] = []
let failure: SessionError.Error | undefined 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 yield* dependencies.llm
.stream( .stream(
LLM.request({ LLM.request({
@@ -277,10 +263,6 @@ const make = (dependencies: Dependencies) => {
text: event.text, 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 return Effect.void
}), }),
Effect.catchTag("LLM.Error", (error) => Effect.catchTag("LLM.Error", (error) =>
@@ -289,21 +271,16 @@ const make = (dependencies: Dependencies) => {
}), }),
), ),
Effect.onInterrupt(() => Effect.onInterrupt(() =>
recordUsage.pipe( plan.reason === "auto"
Effect.andThen( ? failed({
plan.reason === "auto" sessionID: plan.session.id,
? failed({ reason: plan.reason,
sessionID: plan.session.id, error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
reason: plan.reason, inputID: plan.inputID,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, }).pipe(Effect.asVoid)
inputID: plan.inputID, : Effect.void,
}).pipe(Effect.asVoid)
: Effect.void,
),
),
), ),
) )
yield* recordUsage
const summary = chunks.join("") const summary = chunks.join("")
if (failure || !summary.trim()) { if (failure || !summary.trim()) {
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" } const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
@@ -328,7 +305,6 @@ const make = (dependencies: Dependencies) => {
return yield* execute({ return yield* execute({
session: input.session, session: input.session,
model: input.model, model: input.model,
cost: input.cost,
reason: "auto", reason: "auto",
...content, ...content,
}) })
@@ -377,7 +353,6 @@ const make = (dependencies: Dependencies) => {
return yield* execute({ return yield* execute({
session: input.session, session: input.session,
model: resolved.model, model: resolved.model,
cost: resolved.cost,
reason: "manual", reason: "manual",
inputID: input.inputID, inputID: input.inputID,
...content, ...content,
@@ -142,7 +142,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return Effect.gen(function* () { return Effect.gen(function* () {
yield* SessionEvent.All.match(event, { yield* SessionEvent.All.match(event, {
"session.usage.updated": () => Effect.void, "session.usage.updated": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => { "session.agent.selected": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.AgentSelected.make({ SessionMessage.AgentSelected.make({
+1 -2
View File
@@ -609,7 +609,6 @@ const layer = Layer.effectDiscard(
.run() .run()
.pipe(Effect.orDie), .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.Forked, (event) => projectFork(db, event))
yield* events.project(SessionEvent.InputPromoted, (event) => yield* events.project(SessionEvent.InputPromoted, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -782,7 +781,7 @@ const layer = Layer.effectDiscard(
yield* InstructionState.reset(db, event.data.sessionID) yield* InstructionState.reset(db, event.data.sessionID)
}), }),
) )
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed, SessionEvent.UsageRecorded]).pipe( yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe(
Stream.runForEach((event) => { Stream.runForEach((event) => {
if ( if (
event.type === SessionEvent.Step.Failed.type && event.type === SessionEvent.Step.Failed.type &&
+29 -5
View File
@@ -2,9 +2,11 @@ export * as SessionRunnerLLM from "./llm"
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai" import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error" 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 { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { Database } from "../../database/database" import { Database } from "../../database/database"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission" import { PermissionV2 } from "../../permission"
import { QuestionTool } from "../../tool/question" import { QuestionTool } from "../../tool/question"
import { ToolOutputStore } from "../../tool-output-store" import { ToolOutputStore } from "../../tool-output-store"
@@ -26,7 +28,30 @@ import { llmClient } from "../../effect/app-node-platform"
import { StepFailedError } from "../error" import { StepFailedError } from "../error"
import { toSessionError } from "../to-session-error" import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry" import { SessionRunnerRetry } from "./retry"
import { SessionUsage } from "../usage"
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,
)
}
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
@@ -102,7 +127,7 @@ const layer = Layer.effect(
const agent = loaded.agent const agent = loaded.agent
const resolved = loaded.model const resolved = loaded.model
const model = resolved.model const model = resolved.model
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost } const compactionInput = { session, messages: loaded.messages, model }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) { if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput) const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
@@ -191,7 +216,7 @@ const layer = Layer.effect(
) )
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({ const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens), cost: calculateCost(resolved.cost, settlement.tokens),
tokens: settlement.tokens, tokens: settlement.tokens,
}) })
@@ -233,8 +258,7 @@ const layer = Layer.effect(
recoverOverflow && recoverOverflow &&
!publisher.hasRetryEvidence() && !publisher.hasRetryEvidence() &&
isContextOverflowFailure(overflowFailure ?? streamFailure) && isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(recoverOverflow({ session, messages: loaded.messages, model, cost: resolved.cost }))) (yield* restore(recoverOverflow({ session, messages: loaded.messages, model }))).status === "completed"
.status === "completed"
) )
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
@@ -1,4 +1,4 @@
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai" import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/ai"
import { Effect } from "effect" import { Effect } from "effect"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
@@ -9,7 +9,6 @@ import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { AgentV2 } from "../../agent" import { AgentV2 } from "../../agent"
import { Snapshot } from "../../snapshot" import { Snapshot } from "../../snapshot"
import { SessionUsage } from "../usage"
type Input = { type Input = {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
@@ -20,6 +19,20 @@ type Input = {
readonly assistantMessageID?: SessionMessage.ID 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> => const record = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value } typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
@@ -64,7 +77,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
let stepSettlement: let stepSettlement:
| { | {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"] readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
readonly tokens: ReturnType<typeof SessionUsage.tokens> readonly tokens: ReturnType<typeof tokens>
} }
| undefined | undefined
@@ -238,7 +251,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const publishStepFailure = Effect.fnUntraced(function* (usage?: { const publishStepFailure = Effect.fnUntraced(function* (usage?: {
readonly cost: Money.USD readonly cost: Money.USD
readonly tokens: ReturnType<typeof SessionUsage.tokens> readonly tokens: ReturnType<typeof tokens>
}) { }) {
if (stepFailed || stepFailure === undefined) return if (stepFailed || stepFailure === undefined) return
const assistantMessageID = yield* startAssistant() const assistantMessageID = yield* startAssistant()
@@ -416,7 +429,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
case "step-finish": case "step-finish":
yield* flush() yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish")) if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason, tokens: SessionUsage.tokens(event.usage) } stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
if (event.reason === "content-filter") { if (event.reason === "content-filter") {
providerFailed = true providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true) yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)
+1 -18
View File
@@ -1,7 +1,7 @@
export * as SessionTitle from "./title" export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai" import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { Context, Effect, Layer, Stream } from "effect" import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { Database } from "../database/database" import { Database } from "../database/database"
import { EventV2 } from "../event" import { EventV2 } from "../event"
@@ -12,7 +12,6 @@ import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers" import { SessionModelHeaders } from "./model-headers"
import { SessionRunnerModel } from "./runner/model" import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
import { SessionUsage } from "./usage"
const MAX_LENGTH = 100 const MAX_LENGTH = 100
@@ -52,16 +51,6 @@ const make = (dependencies: Dependencies) => {
if (!resolved) return if (!resolved) return
const chunks: string[] = [] const chunks: string[] = []
let failed = false 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 const streamed = yield* dependencies.llm
.stream( .stream(
LLM.request({ LLM.request({
@@ -76,17 +65,11 @@ const make = (dependencies: Dependencies) => {
Stream.runForEach((event) => { Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) chunks.push(event.text) 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 return Effect.void
}), }),
Effect.as(true), Effect.as(true),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)), Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
) )
yield* recordUsage
if (!streamed || failed) return if (!streamed || failed) return
const title = chunks const title = chunks
.join("") .join("")
-55
View File
@@ -1,55 +0,0 @@
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,
},
},
})
+19 -1
View File
@@ -17,6 +17,7 @@ export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000 export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024 export const MAX_CAPTURE_BYTES = 1024 * 1024
export const PROGRESS_LINES = 25
const BACKGROUND_STARTED = "The command was moved to the background." const BACKGROUND_STARTED = "The command was moved to the background."
const BACKGROUND_INSTRUCTION = const BACKGROUND_INSTRUCTION =
@@ -210,6 +211,23 @@ export const Plugin = {
} }
}) })
const captureProgress = Effect.fn("ShellTool.captureProgress")(function* () {
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const start = Math.max(0, latest.size - MAX_CAPTURE_BYTES)
const page = yield* shell.output(info.id, { cursor: start, limit: MAX_CAPTURE_BYTES })
const trailingNewline = page.output.endsWith("\n")
const lines = trailingNewline ? page.output.split("\n").slice(0, -1) : page.output.split("\n")
const truncated = start > 0 || lines.length > PROGRESS_LINES
const output = lines.slice(-PROGRESS_LINES).join("\n") + (trailingNewline ? "\n" : "")
const notice = truncated
? `[output truncated; showing last ${PROGRESS_LINES} lines. Full output saved to: ${info.file}]\n\n`
: ""
return {
output: `${notice}${output || "(no output)"}`,
truncated,
}
})
const settleShell = Effect.fn("ShellTool.settleShell")(function* () { const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id) const final = yield* shell.wait(info.id)
@@ -258,7 +276,7 @@ export const Plugin = {
const progress = yield* Effect.sleep("1 second").pipe( const progress = yield* Effect.sleep("1 second").pipe(
Effect.andThen( Effect.andThen(
captureShell().pipe( captureProgress().pipe(
Effect.flatMap((capture) => Effect.flatMap((capture) =>
context.progress({ context.progress({
structured: { truncated: capture.truncated }, structured: { truncated: capture.truncated },
+2
View File
@@ -754,6 +754,7 @@ describe("Config", () => {
}) })
expect(documents[0]?.info.compaction).toEqual({ expect(documents[0]?.info.compaction).toEqual({
auto: true, auto: true,
prune: false,
keep: { tokens: 2000 }, keep: { tokens: 2000 },
buffer: 10000, buffer: 10000,
}) })
@@ -967,6 +968,7 @@ describe("Config", () => {
}) })
expect(documents[0]?.info.compaction).toEqual({ expect(documents[0]?.info.compaction).toEqual({
auto: true, auto: true,
prune: undefined,
keep: { tokens: 2000 }, keep: { tokens: 2000 },
buffer: 10000, buffer: 10000,
}) })
-1
View File
@@ -188,7 +188,6 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe
connection: { connection: {
active: unusedIntegration, active: unusedIntegration,
resolve: unusedIntegration, resolve: unusedIntegration,
resolveWithRefresh: unusedIntegration,
key: unusedIntegration, key: unusedIntegration,
update: unusedIntegration, update: unusedIntegration,
remove: unusedIntegration, remove: unusedIntegration,
@@ -29,6 +29,20 @@ function required<T>(value: T | undefined): T {
return value 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[]) { function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => { const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`) calls.push(`${method}:${id}`)
@@ -205,8 +219,12 @@ describe("OpenAIPlugin", () => {
}) })
yield* addPlugin() yield* addPlugin()
const eligible = required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))) const eligible = required(
expect(eligible.cost).toEqual([]) yield* eventually(
catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")),
(model) => model?.cost.length === 0,
),
)
expect(eligible.enabled).toBe(true) expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe( expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe(
false, false,
@@ -242,6 +260,9 @@ describe("OpenAIPlugin", () => {
value: Credential.Key.make({ type: "key", key: "sk-test" }), value: Credential.Key.make({ type: "key", key: "sk-test" }),
}) })
yield* addPlugin() 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-5.5"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true) expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true)
@@ -10,7 +10,6 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { PluginTestLayer } from "./fixture"
@@ -21,11 +20,9 @@ const addPlugin = Effect.fn(function* () {
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
const events = yield* EventV2.Service const events = yield* EventV2.Service
const integration = yield* Integration.Service const integration = yield* Integration.Service
yield* State.batch( yield* OpencodePlugin.effect(host).pipe(
OpencodePlugin.effect(host).pipe( Effect.provideService(EventV2.Service, events),
Effect.provideService(EventV2.Service, events), Effect.provideService(Integration.Service, integration),
Effect.provideService(Integration.Service, integration),
),
) )
}) })
@@ -80,50 +77,6 @@ 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", () => { describe("OpencodePlugin", () => {
it.effect("registers account and service account methods", () => it.effect("registers account and service account methods", () =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -330,176 +283,6 @@ 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", () => it.effect("uses a public key and disables paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
+2 -35
View File
@@ -21,7 +21,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect" import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@@ -32,44 +31,17 @@ const model = Model.make({
provider: "test", provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }), 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)({ const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"), prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => { stream: (request: LLMRequest) => {
requests.push(request) requests.push(request)
return Stream.make( return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual summary" }))
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"), generate: () => Effect.die("unused"),
}) })
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({ const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)), resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
}) })
const it = testEffect( const it = testEffect(
AppNodeBuilder.build( AppNodeBuilder.build(
@@ -197,10 +169,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(yield* store.context(sessionID)).toMatchObject([ expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" }, { 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( expect(
yield* db yield* db
.select({ type: EventTable.type }) .select({ type: EventTable.type })
@@ -211,7 +179,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
.pipe(Effect.orDie), .pipe(Effect.orDie),
).toEqual([ ).toEqual([
{ type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) }, { type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) },
{ type: EventV2.versionedType(SessionEvent.UsageRecorded.type, 1) },
{ type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1) }, { type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1) },
]) ])
}), }),
+2 -3
View File
@@ -42,7 +42,6 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator
import { SessionRunner } from "@opencode-ai/core/session/runner" import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" 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 { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
@@ -166,7 +165,7 @@ const recoveryModel = Model.make({
test("calculates step cost using the matching context tier", () => { test("calculates step cost using the matching context tier", () => {
expect( expect(
SessionUsage.calculateCost( SessionRunnerLLM.calculateCost(
[ [
{ {
input: Money.USDPerMillionTokens.make(1), input: Money.USDPerMillionTokens.make(1),
@@ -193,7 +192,7 @@ test("calculates step cost using the matching context tier", () => {
test("does not apply an ineligible tier without base pricing", () => { test("does not apply an ineligible tier without base pricing", () => {
expect( expect(
SessionUsage.calculateCost( SessionRunnerLLM.calculateCost(
[ [
{ {
tier: { type: "context", size: 100 }, tier: { type: "context", size: 100 },
+3 -33
View File
@@ -20,8 +20,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money" import { DateTime, Effect, Layer, Stream } from "effect"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
let requests: LLMRequest[] = [] let requests: LLMRequest[] = []
@@ -30,43 +29,16 @@ const model = Model.make({
provider: "test", provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }), 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)({ const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"), prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => { stream: (request: LLMRequest) => {
requests.push(request) requests.push(request)
return Stream.make( return Stream.make(LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }))
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"), generate: () => Effect.die("unused"),
}) })
const models = Layer.mock(SessionRunnerModel.Service)({ const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)), resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
}) })
const it = testEffect( const it = testEffect(
AppNodeBuilder.build( AppNodeBuilder.build(
@@ -158,8 +130,6 @@ 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") expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
const renamed = yield* store.get(sessionID) const renamed = yield* store.get(sessionID)
expect(renamed?.title).toBe("Generated Title") 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)
}), }),
) )
+11 -8
View File
@@ -166,10 +166,10 @@ const overflowCommand = (bytes: number) =>
isWindows isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100` ? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'` : `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
const progressOverflowCommand = (bytes: number) => const progressLinesCommand = (lines: number) =>
isWindows isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500` ? `1..${lines} | ForEach-Object { [Console]::Out.WriteLine(('line {0:d2}' -f $_)) }; Start-Sleep -Milliseconds 1500`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5` : `i=1; while [ $i -le ${lines} ]; do printf 'line %02d\\n' "$i"; i=$((i+1)); done; sleep 1.5`
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -417,17 +417,17 @@ describe("ShellTool", () => {
), ),
) )
it.live("reports bounded output progress for a running command", () => it.live("reports the latest output lines for a running command", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => { (tmp) => {
reset() reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024 const lines = ShellTool.PROGRESS_LINES + 10
return withSession(tmp.path, (registry) => return withSession(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
const progress: ToolRegistry.Progress[] = [] const progress: ToolRegistry.Progress[] = []
yield* settleTool(registry, { yield* settleTool(registry, {
...call({ command: progressOverflowCommand(bytes) }, "call-progress"), ...call({ command: progressLinesCommand(lines) }, "call-progress"),
progress: (update) => Effect.sync(() => progress.push(update)), progress: (update) => Effect.sync(() => progress.push(update)),
}) })
@@ -436,9 +436,12 @@ describe("ShellTool", () => {
const content = progress[0]?.content[0] const content = progress[0]?.content[0]
expect(content?.type).toBe("text") expect(content?.type).toBe("text")
if (content?.type !== "text") return if (content?.type !== "text") return
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe( expect(content.text).toStartWith(
ShellTool.MAX_CAPTURE_BYTES, `[output truncated; showing last ${ShellTool.PROGRESS_LINES} lines. Full output saved to:`,
) )
expect(content.text).not.toContain("line 10\n")
expect(content.text).toContain("line 11\n")
expect(content.text).toContain(`line ${lines}\n`)
}), }),
) )
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.18.3", "version": "1.18.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+1 -2
View File
@@ -382,8 +382,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
function App() { function App() {
const wslServers = useWslServers() const wslServers = useWslServers()
const ready = createMemo( 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 servers = createMemo(() => {
const data = initializationData(sidecar) const data = initializationData(sidecar)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.18.3", "version": "1.18.2",
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.18.3", "version": "1.18.2",
"name": "@opencode-ai/effect-sqlite-node", "name": "@opencode-ai/effect-sqlite-node",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.18.3", "version": "1.18.2",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.18.3", "version": "1.18.2",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.18.3", "version": "1.18.2",
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes", "description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.18.3", "version": "1.18.2",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+2 -4
View File
@@ -277,7 +277,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
}, },
} }
}), }),
"azure-cognitive-services": Effect.fnUntraced(function* (provider: Info) { "azure-cognitive-services": Effect.fnUntraced(function* () {
const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME") const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
return { return {
autoload: false, autoload: false,
@@ -285,9 +285,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
}, },
options: { options: {
baseURL: resourceName baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
? `https://${resourceName}.cognitiveservices.azure.com/openai${provider.options?.useDeploymentBasedUrls ? "" : "/v1"}`
: undefined,
}, },
} }
}), }),
@@ -1,10 +1,11 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core" import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid" import { testRender, useRenderer } from "@opentui/solid"
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import type { QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { Keymap } from "@opencode-ai/tui/context/keymap" import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { import {
RUN_COMMAND_PANEL_ROWS, RUN_COMMAND_PANEL_ROWS,
RUN_SUBAGENT_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS,
@@ -173,9 +174,15 @@ async function renderFooter(
) )
const state = footerState(input.state) const state = footerState(input.state)
const config = input.tuiConfig ?? tuiConfig const config = input.tuiConfig ?? tuiConfig
let offKeymap: (() => void) | undefined
function Harness() { function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
return ( return (
<Keymap.Provider config={config}> <OpencodeKeymapProvider keymap={keymap}>
<RunFooterView <RunFooterView
directory="/tmp" directory="/tmp"
findFiles={async () => []} findFiles={async () => []}
@@ -208,7 +215,7 @@ async function renderFooter(
onStatus={() => {}} onStatus={() => {}}
onQueuedRemove={async () => true} onQueuedRemove={async () => true}
/> />
</Keymap.Provider> </OpencodeKeymapProvider>
) )
} }
@@ -226,6 +233,8 @@ async function renderFooter(
cleanup() { cleanup() {
app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur() app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
offKeymap = undefined
app.renderer.destroy() app.renderer.destroy()
}, },
} }
@@ -632,42 +641,6 @@ 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 () => { test("direct queued prompt panel renders pending prompt actions", async () => {
const [prompts] = createSignal([ const [prompts] = createSignal([
{ messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } }, { messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } },
@@ -994,9 +967,14 @@ test("direct footer shows editable prompts and additional queued work while runn
permissions: [], permissions: [],
questions: [], questions: [],
}) })
let offKeymap: (() => void) | undefined
function Harness() { function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
return ( return (
<Keymap.Provider config={tuiConfig}> <OpencodeKeymapProvider keymap={keymap}>
<RunFooterView <RunFooterView
directory="/tmp" directory="/tmp"
findFiles={async () => []} findFiles={async () => []}
@@ -1035,7 +1013,7 @@ test("direct footer shows editable prompts and additional queued work while runn
onStatus={() => {}} onStatus={() => {}}
onQueuedRemove={async () => true} onQueuedRemove={async () => true}
/> />
</Keymap.Provider> </OpencodeKeymapProvider>
) )
} }
@@ -1071,7 +1049,7 @@ test("direct footer shows editable prompts and additional queued work while runn
expect(frame).toContain("3 queued") expect(frame).toContain("3 queued")
expect(frame).toContain("ctrl+b background") expect(frame).toContain("ctrl+b background")
expect(frame).toContain("ctrl+x q 3 queued") expect(frame).toContain("ctrl+x q 3 queued")
expect(frame).toContain(" subagents") expect(frame).toContain("ctrl+x down subagents")
expect(frame).toContain("ctrl+p cmd") expect(frame).toContain("ctrl+p cmd")
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation") expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
expect(frame).toContain("subagents · ctrl+p cmd") expect(frame).toContain("subagents · ctrl+p cmd")
@@ -1085,6 +1063,7 @@ test("direct footer shows editable prompts and additional queued work while runn
} finally { } finally {
app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur() app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
app.renderer.destroy() app.renderer.destroy()
} }
}) })
@@ -1108,7 +1087,7 @@ test("direct footer always offers backgrounding for a foreground subagent", asyn
const frame = app.captureCharFrame() const frame = app.captureCharFrame()
expect(frame).toContain("GPT-5") expect(frame).toContain("GPT-5")
expect(frame).toContain("xhigh · ctrl+b background · subagents · ctrl+p cmd") expect(frame).toContain("xhigh · ctrl+b background · ctrl+x down subagents · ctrl+p cmd")
expect(frame).toContain("ctrl+b background") expect(frame).toContain("ctrl+b background")
expect(frame).not.toContain("queued") expect(frame).not.toContain("queued")
} finally { } finally {
@@ -1136,7 +1115,7 @@ test("direct footer hides the subagent hint when only completed subagents remain
expect(frame).toContain("GPT-5") expect(frame).toContain("GPT-5")
expect(frame).toContain("xhigh · ctrl+p cmd") expect(frame).toContain("xhigh · ctrl+p cmd")
expect(frame).not.toContain(" subagents") expect(frame).not.toContain("ctrl+x down subagents")
} finally { } finally {
app.cleanup() app.cleanup()
} }
@@ -1254,9 +1233,15 @@ test.skip("direct custom answer submits through keymap return binding", async ()
], ],
} satisfies QuestionRequest } satisfies QuestionRequest
const questions: unknown[] = [] const questions: unknown[] = []
let off: (() => void) | undefined
function Harness() { function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
return ( return (
<Keymap.Provider config={tuiConfig}> <OpencodeKeymapProvider keymap={keymap}>
<RunQuestionBody <RunQuestionBody
request={question} request={question}
theme={RUN_THEME_FALLBACK.footer} theme={RUN_THEME_FALLBACK.footer}
@@ -1265,7 +1250,7 @@ test.skip("direct custom answer submits through keymap return binding", async ()
}} }}
onReject={() => {}} onReject={() => {}}
/> />
</Keymap.Provider> </OpencodeKeymapProvider>
) )
} }
@@ -1290,6 +1275,7 @@ test.skip("direct custom answer submits through keymap return binding", async ()
} finally { } finally {
app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur() app.renderer.currentFocusedEditor?.blur()
off?.()
app.renderer.destroy() app.renderer.destroy()
} }
}) })
@@ -1297,9 +1283,15 @@ test.skip("direct custom answer submits through keymap return binding", async ()
test("direct permission rejection submits through keymap return binding", async () => { test("direct permission rejection submits through keymap return binding", async () => {
let text = "" let text = ""
const submits: string[] = [] const submits: string[] = []
let off: (() => void) | undefined
function Harness() { function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
return ( return (
<Keymap.Provider config={tuiConfig}> <OpencodeKeymapProvider keymap={keymap}>
<RejectField <RejectField
theme={RUN_THEME_FALLBACK.footer} theme={RUN_THEME_FALLBACK.footer}
text="" text=""
@@ -1312,7 +1304,7 @@ test("direct permission rejection submits through keymap return binding", async
}} }}
onCancel={() => {}} onCancel={() => {}}
/> />
</Keymap.Provider> </OpencodeKeymapProvider>
) )
} }
@@ -1336,6 +1328,7 @@ test("direct permission rejection submits through keymap return binding", async
} finally { } finally {
app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur() app.renderer.currentFocusedEditor?.blur()
off?.()
app.renderer.destroy() app.renderer.destroy()
} }
}) })
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.18.3", "version": "1.18.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+3 -3
View File
@@ -19,7 +19,7 @@ import type {
ShellInfo, ShellInfo,
SkillInfo, SkillInfo,
} from "@opencode-ai/client" } from "@opencode-ai/client"
import type { KeyEvent, Renderable } from "@opentui/core" import type { Renderable } from "@opentui/core"
import type { JSX } from "@opentui/solid" import type { JSX } from "@opentui/solid"
interface LocationCollection<Value> { interface LocationCollection<Value> {
@@ -139,8 +139,8 @@ export interface KeymapCommand {
} }
/** Promotes the command in discovery UI. */ /** Promotes the command in discovery UI. */
readonly suggested?: boolean | (() => boolean) readonly suggested?: boolean | (() => boolean)
/** Executes the command. Keyboard dispatch includes its event; programmatic dispatch does not. Return false to continue. */ /** Executes the command. Return false to let keymap dispatch continue. */
readonly run: (input?: string, event?: KeyEvent) => void | false | Promise<void> readonly run: (input?: string) => void | false | Promise<void>
} }
export interface KeymapLayer { export interface KeymapLayer {
+1 -17
View File
@@ -86,18 +86,6 @@ export const Renamed = Event.durable({
}) })
export type Renamed = typeof Renamed.Type 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({ export const UsageUpdated = Event.ephemeral({
type: "session.usage.updated", type: "session.usage.updated",
schema: { schema: {
@@ -581,10 +569,8 @@ export const Definitions = Event.inventory(
RevertEvent.Committed, RevertEvent.Committed,
) )
// UsageRecorded is durable but internal: excluded from Definitions so it never reaches the public manifest.
export const DurableDefinitions = Event.inventory( export const DurableDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "durable"), ...Definitions.filter((definition) => definition.durability === "durable"),
UsageRecorded,
) )
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
@@ -592,8 +578,6 @@ export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
.annotate({ identifier: "Session.Event.Durable" }) .annotate({ identifier: "Session.Event.Durable" })
export type DurableEvent = typeof Durable.Type export type DurableEvent = typeof Durable.Type
export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe( export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
Schema.toTaggedUnion("type"),
)
export type Event = typeof All.Type export type Event = typeof All.Type
export type Type = Event["type"] export type Type = Event["type"]
+3 -11
View File
@@ -104,7 +104,6 @@ describe("public event manifest", () => {
"session.model.selected.1", "session.model.selected.1",
"session.moved.1", "session.moved.1",
"session.renamed.1", "session.renamed.1",
"session.usage.recorded.1",
"session.forked.2", "session.forked.2",
"session.input.promoted.1", "session.input.promoted.1",
"session.input.admitted.1", "session.input.admitted.1",
@@ -140,16 +139,9 @@ describe("public event manifest", () => {
"session.revert.committed.1", "session.revert.committed.1",
].toSorted(), ].toSorted(),
) )
expect(SessionEvent.DurableDefinitions).toEqual([ expect(SessionEvent.DurableDefinitions).toEqual(
...SessionEvent.Definitions.filter((definition) => definition.durability === "durable"), 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.UsageUpdated.durability).toBe("ephemeral")
expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral") expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral")
expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false) 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", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.18.3", "version": "1.18.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server", "name": "@opencode-ai/server",
"version": "1.18.3", "version": "1.18.2",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -5
View File
@@ -10,7 +10,6 @@ import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerRe
import { createServer } from "node:http" import { createServer } from "node:http"
import { ServerAuth } from "./auth" import { ServerAuth } from "./auth"
import { authorizedRequest } from "./middleware/authorization" import { authorizedRequest } from "./middleware/authorization"
import { withoutParentSpan } from "./request-tracing"
import { createRoutes } from "./routes" import { createRoutes } from "./routes"
import { ServerInfo } from "./server-info" import { ServerInfo } from "./server-info"
import { Status } from "./service-status" import { Status } from "./service-status"
@@ -40,10 +39,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
}) })
const bound = yield* listen(options) const bound = yield* listen(options)
const application = yield* Ref.make(Option.none<App>()) const application = yield* Ref.make(Option.none<App>())
// 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)
yield* bound.http
.serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger)
.pipe(withoutParentSpan)
if (options.service) yield* options.service.onListen(bound.http.address) if (options.service) yield* options.service.onListen(bound.http.address)
const parentScope = yield* Scope.Scope const parentScope = yield* Scope.Scope
-6
View File
@@ -1,6 +0,0 @@
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)),
)
@@ -1,48 +0,0 @@
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")
})

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