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