mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 09:28:27 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4778e6e031 |
@@ -53,7 +53,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@corvu/drawer": "catalog:",
|
"@corvu/drawer": "catalog:",
|
||||||
"@dnd-kit/abstract": "0.5.0",
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode2": "./bin/opencode2.cjs",
|
"opencode2": "./bin/opencode2.cjs",
|
||||||
},
|
},
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
},
|
},
|
||||||
"packages/codemode": {
|
"packages/codemode": {
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "8.15.0",
|
"acorn": "8.15.0",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -278,7 +278,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -302,7 +302,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/support": {
|
"packages/console/support": {
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@opencode-ai/console-core": "workspace:*",
|
"@opencode-ai/console-core": "workspace:*",
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -418,7 +418,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -478,7 +478,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-drizzle-sqlite": {
|
"packages/effect-drizzle-sqlite": {
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -492,7 +492,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-sqlite-node": {
|
"packages/effect-sqlite-node": {
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -504,7 +504,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "catalog:",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -536,7 +536,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -552,7 +552,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||||
},
|
},
|
||||||
@@ -584,7 +584,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -717,7 +717,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/ai": "workspace:*",
|
"@opencode-ai/ai": "workspace:*",
|
||||||
@@ -806,7 +806,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -821,7 +821,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -838,7 +838,7 @@
|
|||||||
},
|
},
|
||||||
"packages/session-ui": {
|
"packages/session-ui": {
|
||||||
"name": "@opencode-ai/session-ui",
|
"name": "@opencode-ai/session-ui",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -899,7 +899,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -912,7 +912,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
@@ -946,7 +946,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-athena": "3.933.0",
|
"@aws-sdk/client-athena": "3.933.0",
|
||||||
"@planetscale/database": "1.19.0",
|
"@planetscale/database": "1.19.0",
|
||||||
@@ -965,7 +965,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -1006,7 +1006,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -1036,7 +1036,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
@@ -1087,7 +1087,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createMemo, onCleanup } from "solid-js"
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout, type LocalProject } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useTabs } from "@/context/tabs"
|
|
||||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
export type CommandPaletteEntry = {
|
export type CommandPaletteEntry = {
|
||||||
id: string
|
id: string
|
||||||
@@ -25,8 +24,6 @@ export type CommandPaletteEntry = {
|
|||||||
path?: string
|
path?: string
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
server?: ServerConnection.Key
|
|
||||||
project?: LocalProject
|
|
||||||
archived?: number
|
archived?: number
|
||||||
updated?: number
|
updated?: number
|
||||||
}
|
}
|
||||||
@@ -78,25 +75,28 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
|
|||||||
|
|
||||||
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
|
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const global = useGlobal()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const layout = useLayout()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const navigate = useNavigate()
|
||||||
const serverSDK = useServerSDK()()
|
const serverSDK = useServerSDK()()
|
||||||
const serverCtx = global.ensureServerCtx(serverSDK.server)
|
const serverSync = useServerSync()
|
||||||
const appTabs = useTabs()
|
const { params, tabs } = useSessionLayout()
|
||||||
const { tabs: sessionTabs } = useSessionLayout()
|
|
||||||
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
const filesOnly = () => props.filesOnly?.() ?? false
|
const filesOnly = () => props.filesOnly?.() ?? false
|
||||||
|
|
||||||
const allowedCommands = createMemo(() => {
|
const allowedCommands = createMemo(() => {
|
||||||
if (filesOnly()) return []
|
if (filesOnly()) return []
|
||||||
return commandPaletteOptions(command.options)
|
return command.options.filter(
|
||||||
|
(option) =>
|
||||||
|
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
||||||
|
)
|
||||||
})
|
})
|
||||||
const commandEntries = createMemo(() => {
|
const commandEntries = createMemo(() => {
|
||||||
const category = language.t("palette.group.commands")
|
const category = language.t("palette.group.commands")
|
||||||
return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category))
|
return allowedCommands().map((option) => createCommandEntry(option, category))
|
||||||
})
|
})
|
||||||
const preferredCommandEntries = createMemo(() => {
|
const preferredCommandEntries = createMemo(() => {
|
||||||
const all = allowedCommands()
|
const all = allowedCommands()
|
||||||
@@ -105,11 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
||||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||||
const category = language.t("palette.group.commands")
|
const category = language.t("palette.group.commands")
|
||||||
return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
|
return sorted.map((option) => createCommandEntry(option, category))
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabState = createSessionTabs({
|
const tabState = createSessionTabs({
|
||||||
tabs: sessionTabs,
|
tabs,
|
||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
})
|
})
|
||||||
@@ -140,12 +140,36 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
.map((path) => createCommandPaletteFileEntry(path, category))
|
.map((path) => createCommandPaletteFileEntry(path, category))
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessions = createServerSessionEntries({
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
server: ServerConnection.key(serverSDK.server),
|
const project = createMemo(() => {
|
||||||
opened: serverCtx.projects.list,
|
const directory = projectDirectory()
|
||||||
stored: () => serverCtx.sync.data.project,
|
if (!directory) return undefined
|
||||||
load: (search, signal) =>
|
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
|
||||||
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
})
|
||||||
|
const workspaces = createMemo(() => {
|
||||||
|
const directory = projectDirectory()
|
||||||
|
const current = project()
|
||||||
|
if (!current) return directory ? [directory] : []
|
||||||
|
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
|
||||||
|
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||||
|
return dirs
|
||||||
|
})
|
||||||
|
const homedir = createMemo(() => serverSync().data.path.home)
|
||||||
|
const sessions = createSessionEntries({
|
||||||
|
workspaces,
|
||||||
|
label: (directory) => {
|
||||||
|
const current = project()
|
||||||
|
const kind =
|
||||||
|
current && directory === current.worktree
|
||||||
|
? language.t("workspace.type.local")
|
||||||
|
: language.t("workspace.type.sandbox")
|
||||||
|
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||||
|
const home = homedir()
|
||||||
|
const path = home ? directory.replace(home, "~") : directory
|
||||||
|
const name = store.vcs?.branch ?? getFilename(directory)
|
||||||
|
return `${kind} : ${name || path}`
|
||||||
|
},
|
||||||
|
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
|
||||||
untitled: () => language.t("command.session.new"),
|
untitled: () => language.t("command.session.new"),
|
||||||
category: () => language.t("command.category.session"),
|
category: () => language.t("command.category.session"),
|
||||||
})
|
})
|
||||||
@@ -167,17 +191,8 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (item.type === "session") {
|
if (item.type === "session") {
|
||||||
if (!item.sessionID || !item.server) return
|
if (!item.directory || !item.sessionID) return
|
||||||
const directory = item.project?.worktree ?? item.directory
|
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
||||||
if (directory) {
|
|
||||||
serverCtx.projects.open(directory)
|
|
||||||
serverCtx.projects.touch(directory)
|
|
||||||
}
|
|
||||||
const tab = appTabs.addSessionTab({
|
|
||||||
server: item.server,
|
|
||||||
sessionId: item.sessionID,
|
|
||||||
})
|
|
||||||
appTabs.select(tab)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!item.path) return
|
if (!item.path) return
|
||||||
@@ -203,7 +218,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
|
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
|
||||||
return {
|
return {
|
||||||
id: "command:" + option.id,
|
id: "command:" + option.id,
|
||||||
type: "command",
|
type: "command",
|
||||||
@@ -215,65 +230,96 @@ export function createCommandPaletteCommandEntry(option: CommandOption, category
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerSessionEntries(props: {
|
function createSessionEntries(props: {
|
||||||
server: ServerConnection.Key
|
workspaces: () => string[]
|
||||||
opened: () => LocalProject[]
|
label: (directory: string) => string
|
||||||
stored: () => Project[]
|
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
|
||||||
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
|
|
||||||
untitled: () => string
|
untitled: () => string
|
||||||
category: () => string
|
category: () => string
|
||||||
}) {
|
}) {
|
||||||
let abort: AbortController | undefined
|
const state: {
|
||||||
|
token: number
|
||||||
|
inflight: Promise<CommandPaletteEntry[]> | undefined
|
||||||
|
cached: CommandPaletteEntry[] | undefined
|
||||||
|
} = { token: 0, inflight: undefined, cached: undefined }
|
||||||
|
|
||||||
onCleanup(() => abort?.abort())
|
return (text: string) => {
|
||||||
|
if (!text.trim()) {
|
||||||
return async (text: string): Promise<CommandPaletteEntry[]> => {
|
state.token += 1
|
||||||
const search = text.trim()
|
state.inflight = undefined
|
||||||
if (!search) {
|
state.cached = undefined
|
||||||
abort?.abort()
|
return [] as CommandPaletteEntry[]
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
abort?.abort()
|
if (state.cached) return state.cached
|
||||||
const current = new AbortController()
|
if (state.inflight) return state.inflight
|
||||||
abort = current
|
|
||||||
await new Promise<void>((resolve) => {
|
const current = state.token
|
||||||
const timer = setTimeout(resolve, 100)
|
const dirs = props.workspaces()
|
||||||
current.signal.addEventListener(
|
if (dirs.length === 0) return [] as CommandPaletteEntry[]
|
||||||
"abort",
|
|
||||||
() => {
|
state.inflight = Promise.all(
|
||||||
clearTimeout(timer)
|
dirs.map((directory) => {
|
||||||
resolve()
|
const description = props.label(directory)
|
||||||
},
|
return props
|
||||||
{ once: true },
|
.load(directory)
|
||||||
)
|
.then((result) =>
|
||||||
})
|
(result.data ?? [])
|
||||||
if (current.signal.aborted) return []
|
.filter((session) => !!session?.id)
|
||||||
const opened = props.opened()
|
.map((session) => ({
|
||||||
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
|
id: session.id,
|
||||||
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
|
title: session.title ?? props.untitled(),
|
||||||
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
|
description,
|
||||||
return props
|
directory,
|
||||||
.load(search, current.signal)
|
archived: session.time?.archived,
|
||||||
.then((result) =>
|
updated: session.time?.updated,
|
||||||
(result.data ?? [])
|
})),
|
||||||
.filter((session) => !session.time.archived)
|
)
|
||||||
.map((session) => {
|
.catch(() => [] as SessionEntryInput[])
|
||||||
const project =
|
}),
|
||||||
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
|
)
|
||||||
return {
|
.then((results) => {
|
||||||
id: `session:${props.server}:${session.id}`,
|
if (state.token !== current) return [] as CommandPaletteEntry[]
|
||||||
type: "session" as const,
|
const seen = new Set<string>()
|
||||||
title: session.title || props.untitled(),
|
const next = results
|
||||||
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
|
.flat()
|
||||||
category: props.category(),
|
.filter((item) => {
|
||||||
directory: session.directory,
|
const key = `${item.directory}:${item.id}`
|
||||||
sessionID: session.id,
|
if (seen.has(key)) return false
|
||||||
server: props.server,
|
seen.add(key)
|
||||||
project,
|
return true
|
||||||
updated: session.time.updated,
|
})
|
||||||
}
|
.map((item) => createSessionEntry(item, props.category()))
|
||||||
}),
|
state.cached = next
|
||||||
)
|
return next
|
||||||
|
})
|
||||||
.catch(() => [] as CommandPaletteEntry[])
|
.catch(() => [] as CommandPaletteEntry[])
|
||||||
|
.finally(() => {
|
||||||
|
state.inflight = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return state.inflight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionEntryInput = {
|
||||||
|
directory: string
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
|
||||||
|
return {
|
||||||
|
id: `session:${input.directory}:${input.id}`,
|
||||||
|
type: "session",
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
category,
|
||||||
|
directory: input.directory,
|
||||||
|
sessionID: input.id,
|
||||||
|
archived: input.archived,
|
||||||
|
updated: input.updated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,13 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
|
|||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
import { formatKeybindParts } from "@/context/command"
|
||||||
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
|
|
||||||
import { useGlobal } from "@/context/global"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import { useTabs } from "@/context/tabs"
|
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
import {
|
import {
|
||||||
createCommandPaletteCommandEntry,
|
|
||||||
createCommandPaletteFileEntry,
|
createCommandPaletteFileEntry,
|
||||||
createCommandPaletteModel,
|
createCommandPaletteModel,
|
||||||
createServerSessionEntries,
|
|
||||||
uniqueCommandPaletteEntries,
|
uniqueCommandPaletteEntries,
|
||||||
type CommandPaletteEntry,
|
type CommandPaletteEntry,
|
||||||
} from "./command-palette"
|
} from "./command-palette"
|
||||||
@@ -37,6 +30,9 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
|
|||||||
|
|
||||||
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
|
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
|
||||||
const palette = createCommandPaletteModel(props)
|
const palette = createCommandPaletteModel(props)
|
||||||
|
const [query, setQuery] = createSignal("")
|
||||||
|
const [active, setActive] = createSignal(0)
|
||||||
|
|
||||||
const loadItems = async (text: string) => {
|
const loadItems = async (text: string) => {
|
||||||
const q = text.trim()
|
const q = text.trim()
|
||||||
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
||||||
@@ -45,105 +41,16 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
|
|||||||
const category = palette.language.t("palette.group.files")
|
const category = palette.language.t("palette.group.files")
|
||||||
return [
|
return [
|
||||||
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
|
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
|
||||||
...nextSessions,
|
...nextSessions.filter((entry) => matchesEntry(entry, q)),
|
||||||
...files.map((path) => createCommandPaletteFileEntry(path, category)),
|
...files.map((path) => createCommandPaletteFileEntry(path, category)),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
||||||
<CommandPaletteView
|
|
||||||
placeholder={palette.language.t("palette.search.placeholder")}
|
|
||||||
loadItems={loadItems}
|
|
||||||
highlight={palette.highlight}
|
|
||||||
select={palette.select}
|
|
||||||
close={palette.close}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogHomeCommandPaletteV2(props: {
|
|
||||||
server: ServerConnection.Any
|
|
||||||
onSelectSession: (entry: CommandPaletteEntry) => void
|
|
||||||
}) {
|
|
||||||
const command = useCommand()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const global = useGlobal()
|
|
||||||
const language = useLanguage()
|
|
||||||
const serverCtx = global.ensureServerCtx(props.server)
|
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
|
||||||
const commandEntries = createMemo(() => {
|
|
||||||
const category = language.t("palette.group.commands")
|
|
||||||
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
|
|
||||||
})
|
|
||||||
const sessions = createServerSessionEntries({
|
|
||||||
server: ServerConnection.key(props.server),
|
|
||||||
opened: serverCtx.projects.list,
|
|
||||||
stored: () => serverCtx.sync.data.project,
|
|
||||||
load: (search, signal) =>
|
|
||||||
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
|
|
||||||
untitled: () => language.t("command.session.new"),
|
|
||||||
category: () => language.t("command.category.session"),
|
|
||||||
})
|
|
||||||
|
|
||||||
const highlight = (item: CommandPaletteEntry | undefined) => {
|
|
||||||
state.cleanup?.()
|
|
||||||
state.cleanup = undefined
|
|
||||||
if (item?.type !== "command") return
|
|
||||||
state.cleanup = item.option?.onHighlight?.()
|
|
||||||
}
|
|
||||||
const select = (item: CommandPaletteEntry | undefined) => {
|
|
||||||
if (!item) return
|
|
||||||
state.committed = true
|
|
||||||
state.cleanup = undefined
|
|
||||||
dialog.close()
|
|
||||||
if (item.type === "command") {
|
|
||||||
item.option?.onSelect?.("palette")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (item.type === "session") props.onSelectSession(item)
|
|
||||||
}
|
|
||||||
const loadItems = async (text: string) => {
|
|
||||||
const query = text.trim()
|
|
||||||
if (!query) return commandEntries().slice(0, 5)
|
|
||||||
return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (state.committed) return
|
|
||||||
state.cleanup?.()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CommandPaletteView
|
|
||||||
placeholder={language.t("palette.search.placeholder.home")}
|
|
||||||
loadItems={loadItems}
|
|
||||||
highlight={highlight}
|
|
||||||
select={select}
|
|
||||||
close={() => dialog.close()}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandPaletteView(props: {
|
|
||||||
placeholder: string
|
|
||||||
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
|
|
||||||
highlight: (item: CommandPaletteEntry | undefined) => void
|
|
||||||
select: (item: CommandPaletteEntry | undefined) => void
|
|
||||||
close: () => void
|
|
||||||
}) {
|
|
||||||
const language = useLanguage()
|
|
||||||
const tabs = useTabs()
|
|
||||||
const [query, setQuery] = createSignal("")
|
|
||||||
const [active, setActive] = createSignal(0)
|
|
||||||
|
|
||||||
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
|
||||||
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
|
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
|
||||||
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
||||||
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
||||||
const activeEntry = createMemo(() => visibleEntries()[active()])
|
const activeEntry = createMemo(() => visibleEntries()[active()])
|
||||||
const openSessions = createMemo(
|
|
||||||
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
query()
|
query()
|
||||||
@@ -152,7 +59,7 @@ function CommandPaletteView(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
props.highlight(activeEntry())
|
palette.highlight(activeEntry())
|
||||||
})
|
})
|
||||||
|
|
||||||
let resultsRef: HTMLDivElement | undefined
|
let resultsRef: HTMLDivElement | undefined
|
||||||
@@ -179,12 +86,12 @@ function CommandPaletteView(props: {
|
|||||||
}
|
}
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
props.select(activeEntry())
|
palette.select(activeEntry())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
props.close()
|
palette.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +105,7 @@ function CommandPaletteView(props: {
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
appearance="large"
|
appearance="large"
|
||||||
placeholder={props.placeholder}
|
placeholder={palette.language.t("palette.search.placeholder")}
|
||||||
leadingIcon={<Icon name="magnifying-glass" />}
|
leadingIcon={<Icon name="magnifying-glass" />}
|
||||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -210,7 +117,7 @@ function CommandPaletteView(props: {
|
|||||||
when={visibleEntries().length > 0}
|
when={visibleEntries().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="command-palette-v2-state">
|
<div class="command-palette-v2-state">
|
||||||
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
|
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -225,14 +132,9 @@ function CommandPaletteView(props: {
|
|||||||
<PaletteRow
|
<PaletteRow
|
||||||
item={item}
|
item={item}
|
||||||
active={activeEntry()?.id === item.id}
|
active={activeEntry()?.id === item.id}
|
||||||
language={language}
|
language={palette.language}
|
||||||
sessionOpen={
|
|
||||||
item.server && item.sessionID
|
|
||||||
? openSessions().has(`${item.server}\0${item.sessionID}`)
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
|
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
|
||||||
onSelect={() => props.select(item)}
|
onSelect={() => palette.select(item)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -251,19 +153,13 @@ function PaletteRow(props: {
|
|||||||
item: CommandPaletteEntry
|
item: CommandPaletteEntry
|
||||||
active: boolean
|
active: boolean
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
sessionOpen: boolean
|
|
||||||
onActive: () => void
|
onActive: () => void
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
}) {
|
}) {
|
||||||
const session = () =>
|
|
||||||
props.item.server && props.item.directory && props.item.sessionID
|
|
||||||
? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="command-palette-v2-row group"
|
class="command-palette-v2-row"
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={props.active}
|
aria-selected={props.active}
|
||||||
data-active={props.active ? "" : undefined}
|
data-active={props.active ? "" : undefined}
|
||||||
@@ -301,25 +197,7 @@ function PaletteRow(props: {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={props.item.type === "session"}>
|
<Match when={props.item.type === "session"}>
|
||||||
<div class="command-palette-v2-row-main">
|
<div class="command-palette-v2-row-main">
|
||||||
<div class="relative shrink-0">
|
<Icon name="status" class="command-palette-v2-row-icon" />
|
||||||
<Show when={props.sessionOpen}>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
|
||||||
style={{ right: "calc(100% + 4px)" }}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={session()}>
|
|
||||||
{(session) => (
|
|
||||||
<SessionTabAvatar
|
|
||||||
project={props.item.project}
|
|
||||||
directory={session().directory}
|
|
||||||
sessionId={session().sessionID}
|
|
||||||
server={session().server}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="command-palette-v2-row-text">
|
<div class="command-palette-v2-row-text">
|
||||||
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
|
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
|
||||||
{props.item.title}
|
{props.item.title}
|
||||||
|
|||||||
@@ -8,9 +8,49 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
|||||||
import homeImage from "@/assets/help/home.png"
|
import homeImage from "@/assets/help/home.png"
|
||||||
import tabsImage from "@/assets/help/tabs.png"
|
import tabsImage from "@/assets/help/tabs.png"
|
||||||
|
|
||||||
|
const helpIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="icon-svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
|
||||||
|
fill="var(--v2-icon-icon-base)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const triggerClass =
|
||||||
|
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
|
||||||
|
|
||||||
// TODO: wire to changelog / seen-state when available
|
// TODO: wire to changelog / seen-state when available
|
||||||
const showPopover = () => true
|
const showPopover = () => true
|
||||||
|
|
||||||
|
export function HelpButton() {
|
||||||
|
const platform = usePlatform()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="https://opencode.ai"
|
||||||
|
aria-label="Open the OpenCode website"
|
||||||
|
data-component="icon-button-v2"
|
||||||
|
data-size="large"
|
||||||
|
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
platform.openLink(event.currentTarget.href)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{helpIcon}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// can remove this after the tabs rollout has been out for a while
|
// can remove this after the tabs rollout has been out for a while
|
||||||
export function TabsInfoPopup() {
|
export function TabsInfoPopup() {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -21,7 +61,7 @@ export function TabsInfoPopup() {
|
|||||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||||
<Show when={settings.general.shouldDisplayTabsToast()}>
|
<Show when={settings.general.shouldDisplayTabsToast()}>
|
||||||
<div
|
<div
|
||||||
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||||
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
|||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
@@ -20,6 +20,7 @@ const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({
|
|||||||
|
|
||||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||||
const PALETTE_ID = "command.palette"
|
const PALETTE_ID = "command.palette"
|
||||||
|
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||||
|
|
||||||
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
|
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command"
|
import { resolveKeybindOption, upsertCommandRegistration } from "./command"
|
||||||
|
|
||||||
const paletteOptions: CommandOption[] = [
|
|
||||||
{ id: "settings.open", title: "Open settings" },
|
|
||||||
{ id: "session.undo", title: "Undo" },
|
|
||||||
{ id: "file.open", title: "Open file" },
|
|
||||||
{ id: "hidden", title: "Hidden", hidden: true },
|
|
||||||
{ id: "disabled", title: "Disabled", disabled: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
describe("commandPaletteOptions", () => {
|
|
||||||
test("keeps visible enabled commands", () => {
|
|
||||||
expect(commandPaletteOptions(paletteOptions).map((option) => option.id)).toEqual(["settings.open", "session.undo"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("upsertCommandRegistration", () => {
|
describe("upsertCommandRegistration", () => {
|
||||||
test("replaces keyed registrations", () => {
|
test("replaces keyed registrations", () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Persist, persisted } from "@/utils/persist"
|
|||||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||||
|
|
||||||
const PALETTE_ID = "command.palette"
|
const PALETTE_ID = "command.palette"
|
||||||
export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
|
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||||
const SUGGESTED_PREFIX = "suggested."
|
const SUGGESTED_PREFIX = "suggested."
|
||||||
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
|
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
|
||||||
|
|
||||||
@@ -87,13 +87,6 @@ export interface CommandOption {
|
|||||||
onHighlight?: () => (() => void) | void
|
onHighlight?: () => (() => void) | void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function commandPaletteOptions(options: CommandOption[]) {
|
|
||||||
return options.filter(
|
|
||||||
(option) =>
|
|
||||||
!option.disabled && !option.hidden && !option.id.startsWith(SUGGESTED_PREFIX) && option.id !== "file.open",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
|
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
|
||||||
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
|
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
|
||||||
}
|
}
|
||||||
@@ -382,7 +375,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showPalette = () => {
|
const showPalette = () => {
|
||||||
run(PALETTE_ID, "palette")
|
run("file.open", "palette")
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { hasCustomAgent, resolveAgent } from "./local-agent"
|
|
||||||
|
|
||||||
describe("hasCustomAgent", () => {
|
|
||||||
test("detects explicitly custom agents", () => {
|
|
||||||
expect(hasCustomAgent([{ native: true }, { native: false }])).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("ignores built-in and unclassified agents", () => {
|
|
||||||
expect(hasCustomAgent([{ native: true }, {}])).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("resolveAgent", () => {
|
|
||||||
const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }]
|
|
||||||
|
|
||||||
test("uses the requested available agent", () => {
|
|
||||||
expect(resolveAgent(agents, "custom")?.name).toBe("custom")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("defaults to build", () => {
|
|
||||||
expect(resolveAgent(agents)?.name).toBe("build")
|
|
||||||
expect(resolveAgent(agents, "missing")?.name).toBe("build")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses the first agent when build is unavailable", () => {
|
|
||||||
expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export function hasCustomAgent(items: Array<{ native?: boolean }>) {
|
|
||||||
return items.some((item) => item.native === false)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveAgent<T extends { name: string }>(items: T[], name?: string) {
|
|
||||||
return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0]
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,8 @@ import { useParams } from "@solidjs/router"
|
|||||||
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
import { batch, createEffect, createMemo, startTransition } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { hasCustomAgent, resolveAgent } from "./local-agent"
|
|
||||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
@@ -64,11 +62,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
const id = createMemo(() => params.id || undefined)
|
const id = createMemo(() => params.id || undefined)
|
||||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||||
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
|
|
||||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||||
|
|
||||||
const [saved, setSaved, , savedReady] = persisted(
|
const [saved, setSaved, , savedReady] = persisted(
|
||||||
@@ -111,7 +107,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pickAgent = (name: string | undefined) => {
|
const pickAgent = (name: string | undefined) => {
|
||||||
return resolveAgent(list(), name)
|
const items = list()
|
||||||
|
if (items.length === 0) return
|
||||||
|
return items.find((item) => item.name === name) ?? items[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -182,9 +180,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const agent = {
|
const agent = {
|
||||||
list,
|
list,
|
||||||
visible: agentsVisible,
|
|
||||||
current() {
|
current() {
|
||||||
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
|
return pickAgent(scope()?.agent ?? store.current)
|
||||||
},
|
},
|
||||||
set(name: string | undefined) {
|
set(name: string | undefined) {
|
||||||
const item = pickAgent(name)
|
const item = pickAgent(name)
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export const dict = {
|
|||||||
"command.session.unshare": "إلغاء مشاركة الجلسة",
|
"command.session.unshare": "إلغاء مشاركة الجلسة",
|
||||||
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
|
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
|
||||||
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
|
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
|
||||||
"palette.search.placeholder.home": "البحث في الأوامر والجلسات",
|
|
||||||
"palette.empty": "لا توجد نتائج",
|
"palette.empty": "لا توجد نتائج",
|
||||||
"palette.group.commands": "الأوامر",
|
"palette.group.commands": "الأوامر",
|
||||||
"palette.group.files": "الملفات",
|
"palette.group.files": "الملفات",
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export const dict = {
|
|||||||
"command.session.unshare": "Parar de compartilhar sessão",
|
"command.session.unshare": "Parar de compartilhar sessão",
|
||||||
"command.session.unshare.description": "Parar de compartilhar esta sessão",
|
"command.session.unshare.description": "Parar de compartilhar esta sessão",
|
||||||
"palette.search.placeholder": "Buscar arquivos, comandos e sessões",
|
"palette.search.placeholder": "Buscar arquivos, comandos e sessões",
|
||||||
"palette.search.placeholder.home": "Buscar comandos e sessões",
|
|
||||||
"palette.empty": "Nenhum resultado encontrado",
|
"palette.empty": "Nenhum resultado encontrado",
|
||||||
"palette.group.commands": "Comandos",
|
"palette.group.commands": "Comandos",
|
||||||
"palette.group.files": "Arquivos",
|
"palette.group.files": "Arquivos",
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export const dict = {
|
|||||||
"command.session.unshare.description": "Zaustavi dijeljenje ove sesije",
|
"command.session.unshare.description": "Zaustavi dijeljenje ove sesije",
|
||||||
|
|
||||||
"palette.search.placeholder": "Pretraži datoteke, komande i sesije",
|
"palette.search.placeholder": "Pretraži datoteke, komande i sesije",
|
||||||
"palette.search.placeholder.home": "Pretraži komande i sesije",
|
|
||||||
"palette.empty": "Nema rezultata",
|
"palette.empty": "Nema rezultata",
|
||||||
"palette.group.commands": "Komande",
|
"palette.group.commands": "Komande",
|
||||||
"palette.group.files": "Datoteke",
|
"palette.group.files": "Datoteke",
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export const dict = {
|
|||||||
"command.session.unshare.description": "Stop med at dele denne session",
|
"command.session.unshare.description": "Stop med at dele denne session",
|
||||||
|
|
||||||
"palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
|
"palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
|
||||||
"palette.search.placeholder.home": "Søg i kommandoer og sessioner",
|
|
||||||
"palette.empty": "Ingen resultater fundet",
|
"palette.empty": "Ingen resultater fundet",
|
||||||
"palette.group.commands": "Kommandoer",
|
"palette.group.commands": "Kommandoer",
|
||||||
"palette.group.files": "Filer",
|
"palette.group.files": "Filer",
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export const dict = {
|
|||||||
"command.session.unshare": "Teilen der Sitzung aufheben",
|
"command.session.unshare": "Teilen der Sitzung aufheben",
|
||||||
"command.session.unshare.description": "Teilen dieser Sitzung beenden",
|
"command.session.unshare.description": "Teilen dieser Sitzung beenden",
|
||||||
"palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen",
|
"palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen",
|
||||||
"palette.search.placeholder.home": "Befehle und Sitzungen durchsuchen",
|
|
||||||
"palette.empty": "Keine Ergebnisse gefunden",
|
"palette.empty": "Keine Ergebnisse gefunden",
|
||||||
"palette.group.commands": "Befehle",
|
"palette.group.commands": "Befehle",
|
||||||
"palette.group.files": "Dateien",
|
"palette.group.files": "Dateien",
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export const dict = {
|
|||||||
"command.session.unshare.description": "Stop sharing this session",
|
"command.session.unshare.description": "Stop sharing this session",
|
||||||
|
|
||||||
"palette.search.placeholder": "Search files, commands, and sessions",
|
"palette.search.placeholder": "Search files, commands, and sessions",
|
||||||
"palette.search.placeholder.home": "Search commands and sessions",
|
|
||||||
"palette.empty": "No results found",
|
"palette.empty": "No results found",
|
||||||
"palette.group.commands": "Commands",
|
"palette.group.commands": "Commands",
|
||||||
"palette.group.files": "Files",
|
"palette.group.files": "Files",
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export const dict = {
|
|||||||
"command.session.unshare.description": "Dejar de compartir esta sesión",
|
"command.session.unshare.description": "Dejar de compartir esta sesión",
|
||||||
|
|
||||||
"palette.search.placeholder": "Buscar archivos, comandos y sesiones",
|
"palette.search.placeholder": "Buscar archivos, comandos y sesiones",
|
||||||
"palette.search.placeholder.home": "Buscar comandos y sesiones",
|
|
||||||
"palette.empty": "No se encontraron resultados",
|
"palette.empty": "No se encontraron resultados",
|
||||||
"palette.group.commands": "Comandos",
|
"palette.group.commands": "Comandos",
|
||||||
"palette.group.files": "Archivos",
|
"palette.group.files": "Archivos",
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export const dict = {
|
|||||||
"command.session.unshare": "Ne plus partager la session",
|
"command.session.unshare": "Ne plus partager la session",
|
||||||
"command.session.unshare.description": "Arrêter de partager cette session",
|
"command.session.unshare.description": "Arrêter de partager cette session",
|
||||||
"palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions",
|
"palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions",
|
||||||
"palette.search.placeholder.home": "Rechercher des commandes et des sessions",
|
|
||||||
"palette.empty": "Aucun résultat trouvé",
|
"palette.empty": "Aucun résultat trouvé",
|
||||||
"palette.group.commands": "Commandes",
|
"palette.group.commands": "Commandes",
|
||||||
"palette.group.files": "Fichiers",
|
"palette.group.files": "Fichiers",
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export const dict = {
|
|||||||
"command.session.unshare": "セッションの共有を停止",
|
"command.session.unshare": "セッションの共有を停止",
|
||||||
"command.session.unshare.description": "このセッションの共有を停止",
|
"command.session.unshare.description": "このセッションの共有を停止",
|
||||||
"palette.search.placeholder": "ファイル、コマンド、セッションを検索",
|
"palette.search.placeholder": "ファイル、コマンド、セッションを検索",
|
||||||
"palette.search.placeholder.home": "コマンドとセッションを検索",
|
|
||||||
"palette.empty": "結果が見つかりません",
|
"palette.empty": "結果が見つかりません",
|
||||||
"palette.group.commands": "コマンド",
|
"palette.group.commands": "コマンド",
|
||||||
"palette.group.files": "ファイル",
|
"palette.group.files": "ファイル",
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ export const dict = {
|
|||||||
"command.session.unshare": "세션 공유 중지",
|
"command.session.unshare": "세션 공유 중지",
|
||||||
"command.session.unshare.description": "이 세션 공유 중지",
|
"command.session.unshare.description": "이 세션 공유 중지",
|
||||||
"palette.search.placeholder": "파일, 명령어 및 세션 검색",
|
"palette.search.placeholder": "파일, 명령어 및 세션 검색",
|
||||||
"palette.search.placeholder.home": "명령어 및 세션 검색",
|
|
||||||
"palette.empty": "결과 없음",
|
"palette.empty": "결과 없음",
|
||||||
"palette.group.commands": "명령어",
|
"palette.group.commands": "명령어",
|
||||||
"palette.group.files": "파일",
|
"palette.group.files": "파일",
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ export const dict = {
|
|||||||
"command.session.unshare.description": "Slutt å dele denne sesjonen",
|
"command.session.unshare.description": "Slutt å dele denne sesjonen",
|
||||||
|
|
||||||
"palette.search.placeholder": "Søk i filer, kommandoer og sesjoner",
|
"palette.search.placeholder": "Søk i filer, kommandoer og sesjoner",
|
||||||
"palette.search.placeholder.home": "Søk i kommandoer og sesjoner",
|
|
||||||
"palette.empty": "Ingen resultater funnet",
|
"palette.empty": "Ingen resultater funnet",
|
||||||
"palette.group.commands": "Kommandoer",
|
"palette.group.commands": "Kommandoer",
|
||||||
"palette.group.files": "Filer",
|
"palette.group.files": "Filer",
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export const dict = {
|
|||||||
"command.session.unshare": "Przestań udostępniać sesję",
|
"command.session.unshare": "Przestań udostępniać sesję",
|
||||||
"command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji",
|
"command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji",
|
||||||
"palette.search.placeholder": "Szukaj plików, poleceń i sesji",
|
"palette.search.placeholder": "Szukaj plików, poleceń i sesji",
|
||||||
"palette.search.placeholder.home": "Szukaj poleceń i sesji",
|
|
||||||
"palette.empty": "Brak wyników",
|
"palette.empty": "Brak wyników",
|
||||||
"palette.group.commands": "Polecenia",
|
"palette.group.commands": "Polecenia",
|
||||||
"palette.group.files": "Pliki",
|
"palette.group.files": "Pliki",
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export const dict = {
|
|||||||
"command.session.unshare.description": "Прекратить публикацию сессии",
|
"command.session.unshare.description": "Прекратить публикацию сессии",
|
||||||
|
|
||||||
"palette.search.placeholder": "Поиск файлов, команд и сессий",
|
"palette.search.placeholder": "Поиск файлов, команд и сессий",
|
||||||
"palette.search.placeholder.home": "Поиск команд и сессий",
|
|
||||||
"palette.empty": "Ничего не найдено",
|
"palette.empty": "Ничего не найдено",
|
||||||
"palette.group.commands": "Команды",
|
"palette.group.commands": "Команды",
|
||||||
"palette.group.files": "Файлы",
|
"palette.group.files": "Файлы",
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export const dict = {
|
|||||||
"command.session.unshare.description": "หยุดการแชร์เซสชันนี้",
|
"command.session.unshare.description": "หยุดการแชร์เซสชันนี้",
|
||||||
|
|
||||||
"palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน",
|
"palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน",
|
||||||
"palette.search.placeholder.home": "ค้นหาคำสั่งและเซสชัน",
|
|
||||||
"palette.empty": "ไม่พบผลลัพธ์",
|
"palette.empty": "ไม่พบผลลัพธ์",
|
||||||
"palette.group.commands": "คำสั่ง",
|
"palette.group.commands": "คำสั่ง",
|
||||||
"palette.group.files": "ไฟล์",
|
"palette.group.files": "ไฟล์",
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ export const dict = {
|
|||||||
"command.session.unshare.description": "Bu oturumun paylaşımını durdur",
|
"command.session.unshare.description": "Bu oturumun paylaşımını durdur",
|
||||||
|
|
||||||
"palette.search.placeholder": "Dosya, komut ve oturum ara",
|
"palette.search.placeholder": "Dosya, komut ve oturum ara",
|
||||||
"palette.search.placeholder.home": "Komut ve oturum ara",
|
|
||||||
"palette.empty": "Sonuç bulunamadı",
|
"palette.empty": "Sonuç bulunamadı",
|
||||||
"palette.group.commands": "Komutlar",
|
"palette.group.commands": "Komutlar",
|
||||||
"palette.group.files": "Dosyalar",
|
"palette.group.files": "Dosyalar",
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export const dict = {
|
|||||||
"command.session.unshare.description": "Припинити поширення цієї сесії",
|
"command.session.unshare.description": "Припинити поширення цієї сесії",
|
||||||
|
|
||||||
"palette.search.placeholder": "Пошук файлів, команд і сесій",
|
"palette.search.placeholder": "Пошук файлів, команд і сесій",
|
||||||
"palette.search.placeholder.home": "Пошук команд і сесій",
|
|
||||||
"palette.empty": "Результатів не знайдено",
|
"palette.empty": "Результатів не знайдено",
|
||||||
"palette.group.commands": "Команди",
|
"palette.group.commands": "Команди",
|
||||||
"palette.group.files": "Файли",
|
"palette.group.files": "Файли",
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ export const dict = {
|
|||||||
"command.session.unshare.description": "停止分享此会话",
|
"command.session.unshare.description": "停止分享此会话",
|
||||||
|
|
||||||
"palette.search.placeholder": "搜索文件、命令和会话",
|
"palette.search.placeholder": "搜索文件、命令和会话",
|
||||||
"palette.search.placeholder.home": "搜索命令和会话",
|
|
||||||
"palette.empty": "未找到结果",
|
"palette.empty": "未找到结果",
|
||||||
"palette.group.commands": "命令",
|
"palette.group.commands": "命令",
|
||||||
"palette.group.files": "文件",
|
"palette.group.files": "文件",
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ export const dict = {
|
|||||||
"command.session.unshare.description": "停止分享此工作階段",
|
"command.session.unshare.description": "停止分享此工作階段",
|
||||||
|
|
||||||
"palette.search.placeholder": "搜尋檔案、命令和工作階段",
|
"palette.search.placeholder": "搜尋檔案、命令和工作階段",
|
||||||
"palette.search.placeholder.home": "搜尋命令和工作階段",
|
|
||||||
"palette.empty": "找不到結果",
|
"palette.empty": "找不到結果",
|
||||||
"palette.group.commands": "命令",
|
"palette.group.commands": "命令",
|
||||||
"palette.group.files": "檔案",
|
"palette.group.files": "檔案",
|
||||||
|
|||||||
+119
-212
@@ -5,7 +5,6 @@ import {
|
|||||||
createMemo,
|
createMemo,
|
||||||
createResource,
|
createResource,
|
||||||
createRoot,
|
createRoot,
|
||||||
createSignal,
|
|
||||||
For,
|
For,
|
||||||
Match,
|
Match,
|
||||||
on,
|
on,
|
||||||
@@ -75,23 +74,6 @@ const HOME_SESSION_LIMIT = 64
|
|||||||
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
||||||
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
|
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
|
||||||
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
|
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
|
||||||
|
|
||||||
function containHomeWheel(event: WheelEvent, viewport: HTMLElement) {
|
|
||||||
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
|
|
||||||
if (!(event.target instanceof Element)) return
|
|
||||||
|
|
||||||
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
|
|
||||||
if (
|
|
||||||
scrollable !== viewport &&
|
|
||||||
scrollable &&
|
|
||||||
(event.deltaY < 0
|
|
||||||
? scrollable.scrollTop > 0
|
|
||||||
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
const SHOW_HOME_SESSION_ARCHIVE = false
|
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||||
const HOME_ROW_LAYOUT =
|
const HOME_ROW_LAYOUT =
|
||||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
||||||
@@ -162,7 +144,6 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
|||||||
let content: HTMLDivElement | undefined
|
let content: HTMLDivElement | undefined
|
||||||
let positionFrame: number | undefined
|
let positionFrame: number | undefined
|
||||||
let resizeObserver: ResizeObserver | undefined
|
let resizeObserver: ResizeObserver | undefined
|
||||||
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
|
|
||||||
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
|
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
|
||||||
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
|
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
@@ -221,13 +202,6 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
|||||||
|
|
||||||
function updatePositionCache() {
|
function updatePositionCache() {
|
||||||
if (!viewport) return
|
if (!viewport) return
|
||||||
const header = groups()
|
|
||||||
.map((group) => headerRefs.get(group.id))
|
|
||||||
.find((el) => el !== undefined)
|
|
||||||
if (header && typeof getComputedStyle === "function") {
|
|
||||||
const top = Number.parseFloat(getComputedStyle(header).top)
|
|
||||||
if (Number.isFinite(top)) stickyTop = top
|
|
||||||
}
|
|
||||||
groups().forEach((group) => {
|
groups().forEach((group) => {
|
||||||
const el = headerRefs.get(group.id)
|
const el = headerRefs.get(group.id)
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -243,7 +217,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
|||||||
.slice(index + 1)
|
.slice(index + 1)
|
||||||
.map((item) => headerOffsets.get(item.id))
|
.map((item) => headerOffsets.get(item.id))
|
||||||
.find((offset) => offset !== undefined)
|
.find((offset) => offset !== undefined)
|
||||||
const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
|
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
|
||||||
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
|
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
|
||||||
const opacity =
|
const opacity =
|
||||||
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
|
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
|
||||||
@@ -297,9 +271,6 @@ export function NewHome() {
|
|||||||
const marked = useMarked()
|
const marked = useMarked()
|
||||||
const openSettings = useSettingsCommand()
|
const openSettings = useSettingsCommand()
|
||||||
let focusSessionSearch: (() => void) | undefined
|
let focusSessionSearch: (() => void) | undefined
|
||||||
let sessionViewport: HTMLDivElement | undefined
|
|
||||||
const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
|
|
||||||
const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
|
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
search: "",
|
search: "",
|
||||||
searchFocused: false,
|
searchFocused: false,
|
||||||
@@ -431,34 +402,6 @@ export function NewHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
command.register("home", () => [
|
command.register("home", () => [
|
||||||
{
|
|
||||||
id: "command.palette",
|
|
||||||
title: language.t("command.palette"),
|
|
||||||
hidden: true,
|
|
||||||
onSelect: async () => {
|
|
||||||
const conn = focusedServer()
|
|
||||||
if (!conn) return
|
|
||||||
const ctx = global.ensureServerCtx(conn)
|
|
||||||
const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
|
|
||||||
void dialog.show(() => (
|
|
||||||
<DialogHomeCommandPaletteV2
|
|
||||||
server={conn}
|
|
||||||
onSelectSession={(entry) => {
|
|
||||||
if (!entry.sessionID || !entry.directory || !entry.server) return
|
|
||||||
const sessionID = entry.sessionID
|
|
||||||
const server = entry.server
|
|
||||||
const directory = entry.project?.worktree ?? entry.directory
|
|
||||||
ctx.projects.open(directory)
|
|
||||||
ctx.projects.touch(directory)
|
|
||||||
void startTransition(() => {
|
|
||||||
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
|
|
||||||
tabs.select(tab)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "home.sessions.search.focus",
|
id: "home.sessions.search.focus",
|
||||||
title: searchPlaceholder(),
|
title: searchPlaceholder(),
|
||||||
@@ -598,158 +541,127 @@ export function NewHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||||
<ScrollView
|
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
|
||||||
class="h-full [container-type:size]"
|
<HomeProjectColumn
|
||||||
thumbContainer={sessionThumbTrack}
|
projects={projects()}
|
||||||
thumbHoverTarget={sessionHoverTarget}
|
recentlyClosed={recentlyClosed()}
|
||||||
viewportRef={(el) => {
|
homedir={homedir()}
|
||||||
sessionViewport = el
|
selected={selection()}
|
||||||
sessionHeaderOpacity.setViewport(el)
|
focusServer={focusServer}
|
||||||
}}
|
selectProject={selectProject}
|
||||||
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
|
openNewSession={openProjectNewSession}
|
||||||
onWheel={(event) => {
|
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
|
||||||
if (!sessionViewport) return
|
chooseProject={(conn) => void chooseProject(conn)}
|
||||||
if (event.target instanceof Node && sessionViewport.contains(event.target)) return
|
editProject={editProject}
|
||||||
containHomeWheel(event, sessionViewport)
|
closeProject={(conn, directory) => {
|
||||||
}}
|
const next = closeHomeProject(
|
||||||
>
|
selection(),
|
||||||
<div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
|
ServerConnection.key(conn),
|
||||||
<HomeProjectColumn
|
global.ensureServerCtx(conn).projects,
|
||||||
projects={projects()}
|
directory,
|
||||||
recentlyClosed={recentlyClosed()}
|
)
|
||||||
homedir={homedir()}
|
if (next) setSelection(next)
|
||||||
selected={selection()}
|
}}
|
||||||
focusServer={focusServer}
|
clearNotifications={clearNotifications}
|
||||||
selectProject={selectProject}
|
unseenCount={unseenCount}
|
||||||
openNewSession={openProjectNewSession}
|
openSettings={openSettings}
|
||||||
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
|
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||||
chooseProject={(conn) => void chooseProject(conn)}
|
language={language}
|
||||||
editProject={editProject}
|
/>
|
||||||
closeProject={(conn, directory) => {
|
|
||||||
const next = closeHomeProject(
|
|
||||||
selection(),
|
|
||||||
ServerConnection.key(conn),
|
|
||||||
global.ensureServerCtx(conn).projects,
|
|
||||||
directory,
|
|
||||||
)
|
|
||||||
if (next) setSelection(next)
|
|
||||||
}}
|
|
||||||
clearNotifications={clearNotifications}
|
|
||||||
unseenCount={unseenCount}
|
|
||||||
openSettings={openSettings}
|
|
||||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
|
||||||
language={language}
|
|
||||||
onWheel={(event) => {
|
|
||||||
if (sessionViewport) containHomeWheel(event, sessionViewport)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section
|
<section
|
||||||
ref={setSessionHoverTarget}
|
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
|
||||||
class="min-h-0 min-w-0 flex-1 flex flex-col"
|
aria-label={language.t("sidebar.project.recentSessions")}
|
||||||
aria-label={language.t("sidebar.project.recentSessions")}
|
>
|
||||||
|
<HomeSessionSearch
|
||||||
|
value={state.search}
|
||||||
|
placeholder={searchPlaceholder()}
|
||||||
|
open={searchOpen()}
|
||||||
|
loading={sessionLoad.isLoading}
|
||||||
|
results={searchResults()}
|
||||||
|
showProjectName={!selectedProject()}
|
||||||
|
server={selection().server}
|
||||||
|
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
||||||
|
bindFocus={(focus) => {
|
||||||
|
focusSessionSearch = focus
|
||||||
|
}}
|
||||||
|
onInput={(value) => setState("search", value)}
|
||||||
|
onFocus={() => setState("searchFocused", true)}
|
||||||
|
onClose={closeSearch}
|
||||||
|
onSelect={selectSearchSession}
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
class="mt-3 -mr-3 min-h-0 flex-1 relative"
|
||||||
|
viewportRef={sessionHeaderOpacity.setViewport}
|
||||||
|
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
|
||||||
>
|
>
|
||||||
<div
|
<Show when={groups().length > 0 && newSessionProject()}>
|
||||||
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
|
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
|
||||||
onWheel={(event) => {
|
<ButtonV2
|
||||||
if (sessionViewport) containHomeWheel(event, sessionViewport)
|
data-action="home-new-session"
|
||||||
}}
|
variant="ghost-muted"
|
||||||
|
size="normal"
|
||||||
|
icon="edit"
|
||||||
|
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||||
|
onClick={openNewSession}
|
||||||
|
>
|
||||||
|
{language.t("command.session.new")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={!sessionLoad.isLoading}
|
||||||
|
fallback={
|
||||||
|
<div class="pt-3">
|
||||||
|
<HomeSessionSkeleton label={language.t("common.loading")} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<HomeSessionSearch
|
<Show
|
||||||
value={state.search}
|
when={groups().length > 0}
|
||||||
placeholder={searchPlaceholder()}
|
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||||
open={searchOpen()}
|
>
|
||||||
loading={sessionLoad.isLoading}
|
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
|
||||||
results={searchResults()}
|
<For each={groups()}>
|
||||||
showProjectName={!selectedProject()}
|
{(group, index) => (
|
||||||
server={selection().server}
|
<>
|
||||||
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
<HomeSessionGroupHeader
|
||||||
bindFocus={(focus) => {
|
title={group.title}
|
||||||
focusSessionSearch = focus
|
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
|
||||||
}}
|
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
|
||||||
onInput={(value) => setState("search", value)}
|
elevated={index() === 0}
|
||||||
onFocus={() => setState("searchFocused", true)}
|
/>
|
||||||
onClose={closeSearch}
|
<div
|
||||||
onSelect={selectSearchSession}
|
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
|
||||||
/>
|
>
|
||||||
<Show when={groups().length > 0 && newSessionProject()}>
|
<For each={group.sessions}>
|
||||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
{(record) => (
|
||||||
<ButtonV2
|
<HomeSessionRow
|
||||||
data-action="home-new-session"
|
record={record}
|
||||||
variant="ghost-muted"
|
showProjectName={!selectedProject()}
|
||||||
size="normal"
|
server={selection().server}
|
||||||
icon="edit"
|
openSession={openSession}
|
||||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
archiveSession={archiveSession}
|
||||||
onClick={openNewSession}
|
/>
|
||||||
>
|
)}
|
||||||
{language.t("command.session.new")}
|
</For>
|
||||||
</ButtonV2>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</Show>
|
||||||
{/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
|
</ScrollView>
|
||||||
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
</section>
|
||||||
<div
|
<HomeUtilityNav
|
||||||
ref={setSessionThumbTrack}
|
class="flex lg:hidden"
|
||||||
data-component="home-session-scroll-track"
|
openSettings={openSettings}
|
||||||
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
|
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||||
/>
|
language={language}
|
||||||
</div>
|
/>
|
||||||
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
</div>
|
||||||
<Show
|
|
||||||
when={!sessionLoad.isLoading}
|
|
||||||
fallback={
|
|
||||||
<div class="pt-3">
|
|
||||||
<HomeSessionSkeleton label={language.t("common.loading")} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={groups().length > 0}
|
|
||||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
|
||||||
>
|
|
||||||
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
|
|
||||||
<For each={groups()}>
|
|
||||||
{(group, index) => (
|
|
||||||
<>
|
|
||||||
<HomeSessionGroupHeader
|
|
||||||
title={group.title}
|
|
||||||
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
|
|
||||||
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
|
|
||||||
elevated={index() === 0}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
|
|
||||||
>
|
|
||||||
<For each={group.sessions}>
|
|
||||||
{(record) => (
|
|
||||||
<HomeSessionRow
|
|
||||||
record={record}
|
|
||||||
showProjectName={!selectedProject()}
|
|
||||||
server={selection().server}
|
|
||||||
openSession={openSession}
|
|
||||||
archiveSession={archiveSession}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<HomeUtilityNav
|
|
||||||
class="flex lg:hidden"
|
|
||||||
openSettings={openSettings}
|
|
||||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
|
||||||
language={language}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -771,7 +683,6 @@ function HomeProjectColumn(props: {
|
|||||||
openSettings: () => void
|
openSettings: () => void
|
||||||
openHelp: () => void
|
openHelp: () => void
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
onWheel: (event: WheelEvent) => void
|
|
||||||
}) {
|
}) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -788,12 +699,8 @@ function HomeProjectColumn(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
|
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
|
||||||
aria-label={props.language.t("home.projects")}
|
aria-label={props.language.t("home.projects")}
|
||||||
onWheel={(event) => {
|
|
||||||
if (event.target === event.currentTarget) return
|
|
||||||
props.onWheel(event)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||||
@@ -1511,7 +1418,7 @@ function HomeSessionGroupHeader(props: {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={props.ref}
|
ref={props.ref}
|
||||||
class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
|
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
|
||||||
>
|
>
|
||||||
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
|
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
|
||||||
{props.title}
|
{props.title}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
import { TabsInfoPopup } from "@/components/help-button"
|
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
|
||||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { setNavigate } from "@/utils/notification-click"
|
import { setNavigate } from "@/utils/notification-click"
|
||||||
@@ -38,6 +38,7 @@ export default function NewLayout(props: ParentProps) {
|
|||||||
</main>
|
</main>
|
||||||
{import.meta.env.DEV && <DebugBar inline />}
|
{import.meta.env.DEV && <DebugBar inline />}
|
||||||
<TabsInfoPopup />
|
<TabsInfoPopup />
|
||||||
|
<HelpButton />
|
||||||
<ToastRegion v2 />
|
<ToastRegion v2 />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
|||||||
import { useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
import { TabsInfoPopup } from "@/components/help-button"
|
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
|
||||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
@@ -2396,6 +2396,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||||
</div>
|
</div>
|
||||||
<TabsInfoPopup />
|
<TabsInfoPopup />
|
||||||
|
<HelpButton />
|
||||||
<ToastRegion v2={false} />
|
<ToastRegion v2={false} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { createStore } from "solid-js/store"
|
|||||||
import { Portal } from "solid-js/web"
|
import { Portal } from "solid-js/web"
|
||||||
import { useSearchParams } from "@solidjs/router"
|
import { useSearchParams } from "@solidjs/router"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { NewSessionDesignView } from "@/components/session"
|
import { NewSessionDesignView } from "@/components/session"
|
||||||
@@ -52,7 +51,6 @@ export default function NewSessionPage() {
|
|||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const dialog = useDialog()
|
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const openProviderSettings = useSettingsDialog("providers")
|
const openProviderSettings = useSettingsDialog("providers")
|
||||||
@@ -79,15 +77,6 @@ export default function NewSessionPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
command.register("new-session", () => [
|
command.register("new-session", () => [
|
||||||
{
|
|
||||||
id: "command.palette",
|
|
||||||
title: language.t("command.palette"),
|
|
||||||
hidden: true,
|
|
||||||
onSelect: async () => {
|
|
||||||
const { DialogSelectFile } = await import("@/components/dialog-select-file")
|
|
||||||
void dialog.show(() => <DialogSelectFile />)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "input.focus",
|
id: "input.focus",
|
||||||
title: language.t("command.input.focus"),
|
title: language.t("command.input.focus"),
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/r
|
|||||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||||
import { ErrorPage } from "@/pages/error"
|
import { ErrorPage } from "@/pages/error"
|
||||||
import { CommentsProvider, useComments } from "@/context/comments"
|
import { CommentsProvider, useComments } from "@/context/comments"
|
||||||
import { useCommand } from "@/context/command"
|
|
||||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -363,7 +362,6 @@ export default function Page() {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const command = useCommand()
|
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -1100,14 +1098,6 @@ export default function Page() {
|
|||||||
review: reviewTab,
|
review: reviewTab,
|
||||||
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
|
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
|
||||||
})
|
})
|
||||||
command.register("session-palette", () => [
|
|
||||||
{
|
|
||||||
id: "command.palette",
|
|
||||||
title: language.t("command.palette"),
|
|
||||||
hidden: true,
|
|
||||||
onSelect: () => command.trigger("file.open", "palette"),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const openReviewFile = createOpenReviewFile({
|
const openReviewFile = createOpenReviewFile({
|
||||||
showAllFiles,
|
showAllFiles,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function createPromptInputController(input: {
|
|||||||
options: local.agent.list().map((agent) => agent.name),
|
options: local.agent.list().map((agent) => agent.name),
|
||||||
current: local.agent.current()?.name ?? "",
|
current: local.agent.current()?.name ?? "",
|
||||||
loading: agentsQuery.isLoading,
|
loading: agentsQuery.isLoading,
|
||||||
visible: local.agent.visible(),
|
visible: settings.visibility.customAgents(),
|
||||||
select: local.agent.set,
|
select: local.agent.set,
|
||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLocal, type ModelSelection } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
|
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||||
import { useSessionLayout } from "./session-layout"
|
import { useSessionLayout } from "./session-layout"
|
||||||
@@ -18,6 +19,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
const settings = useSettings()
|
||||||
const { sessionKey } = useSessionLayout()
|
const { sessionKey } = useSessionLayout()
|
||||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||||
const model = input.model ?? local.model
|
const model = input.model ?? local.model
|
||||||
@@ -68,7 +70,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
|||||||
description: language.t("command.agent.cycle.description"),
|
description: language.t("command.agent.cycle.description"),
|
||||||
keybind: "mod+.",
|
keybind: "mod+.",
|
||||||
slash: "agent",
|
slash: "agent",
|
||||||
disabled: !local.agent.visible(),
|
disabled: !settings.visibility.customAgents(),
|
||||||
onSelect: () => local.agent.move(1),
|
onSelect: () => local.agent.move(1),
|
||||||
}),
|
}),
|
||||||
agentCommand({
|
agentCommand({
|
||||||
@@ -76,7 +78,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
|||||||
title: language.t("command.agent.cycle.reverse"),
|
title: language.t("command.agent.cycle.reverse"),
|
||||||
description: language.t("command.agent.cycle.reverse.description"),
|
description: language.t("command.agent.cycle.reverse.description"),
|
||||||
keybind: "shift+mod+.",
|
keybind: "shift+mod+.",
|
||||||
disabled: !local.agent.visible(),
|
disabled: !settings.visibility.customAgents(),
|
||||||
onSelect: () => local.agent.move(-1),
|
onSelect: () => local.agent.move(-1),
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
id: "file.open",
|
id: "file.open",
|
||||||
title: language.t("command.file.open"),
|
title: language.t("command.file.open"),
|
||||||
description: language.t("palette.search.placeholder"),
|
description: language.t("palette.search.placeholder"),
|
||||||
keybind: "mod+p",
|
keybind: "mod+k,mod+p",
|
||||||
slash: "open",
|
slash: "open",
|
||||||
onSelect: openFile,
|
onSelect: openFile,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { createRoot } from "solid-js"
|
|
||||||
import { createServerSessionEntries } from "@/components/command-palette"
|
|
||||||
import type { LocalProject } from "@/context/layout"
|
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
|
||||||
|
|
||||||
const stored: Project = {
|
|
||||||
id: "project-1",
|
|
||||||
name: "Palette project",
|
|
||||||
worktree: "/workspace/project",
|
|
||||||
sandboxes: [],
|
|
||||||
time: { created: 1, updated: 1 },
|
|
||||||
}
|
|
||||||
|
|
||||||
const session: GlobalSession = {
|
|
||||||
id: "session-1",
|
|
||||||
slug: "session-1",
|
|
||||||
projectID: stored.id,
|
|
||||||
directory: stored.worktree,
|
|
||||||
title: "Palette session",
|
|
||||||
version: "1",
|
|
||||||
time: { created: 1, updated: 2 },
|
|
||||||
project: { id: stored.id, name: stored.name, worktree: stored.worktree },
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("command palette sessions", () => {
|
|
||||||
test("uses the home project avatar and cancels superseded searches", async () => {
|
|
||||||
const server = ServerConnection.Key.make("selected-server")
|
|
||||||
const opened: LocalProject = {
|
|
||||||
...stored,
|
|
||||||
icon: { override: "home-project-avatar" },
|
|
||||||
expanded: true,
|
|
||||||
}
|
|
||||||
const searches: string[] = []
|
|
||||||
const result = await new Promise<Awaited<ReturnType<ReturnType<typeof createServerSessionEntries>>>>(
|
|
||||||
(resolve, reject) => {
|
|
||||||
createRoot((dispose) => {
|
|
||||||
const search = createServerSessionEntries({
|
|
||||||
server,
|
|
||||||
opened: () => [opened],
|
|
||||||
stored: () => [{ ...stored, icon: { url: "stored-project-avatar" } }],
|
|
||||||
load: async (text) => {
|
|
||||||
searches.push(text)
|
|
||||||
return {
|
|
||||||
data: [session, { ...session, id: "archived-session", time: { ...session.time, archived: 3 } }],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
untitled: () => "Untitled",
|
|
||||||
category: () => "Sessions",
|
|
||||||
})
|
|
||||||
const first = search("palette")
|
|
||||||
const second = search("palette session")
|
|
||||||
Promise.all([first, second])
|
|
||||||
.then(([cancelled, entries]) => {
|
|
||||||
expect(cancelled).toEqual([])
|
|
||||||
resolve(entries)
|
|
||||||
})
|
|
||||||
.catch(reject)
|
|
||||||
.finally(dispose)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(searches).toEqual(["palette session"])
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(getProjectAvatarSource(result[0]?.project?.id, result[0]?.project?.icon)).toBe("home-project-avatar")
|
|
||||||
expect(result[0]).toMatchObject({
|
|
||||||
server,
|
|
||||||
sessionID: session.id,
|
|
||||||
description: stored.name,
|
|
||||||
project: { id: stored.id, icon: opened.icon },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ServerProcess } from "../../server-process"
|
|||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.serve,
|
Commands.commands.serve,
|
||||||
Effect.fnUntraced(function* (input) {
|
Effect.fn("cli.serve")(function* (input) {
|
||||||
if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined"))
|
if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined"))
|
||||||
return yield* ServerProcess.run({
|
return yield* ServerProcess.run({
|
||||||
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
|
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
|
||||||
|
|||||||
@@ -633,12 +633,6 @@ export function RunSubagentSelectBody(props: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.name.toLowerCase() === "up" && menu.selected() === 0) {
|
|
||||||
event.preventDefault()
|
|
||||||
props.onClose()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
movePromptHistory,
|
movePromptHistory,
|
||||||
pushPromptHistory,
|
pushPromptHistory,
|
||||||
} from "./prompt.shared"
|
} from "./prompt.shared"
|
||||||
import { Keymap } from "@opencode-ai/tui/context/keymap"
|
import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
|
||||||
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
||||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||||
import type { RunFooterTheme } from "./theme"
|
import type { RunFooterTheme } from "./theme"
|
||||||
@@ -993,83 +993,93 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: baseBindingsEnabled(),
|
enabled: baseBindingsEnabled(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "prompt.clear",
|
name: "prompt.clear",
|
||||||
title: "Clear prompt or exit",
|
title: "Clear prompt or exit",
|
||||||
group: "Prompt",
|
category: "Prompt",
|
||||||
run() {
|
run() {
|
||||||
if (requestExit()) return
|
if (requestExit()) return
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
bindings: input.tuiConfig.keybinds.get("prompt.clear"),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: input.prompt(),
|
enabled: input.prompt(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "session.interrupt",
|
name: "session.interrupt",
|
||||||
title: "Interrupt session",
|
title: "Interrupt session",
|
||||||
group: "Session",
|
category: "Session",
|
||||||
run() {
|
run() {
|
||||||
if (input.onInterrupt()) return
|
if (input.onInterrupt()) return
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
bindings: input.tuiConfig.keybinds.get("session.interrupt"),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: input.prompt() && !visible(),
|
enabled: input.prompt() && !visible(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "prompt.editor",
|
name: "prompt.editor",
|
||||||
title: "Open editor",
|
title: "Open editor",
|
||||||
group: "Prompt",
|
category: "Prompt",
|
||||||
run() {
|
run() {
|
||||||
void openEditor()
|
void openEditor()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
bindings: input.tuiConfig.keybinds.get("prompt.editor"),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
useBindings(() => ({
|
||||||
priority: 1,
|
priority: 1,
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: input.prompt() && !visible(),
|
enabled: input.prompt() && !visible(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "prompt.history.previous",
|
name: "prompt.history.previous",
|
||||||
title: "Previous prompt history",
|
title: "Previous prompt history",
|
||||||
group: "Prompt",
|
category: "Prompt",
|
||||||
run(_input: string | undefined, event?: KeyEvent) {
|
run(ctx: { event: KeyEvent }) {
|
||||||
if (!event) return false
|
return historyCommand(-1, ctx.event)
|
||||||
return historyCommand(-1, event)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "prompt.history.next",
|
name: "prompt.history.next",
|
||||||
title: "Next prompt history",
|
title: "Next prompt history",
|
||||||
group: "Prompt",
|
category: "Prompt",
|
||||||
run(_input: string | undefined, event?: KeyEvent) {
|
run(ctx: { event: KeyEvent }) {
|
||||||
if (!event) return false
|
return historyCommand(1, ctx.event)
|
||||||
return historyCommand(1, event)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
bindings: [
|
||||||
|
...input.tuiConfig.keybinds.get("prompt.history.previous"),
|
||||||
|
...input.tuiConfig.keybinds.get("prompt.history.next"),
|
||||||
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: input.prompt() && !visible(),
|
enabled: input.prompt() && !visible(),
|
||||||
commands: [
|
bindings: [
|
||||||
{
|
{
|
||||||
bind: "!",
|
key: "!",
|
||||||
title: "Shell mode",
|
desc: "Shell mode",
|
||||||
group: "Prompt",
|
group: "Prompt",
|
||||||
run() {
|
cmd() {
|
||||||
if (shell()) return false
|
if (shell()) return false
|
||||||
if (!area || area.isDestroyed) return false
|
if (!area || area.isDestroyed) return false
|
||||||
if (area.cursorOffset !== 0) return false
|
if (area.cursorOffset !== 0) return false
|
||||||
@@ -1079,20 +1089,21 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: input.prompt() && shell() && !visible(),
|
enabled: input.prompt() && shell() && !visible(),
|
||||||
commands: [
|
bindings: [
|
||||||
{
|
{
|
||||||
bind: "escape",
|
key: "escape",
|
||||||
title: "Exit shell mode",
|
desc: "Exit shell mode",
|
||||||
group: "Prompt",
|
group: "Prompt",
|
||||||
run: () => setShellMode(false),
|
cmd: () => setShellMode(false),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bind: "backspace",
|
key: "backspace",
|
||||||
title: "Exit shell mode",
|
desc: "Exit shell mode",
|
||||||
group: "Prompt",
|
group: "Prompt",
|
||||||
run() {
|
cmd() {
|
||||||
if (!area || area.isDestroyed) return false
|
if (!area || area.isDestroyed) return false
|
||||||
if (area.cursorOffset !== 0) return false
|
if (area.cursorOffset !== 0) return false
|
||||||
setShellMode(false)
|
setShellMode(false)
|
||||||
@@ -1101,31 +1112,32 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: input.prompt() && visible(),
|
enabled: input.prompt() && visible(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "prompt.autocomplete.prev",
|
name: "prompt.autocomplete.prev",
|
||||||
title: "Previous autocomplete item",
|
title: "Previous autocomplete item",
|
||||||
group: "Autocomplete",
|
category: "Autocomplete",
|
||||||
run: () => menu.move(-1),
|
run: () => menu.move(-1),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "prompt.autocomplete.next",
|
name: "prompt.autocomplete.next",
|
||||||
title: "Next autocomplete item",
|
title: "Next autocomplete item",
|
||||||
group: "Autocomplete",
|
category: "Autocomplete",
|
||||||
run: () => menu.move(1),
|
run: () => menu.move(1),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "prompt.autocomplete.hide",
|
name: "prompt.autocomplete.hide",
|
||||||
title: "Hide autocomplete",
|
title: "Hide autocomplete",
|
||||||
group: "Autocomplete",
|
category: "Autocomplete",
|
||||||
run: cancelAutocomplete,
|
run: cancelAutocomplete,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "prompt.autocomplete.select",
|
name: "prompt.autocomplete.select",
|
||||||
title: "Select autocomplete item",
|
title: "Select autocomplete item",
|
||||||
group: "Autocomplete",
|
category: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
if (mode() === "slash" && options().length === 0) {
|
if (mode() === "slash" && options().length === 0) {
|
||||||
hide()
|
hide()
|
||||||
@@ -1135,9 +1147,9 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "prompt.autocomplete.complete",
|
name: "prompt.autocomplete.complete",
|
||||||
title: "Complete autocomplete item",
|
title: "Complete autocomplete item",
|
||||||
group: "Autocomplete",
|
category: "Autocomplete",
|
||||||
run() {
|
run() {
|
||||||
if (mode() === "slash" && options().length === 0) {
|
if (mode() === "slash" && options().length === 0) {
|
||||||
hide()
|
hide()
|
||||||
@@ -1152,6 +1164,13 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
bindings: [
|
||||||
|
"prompt.autocomplete.prev",
|
||||||
|
"prompt.autocomplete.next",
|
||||||
|
"prompt.autocomplete.hide",
|
||||||
|
"prompt.autocomplete.select",
|
||||||
|
"prompt.autocomplete.complete",
|
||||||
|
].flatMap((command) => input.tuiConfig.keybinds.get(command)),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const onKeyDown = (event: KeyEvent) => {
|
const onKeyDown = (event: KeyEvent) => {
|
||||||
|
|||||||
@@ -24,11 +24,12 @@
|
|||||||
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
|
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
|
||||||
// two-press pattern where the first press shows a hint and the second press
|
// two-press pattern where the first press shows a hint and the second press
|
||||||
// within 5 seconds actually fires the action.
|
// within 5 seconds actually fires the action.
|
||||||
import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core"
|
import { CliRenderEvents, type CliRenderer, type KeyEvent, type Renderable, type TreeSitterClient } from "@opentui/core"
|
||||||
|
import type { Keymap } from "@opentui/keymap"
|
||||||
import { render } from "@opentui/solid"
|
import { render } from "@opentui/solid"
|
||||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { Keymap } from "@opencode-ai/tui/context/keymap"
|
import { OpencodeKeymapProvider } from "@opencode-ai/tui/keymap"
|
||||||
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
||||||
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
||||||
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
||||||
@@ -81,6 +82,7 @@ type RunFooterOptions = {
|
|||||||
first: boolean
|
first: boolean
|
||||||
history?: RunPrompt[]
|
history?: RunPrompt[]
|
||||||
theme: RunTheme
|
theme: RunTheme
|
||||||
|
keymap: Keymap<Renderable, KeyEvent>
|
||||||
tuiConfig: RunTuiConfig
|
tuiConfig: RunTuiConfig
|
||||||
diffStyle: RunDiffStyle
|
diffStyle: RunDiffStyle
|
||||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||||
@@ -303,8 +305,8 @@ export class RunFooter implements FooterApi {
|
|||||||
const footer = this
|
const footer = this
|
||||||
void render(
|
void render(
|
||||||
() =>
|
() =>
|
||||||
createComponent(Keymap.Provider, {
|
createComponent(OpencodeKeymapProvider, {
|
||||||
config: options.tuiConfig,
|
keymap: options.keymap,
|
||||||
get children() {
|
get children() {
|
||||||
return createComponent(RunFooterView, {
|
return createComponent(RunFooterView, {
|
||||||
directory: options.directory,
|
directory: options.directory,
|
||||||
|
|||||||
@@ -27,8 +27,14 @@ import { RunPromptBody, createPromptState } from "./footer.prompt"
|
|||||||
import { RunPermissionBody } from "./footer.permission"
|
import { RunPermissionBody } from "./footer.permission"
|
||||||
import { RunQuestionBody } from "./footer.question"
|
import { RunQuestionBody } from "./footer.question"
|
||||||
import { footerWidthPolicy } from "./footer.width"
|
import { footerWidthPolicy } from "./footer.width"
|
||||||
import { Keymap } from "@opencode-ai/tui/context/keymap"
|
import {
|
||||||
|
OPENCODE_BASE_MODE,
|
||||||
|
formatKeyBindings,
|
||||||
|
formatKeySequence,
|
||||||
|
useBindings,
|
||||||
|
useKeymapSelector,
|
||||||
|
type OpenTuiKeymap,
|
||||||
|
} from "@opencode-ai/tui/keymap"
|
||||||
import type {
|
import type {
|
||||||
FooterPromptRoute,
|
FooterPromptRoute,
|
||||||
FooterQueuedPrompt,
|
FooterQueuedPrompt,
|
||||||
@@ -171,15 +177,75 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
const current = route()
|
const current = route()
|
||||||
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
|
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
|
||||||
})
|
})
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const command = useKeymapSelector(
|
||||||
const command = () => shortcuts.get("command.palette.show") ?? ""
|
(keymap: OpenTuiKeymap) =>
|
||||||
const subagentShortcut = () => shortcuts.get("session.child.first") ?? ""
|
formatKeySequence(
|
||||||
const queuedShortcut = () => shortcuts.get("session.queued_prompts") ?? ""
|
keymap
|
||||||
const backgroundShortcut = () => shortcuts.get("session.background") ?? ""
|
.getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] })
|
||||||
const subagentInterruptShortcut = () => shortcuts.get("subagent.interrupt") ?? ""
|
.get("command.palette.show")?.[0]?.sequence,
|
||||||
const interrupt = () => shortcuts.get("session.interrupt") ?? ""
|
props.tuiConfig,
|
||||||
const variantCycle = () => shortcuts.all("variant.cycle") ?? ""
|
) ?? "",
|
||||||
const clearShortcut = () => shortcuts.get("prompt.clear") ?? ""
|
)
|
||||||
|
const subagentShortcut = useKeymapSelector(
|
||||||
|
(keymap: OpenTuiKeymap) =>
|
||||||
|
formatKeySequence(
|
||||||
|
keymap
|
||||||
|
.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] })
|
||||||
|
.get("session.child.first")?.[0]?.sequence,
|
||||||
|
props.tuiConfig,
|
||||||
|
) ?? "",
|
||||||
|
)
|
||||||
|
const queuedShortcut = useKeymapSelector(
|
||||||
|
(keymap: OpenTuiKeymap) =>
|
||||||
|
formatKeySequence(
|
||||||
|
keymap
|
||||||
|
.getCommandBindings({ visibility: "registered", commands: ["session.queued_prompts"] })
|
||||||
|
.get("session.queued_prompts")?.[0]?.sequence,
|
||||||
|
props.tuiConfig,
|
||||||
|
) ?? "",
|
||||||
|
)
|
||||||
|
const backgroundShortcut = useKeymapSelector(
|
||||||
|
(keymap: OpenTuiKeymap) =>
|
||||||
|
formatKeySequence(
|
||||||
|
keymap
|
||||||
|
.getCommandBindings({ visibility: "registered", commands: ["session.background"] })
|
||||||
|
.get("session.background")?.[0]?.sequence,
|
||||||
|
props.tuiConfig,
|
||||||
|
) ?? "",
|
||||||
|
)
|
||||||
|
const subagentInterruptShortcut = useKeymapSelector(
|
||||||
|
(keymap: OpenTuiKeymap) =>
|
||||||
|
formatKeySequence(
|
||||||
|
keymap
|
||||||
|
.getCommandBindings({ visibility: "registered", commands: ["subagent.interrupt"] })
|
||||||
|
.get("subagent.interrupt")?.[0]?.sequence,
|
||||||
|
props.tuiConfig,
|
||||||
|
) ?? "",
|
||||||
|
)
|
||||||
|
const interrupt = useKeymapSelector(
|
||||||
|
(keymap: OpenTuiKeymap) =>
|
||||||
|
formatKeySequence(
|
||||||
|
keymap
|
||||||
|
.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
|
||||||
|
.get("session.interrupt")?.[0]?.sequence,
|
||||||
|
props.tuiConfig,
|
||||||
|
) ?? "",
|
||||||
|
)
|
||||||
|
const variantCycle = useKeymapSelector(
|
||||||
|
(keymap: OpenTuiKeymap) =>
|
||||||
|
formatKeyBindings(
|
||||||
|
keymap.getCommandBindings({ visibility: "registered", commands: ["variant.cycle"] }).get("variant.cycle"),
|
||||||
|
props.tuiConfig,
|
||||||
|
) ?? "",
|
||||||
|
)
|
||||||
|
const clearShortcut = useKeymapSelector(
|
||||||
|
(keymap: OpenTuiKeymap) =>
|
||||||
|
formatKeySequence(
|
||||||
|
keymap.getCommandBindings({ visibility: "registered", commands: ["prompt.clear"] }).get("prompt.clear")?.[0]
|
||||||
|
?.sequence,
|
||||||
|
props.tuiConfig,
|
||||||
|
) ?? "",
|
||||||
|
)
|
||||||
const busy = createMemo(() => props.state().phase === "running")
|
const busy = createMemo(() => props.state().phase === "running")
|
||||||
const armed = createMemo(() => props.state().interrupt > 0)
|
const armed = createMemo(() => props.state().interrupt > 0)
|
||||||
const exiting = createMemo(() => props.state().exit > 0)
|
const exiting = createMemo(() => props.state().exit > 0)
|
||||||
@@ -438,62 +504,74 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
props.onRequestExit?.(undefined)
|
props.onRequestExit?.(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
|
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "command.palette.show",
|
name: "command.palette.show",
|
||||||
title: "Open command palette",
|
title: "Open command palette",
|
||||||
group: "Prompt",
|
category: "Prompt",
|
||||||
run: openCommand,
|
run: openCommand,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "variant.cycle",
|
name: "variant.cycle",
|
||||||
title: "Cycle model variant",
|
title: "Cycle model variant",
|
||||||
group: "Model",
|
category: "Model",
|
||||||
run: props.onCycle,
|
run: props.onCycle,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
bindings: [
|
||||||
|
...props.tuiConfig.keybinds.get("command.palette.show"),
|
||||||
|
...props.tuiConfig.keybinds.get("variant.cycle"),
|
||||||
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
|
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
|
||||||
priority: 1,
|
priority: 1,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "session.background",
|
name: "session.background",
|
||||||
title: "Background subagents",
|
title: "Background subagents",
|
||||||
group: "Session",
|
category: "Session",
|
||||||
run: () => props.onBackground?.(),
|
run: () => props.onBackground?.(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
bindings: props.tuiConfig.keybinds.get("session.background"),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
|
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "session.child.first",
|
name: "session.child.first",
|
||||||
title: "View subagents",
|
title: "View subagents",
|
||||||
group: "Session",
|
category: "Session",
|
||||||
run: openSubagentMenu,
|
run: openSubagentMenu,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
bindings: props.tuiConfig.keybinds.get("session.child.first"),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
|
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "session.queued_prompts",
|
name: "session.queued_prompts",
|
||||||
title: "Manage queued prompts",
|
title: "Manage queued prompts",
|
||||||
group: "Session",
|
category: "Session",
|
||||||
run: openQueuedMenu,
|
run: openQueuedMenu,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
useBindings(() => ({
|
||||||
|
mode: OPENCODE_BASE_MODE,
|
||||||
enabled:
|
enabled:
|
||||||
active().type === "prompt" &&
|
active().type === "prompt" &&
|
||||||
route().type === "subagent" &&
|
route().type === "subagent" &&
|
||||||
@@ -502,10 +580,9 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
priority: 1,
|
priority: 1,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "subagent.interrupt",
|
name: "subagent.interrupt",
|
||||||
title: "Interrupt subagent",
|
title: "Interrupt subagent",
|
||||||
group: "Session",
|
category: "Session",
|
||||||
bind: "ctrl+d",
|
|
||||||
run: () => {
|
run: () => {
|
||||||
const current = selectedTab()
|
const current = selectedTab()
|
||||||
if (current?.status !== "running") {
|
if (current?.status !== "running") {
|
||||||
@@ -516,6 +593,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||||
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
||||||
import { isDefaultTitle } from "@opencode-ai/tui/util/session"
|
import { isDefaultTitle } from "@opencode-ai/tui/util/session"
|
||||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
import { Locale } from "@opencode-ai/tui/util/locale"
|
||||||
import { resolveInteractiveStdin } from "./runtime.stdin"
|
import { resolveInteractiveStdin } from "./runtime.stdin"
|
||||||
@@ -165,6 +167,8 @@ function queueSplash(
|
|||||||
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
||||||
const source = resolveInteractiveStdin()
|
const source = resolveInteractiveStdin()
|
||||||
const footerTask = import("./footer")
|
const footerTask = import("./footer")
|
||||||
|
let unregisterKeymap: (() => void) | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const renderer = await createCliRenderer({
|
const renderer = await createCliRenderer({
|
||||||
stdin: source.stdin,
|
stdin: source.stdin,
|
||||||
@@ -183,6 +187,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
})
|
})
|
||||||
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
|
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
|
||||||
renderer.setBackgroundColor(theme.background)
|
renderer.setBackgroundColor(theme.background)
|
||||||
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
|
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
|
||||||
const state: SplashState = {
|
const state: SplashState = {
|
||||||
entry: false,
|
entry: false,
|
||||||
exit: false,
|
exit: false,
|
||||||
@@ -227,6 +233,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
history: input.history,
|
history: input.history,
|
||||||
theme,
|
theme,
|
||||||
wrote,
|
wrote,
|
||||||
|
keymap,
|
||||||
tuiConfig,
|
tuiConfig,
|
||||||
diffStyle: tuiConfig.diff_style ?? "auto",
|
diffStyle: tuiConfig.diff_style ?? "auto",
|
||||||
onPermissionReply: input.onPermissionReply,
|
onPermissionReply: input.onPermissionReply,
|
||||||
@@ -326,6 +333,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
footer.close()
|
footer.close()
|
||||||
await footer.idle().catch(() => {})
|
await footer.idle().catch(() => {})
|
||||||
footer.destroy()
|
footer.destroy()
|
||||||
|
unregisterKeymap?.()
|
||||||
shutdown(renderer)
|
shutdown(renderer)
|
||||||
if (!wroteExit) {
|
if (!wroteExit) {
|
||||||
process.stdout.write("\n")
|
process.stdout.write("\n")
|
||||||
@@ -383,6 +391,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
close,
|
close,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
unregisterKeymap?.()
|
||||||
source.cleanup?.()
|
source.cleanup?.()
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,13 @@ export type Options = {
|
|||||||
readonly port?: number
|
readonly port?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
|
export const run = Effect.fn("cli.server-process.run")((options: Options) =>
|
||||||
export const run = Effect.fnUntraced(function* (options: Options) {
|
processEffect(options).pipe(
|
||||||
return yield* processEffect(options).pipe(
|
|
||||||
Effect.provide(Updater.layer),
|
Effect.provide(Updater.layer),
|
||||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
||||||
Effect.provide(NodeServices.layer),
|
Effect.provide(NodeServices.layer),
|
||||||
)
|
),
|
||||||
})
|
)
|
||||||
|
|
||||||
const processEffect = Effect.fnUntraced(function* (options: Options) {
|
const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
|
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { testRender } from "@opentui/solid"
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
import { Keymap } from "@opencode-ai/tui/context/keymap"
|
import { testRender, useRenderer } from "@opentui/solid"
|
||||||
|
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
||||||
import { resolve } from "@opencode-ai/tui/config/v1"
|
import { resolve } from "@opencode-ai/tui/config/v1"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { createSignal } from "solid-js"
|
import { createComponent, createSignal } from "solid-js"
|
||||||
import { RunFooterView } from "../src/mini/footer.view"
|
import { RunFooterView } from "../src/mini/footer.view"
|
||||||
import { RUN_THEME_FALLBACK } from "../src/mini/theme"
|
import { RUN_THEME_FALLBACK } from "../src/mini/theme"
|
||||||
import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types"
|
import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types"
|
||||||
@@ -41,43 +42,52 @@ test("down opens subagents from an empty prompt", async () => {
|
|||||||
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
|
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
|
||||||
{ terminalSuspend: true },
|
{ terminalSuspend: true },
|
||||||
)
|
)
|
||||||
|
let offKeymap: (() => void) | undefined
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
return (
|
const renderer = useRenderer()
|
||||||
<Keymap.Provider config={config}>
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
<RunFooterView
|
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
||||||
directory="/tmp"
|
|
||||||
findFiles={async () => []}
|
return createComponent(OpencodeKeymapProvider, {
|
||||||
agents={() => []}
|
keymap,
|
||||||
references={() => []}
|
get children() {
|
||||||
commands={() => []}
|
return (
|
||||||
providers={() => undefined}
|
<RunFooterView
|
||||||
currentModel={() => undefined}
|
directory="/tmp"
|
||||||
variants={() => []}
|
findFiles={async () => []}
|
||||||
currentVariant={() => undefined}
|
agents={() => []}
|
||||||
state={state}
|
references={() => []}
|
||||||
view={view}
|
commands={() => []}
|
||||||
subagent={subagents}
|
providers={() => undefined}
|
||||||
theme={() => RUN_THEME_FALLBACK}
|
currentModel={() => undefined}
|
||||||
tuiConfig={config}
|
variants={() => []}
|
||||||
agent="opencode"
|
currentVariant={() => undefined}
|
||||||
onSubmit={() => true}
|
state={state}
|
||||||
onPermissionReply={() => {}}
|
view={view}
|
||||||
onQuestionReply={() => {}}
|
subagent={subagents}
|
||||||
onQuestionReject={() => {}}
|
theme={() => RUN_THEME_FALLBACK}
|
||||||
onCycle={() => {}}
|
tuiConfig={config}
|
||||||
onInterrupt={() => false}
|
agent="opencode"
|
||||||
onEditorOpen={async () => undefined}
|
onSubmit={() => true}
|
||||||
onInputClear={() => {}}
|
onPermissionReply={() => {}}
|
||||||
onExit={() => {}}
|
onQuestionReply={() => {}}
|
||||||
onModelSelect={() => {}}
|
onQuestionReject={() => {}}
|
||||||
onVariantSelect={() => {}}
|
onCycle={() => {}}
|
||||||
onRows={() => {}}
|
onInterrupt={() => false}
|
||||||
onLayout={() => {}}
|
onEditorOpen={async () => undefined}
|
||||||
onStatus={() => {}}
|
onInputClear={() => {}}
|
||||||
onQueuedRemove={async () => true}
|
onExit={() => {}}
|
||||||
/>
|
onModelSelect={() => {}}
|
||||||
</Keymap.Provider>
|
onVariantSelect={() => {}}
|
||||||
)
|
onRows={() => {}}
|
||||||
|
onLayout={() => {}}
|
||||||
|
onStatus={() => {}}
|
||||||
|
onQueuedRemove={async () => true}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
|
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
|
||||||
@@ -90,6 +100,7 @@ test("down opens subagents from an empty prompt", async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
app.renderer.currentFocusedRenderable?.blur()
|
app.renderer.currentFocusedRenderable?.blur()
|
||||||
app.renderer.currentFocusedEditor?.blur()
|
app.renderer.currentFocusedEditor?.blur()
|
||||||
|
offKeymap?.()
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -803,16 +803,6 @@ export type SessionRevertCommitted = {
|
|||||||
data: { sessionID: string; to: string }
|
data: { sessionID: string; to: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionUsageRecorded = {
|
|
||||||
id: string
|
|
||||||
created: number
|
|
||||||
metadata?: { [x: string]: any }
|
|
||||||
type: "session.usage.recorded"
|
|
||||||
durable: { aggregateID: string; seq: number; version: 1 }
|
|
||||||
location?: LocationRef
|
|
||||||
data: { sessionID: string; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelsDevRefreshed = {
|
export type ModelsDevRefreshed = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
@@ -2226,7 +2216,6 @@ export type SessionEventDurable =
|
|||||||
| SessionRevertStaged
|
| SessionRevertStaged
|
||||||
| SessionRevertCleared
|
| SessionRevertCleared
|
||||||
| SessionRevertCommitted
|
| SessionRevertCommitted
|
||||||
| SessionUsageRecorded
|
|
||||||
|
|
||||||
export type SessionMessagesResponse = {
|
export type SessionMessagesResponse = {
|
||||||
data: Array<SessionMessageInfo>
|
data: Array<SessionMessageInfo>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ ultimate source of truth.
|
|||||||
- [x] Direct function declarations are hoisted in program and block statement lists.
|
- [x] Direct function declarations are hoisted in program and block statement lists.
|
||||||
- [x] Parameter defaults observe a temporal dead zone for later parameters.
|
- [x] Parameter defaults observe a temporal dead zone for later parameters.
|
||||||
- [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations.
|
- [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations.
|
||||||
- [x] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and
|
- [ ] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and
|
||||||
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
|
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
|
||||||
temporal dead zone.
|
temporal dead zone.
|
||||||
- [ ] Hoist function declarations accepted directly in switch cases.
|
- [ ] Hoist function declarations accepted directly in switch cases.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"description": "Effect-native confined code execution over schema-described tools",
|
"description": "Effect-native confined code execution over schema-described tools",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -144,20 +144,6 @@ const collectPatternNames = (pattern: AstNode, out: Array<string> = []): Array<s
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
const loopDeclaration = (left: AstNode, statement: "for...of" | "for...in") => {
|
|
||||||
if (left.type !== "VariableDeclaration") return undefined
|
|
||||||
const declarations = getArray(left, "declarations")
|
|
||||||
if (declarations.length !== 1) {
|
|
||||||
throw new InterpreterRuntimeError(`${statement} supports one declared binding.`, left)
|
|
||||||
}
|
|
||||||
const kind = getString(left, "kind")
|
|
||||||
return {
|
|
||||||
pattern: getNode(asNode(declarations[0], "declarations[0]"), "id"),
|
|
||||||
mutable: kind !== "const",
|
|
||||||
lexical: kind !== "var",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Interpreter<R> {
|
export class Interpreter<R> {
|
||||||
private scopes: ScopeStack
|
private scopes: ScopeStack
|
||||||
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||||
@@ -221,7 +207,6 @@ export class Interpreter<R> {
|
|||||||
// Keep top-level declarations separate so they can shadow builtins.
|
// Keep top-level declarations separate so they can shadow builtins.
|
||||||
this.scopes.push()
|
this.scopes.push()
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
self.predeclareLexical(program.body)
|
|
||||||
self.hoistFunctions(program.body)
|
self.hoistFunctions(program.body)
|
||||||
let value: unknown = undefined
|
let value: unknown = undefined
|
||||||
for (const [index, statement] of program.body.entries()) {
|
for (const [index, statement] of program.body.entries()) {
|
||||||
@@ -318,7 +303,6 @@ export class Interpreter<R> {
|
|||||||
const self = this
|
const self = this
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const body = getArray(node, "body")
|
const body = getArray(node, "body")
|
||||||
self.predeclareLexical(body)
|
|
||||||
self.hoistFunctions(body)
|
self.hoistFunctions(body)
|
||||||
|
|
||||||
for (const statementValue of body) {
|
for (const statementValue of body) {
|
||||||
@@ -359,25 +343,6 @@ export class Interpreter<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private predeclareLexical(statements: Array<unknown>): void {
|
|
||||||
for (const statementValue of statements) {
|
|
||||||
if (!isRecord(statementValue) || statementValue.type !== "VariableDeclaration") continue
|
|
||||||
const statement = statementValue as AstNode
|
|
||||||
const kind = getString(statement, "kind")
|
|
||||||
if (kind === "var") continue
|
|
||||||
for (const declarationValue of getArray(statement, "declarations")) {
|
|
||||||
const declaration = asNode(declarationValue, "declarations")
|
|
||||||
for (const name of collectPatternNames(getNode(declaration, "id"))) {
|
|
||||||
this.scopes.reserve(name, kind !== "const", declaration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private predeclarePattern(pattern: AstNode, mutable: boolean, node: AstNode): void {
|
|
||||||
for (const name of collectPatternNames(pattern)) this.scopes.reserve(name, mutable, node)
|
|
||||||
}
|
|
||||||
|
|
||||||
private evaluateIfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
private evaluateIfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||||
const testNode = getNode(node, "test")
|
const testNode = getNode(node, "test")
|
||||||
const consequentNode = getNode(node, "consequent")
|
const consequentNode = getNode(node, "consequent")
|
||||||
@@ -394,6 +359,7 @@ export class Interpreter<R> {
|
|||||||
|
|
||||||
private evaluateSwitchStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
private evaluateSwitchStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||||
const self = this
|
const self = this
|
||||||
|
this.scopes.push()
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant"))
|
const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant"))
|
||||||
if (containsOpaqueReference(discriminant)) {
|
if (containsOpaqueReference(discriminant)) {
|
||||||
@@ -403,43 +369,39 @@ export class Interpreter<R> {
|
|||||||
"InvalidDataValue",
|
"InvalidDataValue",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
self.scopes.push()
|
const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`))
|
||||||
return yield* Effect.gen(function* () {
|
let defaultIndex: number | undefined
|
||||||
const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`))
|
let selected: number | undefined
|
||||||
self.predeclareLexical(cases.flatMap((branch) => getArray(branch, "consequent")))
|
for (const [index, branch] of cases.entries()) {
|
||||||
let defaultIndex: number | undefined
|
const test = getOptionalNode(branch, "test")
|
||||||
let selected: number | undefined
|
if (!test) {
|
||||||
for (const [index, branch] of cases.entries()) {
|
defaultIndex = index
|
||||||
const test = getOptionalNode(branch, "test")
|
continue
|
||||||
if (!test) {
|
|
||||||
defaultIndex = index
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const candidate = yield* self.evaluateExpression(test)
|
|
||||||
if (containsOpaqueReference(candidate)) {
|
|
||||||
throw new InterpreterRuntimeError(
|
|
||||||
"Switch case values must be data values in CodeMode.",
|
|
||||||
test,
|
|
||||||
"InvalidDataValue",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (candidate === discriminant) {
|
|
||||||
selected = index
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const start = selected ?? defaultIndex
|
const candidate = yield* self.evaluateExpression(test)
|
||||||
if (start === undefined) return { kind: "none" } satisfies StatementResult
|
if (containsOpaqueReference(candidate)) {
|
||||||
for (let index = start; index < cases.length; index += 1) {
|
throw new InterpreterRuntimeError(
|
||||||
for (const statementValue of getArray(cases[index]!, "consequent")) {
|
"Switch case values must be data values in CodeMode.",
|
||||||
const result = yield* self.evaluateStatement(asNode(statementValue, "consequent"))
|
test,
|
||||||
if (result.kind === "break") return { kind: "none" } satisfies StatementResult
|
"InvalidDataValue",
|
||||||
if (result.kind === "return" || result.kind === "continue") return result
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { kind: "none" } satisfies StatementResult
|
if (candidate === discriminant) {
|
||||||
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
|
selected = index
|
||||||
})
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const start = selected ?? defaultIndex
|
||||||
|
if (start === undefined) return { kind: "none" } satisfies StatementResult
|
||||||
|
for (let index = start; index < cases.length; index += 1) {
|
||||||
|
for (const statementValue of getArray(cases[index]!, "consequent")) {
|
||||||
|
const result = yield* self.evaluateStatement(asNode(statementValue, "consequent"))
|
||||||
|
if (result.kind === "break") return { kind: "none" } satisfies StatementResult
|
||||||
|
if (result.kind === "return" || result.kind === "continue") return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kind: "none" } satisfies StatementResult
|
||||||
|
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
|
||||||
}
|
}
|
||||||
|
|
||||||
private evaluateWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
private evaluateWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||||
@@ -503,10 +465,6 @@ export class Interpreter<R> {
|
|||||||
const updateNode = getOptionalNode(node, "update")
|
const updateNode = getOptionalNode(node, "update")
|
||||||
const bodyNode = getNode(node, "body")
|
const bodyNode = getNode(node, "body")
|
||||||
|
|
||||||
if (initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var") {
|
|
||||||
self.predeclareLexical([initNode])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initNode) {
|
if (initNode) {
|
||||||
if (initNode.type === "VariableDeclaration") {
|
if (initNode.type === "VariableDeclaration") {
|
||||||
yield* self.evaluateVariableDeclaration(initNode)
|
yield* self.evaluateVariableDeclaration(initNode)
|
||||||
@@ -520,18 +478,21 @@ export class Interpreter<R> {
|
|||||||
? Array.from(self.scopes.current().keys())
|
? Array.from(self.scopes.current().keys())
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const nextIteration = () => {
|
|
||||||
if (perIterationBindings.length === 0) return
|
|
||||||
const current = self.scopes.current()
|
|
||||||
self.scopes.pop()
|
|
||||||
self.scopes.push(
|
|
||||||
new Map(perIterationBindings.map((name): [string, Binding] => [name, { ...current.get(name)! }])),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
nextIteration()
|
|
||||||
|
|
||||||
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
|
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
|
||||||
const result = yield* self.evaluateStatement(bodyNode)
|
const iterationScope =
|
||||||
|
perIterationBindings.length > 0
|
||||||
|
? new Map(
|
||||||
|
perIterationBindings.map((name): [string, Binding] => [name, { ...self.scopes.current().get(name)! }]),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
if (iterationScope) self.scopes.push(iterationScope)
|
||||||
|
const result = yield* self.evaluateStatement(bodyNode).pipe(
|
||||||
|
Effect.ensuring(
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (iterationScope) self.scopes.pop()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if (result.kind === "return") {
|
if (result.kind === "return") {
|
||||||
return result
|
return result
|
||||||
@@ -541,7 +502,13 @@ export class Interpreter<R> {
|
|||||||
return { kind: "none" } satisfies StatementResult
|
return { kind: "none" } satisfies StatementResult
|
||||||
}
|
}
|
||||||
|
|
||||||
nextIteration()
|
if (iterationScope) {
|
||||||
|
const loopScope = self.scopes.current()
|
||||||
|
for (const name of perIterationBindings) {
|
||||||
|
loopScope.set(name, { ...iterationScope.get(name)! })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (updateNode) {
|
if (updateNode) {
|
||||||
yield* self.evaluateExpression(updateNode)
|
yield* self.evaluateExpression(updateNode)
|
||||||
}
|
}
|
||||||
@@ -560,13 +527,9 @@ export class Interpreter<R> {
|
|||||||
throw new InterpreterRuntimeError("for await...of is not supported.", node)
|
throw new InterpreterRuntimeError("for await...of is not supported.", node)
|
||||||
}
|
}
|
||||||
|
|
||||||
const left = getNode(node, "left")
|
|
||||||
const declared = loopDeclaration(left, "for...of")
|
|
||||||
if (declared?.lexical) this.scopes.push()
|
|
||||||
|
|
||||||
const self = this
|
const self = this
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
const left = getNode(node, "left")
|
||||||
const right = yield* self.evaluateExpression(getNode(node, "right"))
|
const right = yield* self.evaluateExpression(getNode(node, "right"))
|
||||||
const body = getNode(node, "body")
|
const body = getNode(node, "body")
|
||||||
|
|
||||||
@@ -575,34 +538,40 @@ export class Interpreter<R> {
|
|||||||
throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node)
|
throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
|
||||||
let assignment: AstNode | undefined
|
let assignment: AstNode | undefined
|
||||||
|
|
||||||
if (
|
if (left.type === "VariableDeclaration") {
|
||||||
left.type !== "VariableDeclaration" &&
|
const declarations = getArray(left, "declarations")
|
||||||
(left.type === "Identifier" ||
|
if (declarations.length !== 1) {
|
||||||
left.type === "MemberExpression" ||
|
throw new InterpreterRuntimeError("for...of supports one declared binding.", left)
|
||||||
left.type === "ArrayPattern" ||
|
}
|
||||||
left.type === "ObjectPattern")
|
|
||||||
|
const declarator = asNode(declarations[0], "declarations[0]")
|
||||||
|
declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
|
||||||
|
} else if (
|
||||||
|
left.type === "Identifier" ||
|
||||||
|
left.type === "MemberExpression" ||
|
||||||
|
left.type === "ArrayPattern" ||
|
||||||
|
left.type === "ObjectPattern"
|
||||||
) {
|
) {
|
||||||
assignment = left
|
assignment = left
|
||||||
} else if (left.type !== "VariableDeclaration") {
|
} else {
|
||||||
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
|
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const value of iterable) {
|
for (const value of iterable) {
|
||||||
const result = yield* Effect.gen(function* () {
|
if (declaration) {
|
||||||
if (declared) {
|
self.scopes.push()
|
||||||
self.scopes.push()
|
yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left)
|
||||||
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
} else if (assignment) {
|
||||||
yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical)
|
yield* self.assignPattern(assignment, value, left)
|
||||||
} else if (assignment) {
|
}
|
||||||
yield* self.assignPattern(assignment, value, left)
|
|
||||||
}
|
const result = yield* self.evaluateStatement(body).pipe(
|
||||||
return yield* self.evaluateStatement(body)
|
|
||||||
}).pipe(
|
|
||||||
Effect.ensuring(
|
Effect.ensuring(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
if (declared) self.scopes.pop()
|
if (declaration) self.scopes.pop()
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -612,7 +581,7 @@ export class Interpreter<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.kind === "break") {
|
if (result.kind === "break") {
|
||||||
return { kind: "none" } satisfies StatementResult
|
return { kind: "none" }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.kind === "continue") {
|
if (result.kind === "continue") {
|
||||||
@@ -620,14 +589,8 @@ export class Interpreter<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { kind: "none" } satisfies StatementResult
|
return { kind: "none" }
|
||||||
}).pipe(
|
})
|
||||||
Effect.ensuring(
|
|
||||||
Effect.sync(() => {
|
|
||||||
if (declared?.lexical) self.scopes.pop()
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enumerableKeys(value: unknown): Array<string> | undefined {
|
private enumerableKeys(value: unknown): Array<string> | undefined {
|
||||||
@@ -644,13 +607,9 @@ export class Interpreter<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private evaluateForInStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
private evaluateForInStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||||
const left = getNode(node, "left")
|
|
||||||
const declared = loopDeclaration(left, "for...in")
|
|
||||||
if (declared?.lexical) this.scopes.push()
|
|
||||||
|
|
||||||
const self = this
|
const self = this
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
const left = getNode(node, "left")
|
||||||
const right = yield* self.evaluateExpression(getNode(node, "right"))
|
const right = yield* self.evaluateExpression(getNode(node, "right"))
|
||||||
const body = getNode(node, "body")
|
const body = getNode(node, "body")
|
||||||
|
|
||||||
@@ -662,28 +621,35 @@ export class Interpreter<R> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
|
||||||
let assignmentName: string | undefined
|
let assignmentName: string | undefined
|
||||||
|
|
||||||
if (left.type === "Identifier") {
|
if (left.type === "VariableDeclaration") {
|
||||||
|
const declarations = getArray(left, "declarations")
|
||||||
|
if (declarations.length !== 1) {
|
||||||
|
throw new InterpreterRuntimeError("for...in supports one declared binding.", left)
|
||||||
|
}
|
||||||
|
|
||||||
|
const declarator = asNode(declarations[0], "declarations[0]")
|
||||||
|
declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
|
||||||
|
} else if (left.type === "Identifier") {
|
||||||
assignmentName = getString(left, "name")
|
assignmentName = getString(left, "name")
|
||||||
} else if (left.type !== "VariableDeclaration") {
|
} else {
|
||||||
throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
|
throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const result = yield* Effect.gen(function* () {
|
if (declaration) {
|
||||||
if (declared) {
|
self.scopes.push()
|
||||||
self.scopes.push()
|
yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left)
|
||||||
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
} else if (assignmentName) {
|
||||||
yield* self.declarePattern(declared.pattern, key, declared.mutable, left, declared.lexical)
|
self.scopes.set(assignmentName, key, left)
|
||||||
} else if (assignmentName) {
|
}
|
||||||
self.scopes.set(assignmentName, key, left)
|
|
||||||
}
|
const result = yield* self.evaluateStatement(body).pipe(
|
||||||
return yield* self.evaluateStatement(body)
|
|
||||||
}).pipe(
|
|
||||||
Effect.ensuring(
|
Effect.ensuring(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
if (declared) self.scopes.pop()
|
if (declaration) self.scopes.pop()
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -693,7 +659,7 @@ export class Interpreter<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.kind === "break") {
|
if (result.kind === "break") {
|
||||||
return { kind: "none" } satisfies StatementResult
|
return { kind: "none" }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.kind === "continue") {
|
if (result.kind === "continue") {
|
||||||
@@ -701,14 +667,8 @@ export class Interpreter<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { kind: "none" } satisfies StatementResult
|
return { kind: "none" }
|
||||||
}).pipe(
|
})
|
||||||
Effect.ensuring(
|
|
||||||
Effect.sync(() => {
|
|
||||||
if (declared?.lexical) self.scopes.pop()
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private evaluateBreakStatement(node: AstNode): StatementResult {
|
private evaluateBreakStatement(node: AstNode): StatementResult {
|
||||||
@@ -792,7 +752,7 @@ export class Interpreter<R> {
|
|||||||
|
|
||||||
const init = getOptionalNode(declaration, "init")
|
const init = getOptionalNode(declaration, "init")
|
||||||
const value = init ? yield* self.evaluateExpression(init) : undefined
|
const value = init ? yield* self.evaluateExpression(init) : undefined
|
||||||
yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration, kind !== "var")
|
yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -802,20 +762,17 @@ export class Interpreter<R> {
|
|||||||
value: unknown,
|
value: unknown,
|
||||||
mutable: boolean,
|
mutable: boolean,
|
||||||
node: AstNode,
|
node: AstNode,
|
||||||
initialize = false,
|
|
||||||
): Effect.Effect<void, unknown, R> {
|
): Effect.Effect<void, unknown, R> {
|
||||||
const self = this
|
const self = this
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
if (pattern.type === "Identifier") {
|
if (pattern.type === "Identifier") {
|
||||||
const name = getString(pattern, "name")
|
self.scopes.declare(getString(pattern, "name"), value, mutable, node)
|
||||||
if (initialize) self.scopes.initialize(name, value, node)
|
|
||||||
else self.scopes.declare(name, value, mutable, node)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pattern.type === "AssignmentPattern") {
|
if (pattern.type === "AssignmentPattern") {
|
||||||
const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value
|
const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value
|
||||||
yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node, initialize)
|
yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,7 +794,7 @@ export class Interpreter<R> {
|
|||||||
for (const [key, item] of Object.entries(value as SafeObject)) {
|
for (const [key, item] of Object.entries(value as SafeObject)) {
|
||||||
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
|
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
|
||||||
}
|
}
|
||||||
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize)
|
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,7 +808,6 @@ export class Interpreter<R> {
|
|||||||
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
|
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
|
||||||
mutable,
|
mutable,
|
||||||
property,
|
property,
|
||||||
initialize,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -867,10 +823,10 @@ export class Interpreter<R> {
|
|||||||
if (item === null) continue
|
if (item === null) continue
|
||||||
const element = asNode(item, `elements[${index}]`)
|
const element = asNode(item, `elements[${index}]`)
|
||||||
if (element.type === "RestElement") {
|
if (element.type === "RestElement") {
|
||||||
yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element, initialize)
|
yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
yield* self.declarePattern(element, items[index], mutable, pattern, initialize)
|
yield* self.declarePattern(element, items[index], mutable, pattern)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1621,10 +1577,10 @@ export class Interpreter<R> {
|
|||||||
}
|
}
|
||||||
for (const [index, parameter] of fn.parameters.entries()) {
|
for (const [index, parameter] of fn.parameters.entries()) {
|
||||||
if (parameter.type === "RestElement") {
|
if (parameter.type === "RestElement") {
|
||||||
yield* invocation.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter, true)
|
yield* invocation.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
yield* invocation.declarePattern(parameter, args[index], true, parameter, true)
|
yield* invocation.declarePattern(parameter, args[index], true, parameter)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fn.body.type === "BlockStatement") {
|
if (fn.body.type === "BlockStatement") {
|
||||||
|
|||||||
@@ -7,28 +7,14 @@ export class ScopeStack {
|
|||||||
this.scopes = scopes
|
this.scopes = scopes
|
||||||
}
|
}
|
||||||
|
|
||||||
reserve(name: string, mutable: boolean, node: AstNode): void {
|
|
||||||
const scope = this.current()
|
|
||||||
if (scope.has(name)) {
|
|
||||||
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
|
|
||||||
}
|
|
||||||
scope.set(name, { mutable, value: undefined, initialized: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
initialize(name: string, value: unknown, node: AstNode): void {
|
|
||||||
const binding = this.current().get(name)
|
|
||||||
if (!binding || binding.initialized !== false) {
|
|
||||||
throw new InterpreterRuntimeError(`Identifier '${name}' has not been reserved for initialization.`, node)
|
|
||||||
}
|
|
||||||
binding.value = value
|
|
||||||
binding.initialized = true
|
|
||||||
}
|
|
||||||
|
|
||||||
declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
|
declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
|
||||||
const scope = this.current()
|
const scope = this.current()
|
||||||
if (scope.has(name)) {
|
|
||||||
|
const existing = scope.get(name)
|
||||||
|
if (existing && existing.initialized !== false) {
|
||||||
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
|
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
scope.set(name, { mutable, value, initialized: true })
|
scope.set(name, { mutable, value, initialized: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +39,6 @@ export class ScopeStack {
|
|||||||
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
|
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (binding.initialized === false) {
|
|
||||||
throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!binding.mutable) {
|
if (!binding.mutable) {
|
||||||
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError")
|
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
/*
|
|
||||||
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
|
|
||||||
* - test/language/statements/let/global-use-before-initialization-in-prior-statement.js
|
|
||||||
* - test/language/statements/let/block-local-use-before-initialization-in-prior-statement.js
|
|
||||||
* - test/language/statements/const/global-use-before-initialization-in-prior-statement.js
|
|
||||||
* - test/language/statements/const/block-local-use-before-initialization-in-prior-statement.js
|
|
||||||
* - test/language/statements/let/block-local-use-before-initialization-in-declaration-statement.js
|
|
||||||
* - test/language/statements/const/block-local-use-before-initialization-in-declaration-statement.js
|
|
||||||
* - test/language/statements/let/block-local-closure-set-before-initialization.js
|
|
||||||
* - test/language/statements/for-of/head-let-bound-names-fordecl-tdz.js
|
|
||||||
* - test/language/statements/for-in/head-let-bound-names-fordecl-tdz.js
|
|
||||||
* - test/language/statements/let/syntax/let-iteration-variable-is-freshly-allocated-for-each-iteration-single-let-binding.js
|
|
||||||
* - test/language/statements/let/syntax/let-iteration-variable-is-freshly-allocated-for-each-iteration-multi-let-binding.js
|
|
||||||
* - test/language/statements/for-of/head-let-fresh-binding-per-iteration.js
|
|
||||||
* - test/language/statements/for-in/head-let-fresh-binding-per-iteration.js
|
|
||||||
* - test/language/statements/for/scope-head-lex-open.js
|
|
||||||
* - test/language/statements/for/scope-body-lex-open.js
|
|
||||||
* - test/language/statements/switch/scope-lex-open-case.js
|
|
||||||
* - test/language/statements/switch/scope-lex-close-case.js
|
|
||||||
* - test/language/statements/function/dflt-params-ref-prior.js
|
|
||||||
* - test/language/statements/function/dflt-params-ref-later.js
|
|
||||||
* - test/language/statements/function/dflt-params-ref-self.js
|
|
||||||
*
|
|
||||||
* Copyright (C) 2011, 2014, 2016 the V8 project authors. All rights reserved.
|
|
||||||
* Test262 portions are governed by the BSD license in LICENSE.test262.
|
|
||||||
*/
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { CodeMode } from "../src/index.js"
|
|
||||||
|
|
||||||
const value = async (code: string) => {
|
|
||||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
|
||||||
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
|
|
||||||
return result.value
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Test262 lexical temporal dead zones", () => {
|
|
||||||
test("program and block bindings exist before initialization", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
const errors = []
|
|
||||||
try { programLet; let programLet } catch (error) { errors.push(error.name) }
|
|
||||||
try { programConst; const programConst = 1 } catch (error) { errors.push(error.name) }
|
|
||||||
try { { blockLet; let blockLet } } catch (error) { errors.push(error.name) }
|
|
||||||
try { { blockConst; const blockConst = 1 } } catch (error) { errors.push(error.name) }
|
|
||||||
return errors
|
|
||||||
`),
|
|
||||||
).toEqual(["ReferenceError", "ReferenceError", "ReferenceError", "ReferenceError"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("self-initialization and closure assignment observe the TDZ", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
const errors = []
|
|
||||||
try { { let item = item + 1 } } catch (error) { errors.push(error.name) }
|
|
||||||
try { { const item = item + 1 } } catch (error) { errors.push(error.name) }
|
|
||||||
try {
|
|
||||||
{
|
|
||||||
function assign() { item = 1 }
|
|
||||||
assign()
|
|
||||||
let item
|
|
||||||
}
|
|
||||||
} catch (error) { errors.push(error.name) }
|
|
||||||
return errors
|
|
||||||
`),
|
|
||||||
).toEqual(["ReferenceError", "ReferenceError", "ReferenceError"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("for-of and for-in bound names are in the head TDZ", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
const errors = []
|
|
||||||
try { let item = [1]; for (let item of item) {} } catch (error) { errors.push(error.name) }
|
|
||||||
try { let item = { value: 1 }; for (let item in item) {} } catch (error) { errors.push(error.name) }
|
|
||||||
return errors
|
|
||||||
`),
|
|
||||||
).toEqual(["ReferenceError", "ReferenceError"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Test262 lexical loop environments", () => {
|
|
||||||
test("classic for creates fresh single and multiple bindings", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
const single = []
|
|
||||||
for (let index = 0; index < 5; ++index) single.push(() => index)
|
|
||||||
|
|
||||||
const left = []
|
|
||||||
const right = []
|
|
||||||
for (let first = 0, second = 10; first < 5; ++first, ++second) {
|
|
||||||
left.push(() => first)
|
|
||||||
right.push(() => second)
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
single.map((read) => read()),
|
|
||||||
left.map((read) => read()),
|
|
||||||
right.map((read) => read()),
|
|
||||||
]
|
|
||||||
`),
|
|
||||||
).toEqual([
|
|
||||||
[0, 1, 2, 3, 4],
|
|
||||||
[0, 1, 2, 3, 4],
|
|
||||||
[10, 11, 12, 13, 14],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("for-of and for-in create fresh bindings", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
const values = []
|
|
||||||
for (let item of [1, 2, 3]) values.push(() => item)
|
|
||||||
|
|
||||||
const keys = {}
|
|
||||||
for (let key in { first: 1, second: 2, third: 3 }) keys[key] = () => key
|
|
||||||
return [values.map((read) => read()), keys.first(), keys.second(), keys.third()]
|
|
||||||
`),
|
|
||||||
).toEqual([[1, 2, 3], "first", "second", "third"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("classic for separates declaration and per-iteration environments", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
let before
|
|
||||||
let testRead
|
|
||||||
let bodyRead
|
|
||||||
let updateRead
|
|
||||||
let run = true
|
|
||||||
for (
|
|
||||||
let item = "outside", ignored = before = () => item;
|
|
||||||
run && (item = "inside", testRead = () => item);
|
|
||||||
updateRead = () => item
|
|
||||||
) bodyRead = () => item, run = false
|
|
||||||
return [before(), testRead(), bodyRead(), updateRead()]
|
|
||||||
`),
|
|
||||||
).toEqual(["outside", "inside", "inside", "inside"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Test262 switch and parameter environments", () => {
|
|
||||||
test("switch creates its lexical environment after the discriminant", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
let item = "outside"
|
|
||||||
let discriminantRead
|
|
||||||
let selectorRead
|
|
||||||
let statementRead
|
|
||||||
switch ((discriminantRead = () => item, null)) {
|
|
||||||
case (selectorRead = () => item, null):
|
|
||||||
statementRead = () => item
|
|
||||||
let item = "inside"
|
|
||||||
}
|
|
||||||
return [discriminantRead(), selectorRead(), statementRead()]
|
|
||||||
`),
|
|
||||||
).toEqual(["outside", "inside", "inside"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("all switch cases share one lexical environment that closes afterward", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
let item = "outside"
|
|
||||||
let firstRead
|
|
||||||
let secondRead
|
|
||||||
switch (null) {
|
|
||||||
case null:
|
|
||||||
let item = "inside"
|
|
||||||
firstRead = () => item
|
|
||||||
case null:
|
|
||||||
secondRead = () => item
|
|
||||||
}
|
|
||||||
return [firstRead(), secondRead(), item]
|
|
||||||
`),
|
|
||||||
).toEqual(["inside", "inside", "outside"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parameter defaults see prior bindings but not self or later bindings", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
function prior(first, second = first, third = second) { return [first, second, third] }
|
|
||||||
function later(first = second, second) { return first }
|
|
||||||
function self(item = item) { return item }
|
|
||||||
function failure(run) {
|
|
||||||
try { return run() } catch (error) { return error.name }
|
|
||||||
}
|
|
||||||
return [prior(3), failure(later), failure(self)]
|
|
||||||
`),
|
|
||||||
).toEqual([[3, 3, 3], "ReferenceError", "ReferenceError"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -99,66 +99,12 @@ describe("H4: typeof on an undeclared identifier is 'undefined'", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("CodeMode lexical scope integration", () => {
|
|
||||||
test("keeps self, cross, and destructuring defaults in the TDZ", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
const outer = 1
|
|
||||||
const errors = []
|
|
||||||
try { const first = second, second = 2 } catch (error) { errors.push(error.name) }
|
|
||||||
try { const [first = second, second = 2] = [] } catch (error) { errors.push(error.name) }
|
|
||||||
return errors
|
|
||||||
`),
|
|
||||||
).toEqual(["ReferenceError", "ReferenceError"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps typeof and constant assignment inside the TDZ", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
const errors = []
|
|
||||||
try { { errors.push(typeof item); let item } } catch (error) { errors.push(error.name) }
|
|
||||||
try { { constant = 1; const constant = 2 } } catch (error) { errors.push(error.name) }
|
|
||||||
return errors
|
|
||||||
`),
|
|
||||||
).toEqual(["ReferenceError", "ReferenceError"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("shadows builtins from the start of the program scope", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
let observed
|
|
||||||
try { observed = typeof Promise } catch (error) { observed = error.name }
|
|
||||||
const Promise = 1
|
|
||||||
return observed
|
|
||||||
`),
|
|
||||||
).toBe("ReferenceError")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps classic for initializers inside the header TDZ", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
let index = 1
|
|
||||||
try { for (let index = index; index < 2; index++) {} } catch (error) { return error.name }
|
|
||||||
`),
|
|
||||||
).toBe("ReferenceError")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("removes loop scopes when per-iteration initialization fails", async () => {
|
|
||||||
expect(
|
|
||||||
await value(`
|
|
||||||
const value = "outer"
|
|
||||||
try { for (let [value] of [1]) {} } catch {}
|
|
||||||
return value
|
|
||||||
`),
|
|
||||||
).toBe("outer")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("unary void", () => {
|
describe("unary void", () => {
|
||||||
test("evaluates its operand and returns undefined", async () => {
|
test("evaluates its operand and returns undefined", async () => {
|
||||||
expect(
|
expect(await value(`let count = 0; const result = void (count += 1); return [count, result === undefined]`)).toEqual([
|
||||||
await value(`let count = 0; const result = void (count += 1); return [count, result === undefined]`),
|
1,
|
||||||
).toEqual([1, true])
|
true,
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("discards opaque values", async () => {
|
test("discards opaque values", async () => {
|
||||||
@@ -191,11 +137,12 @@ describe("property deletion", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("deleting an array index creates a hole without changing its length", async () => {
|
test("deleting an array index creates a hole without changing its length", async () => {
|
||||||
expect(
|
expect(await value(`const values = [1, 2, 3]; const removed = delete values[1]; return [removed, values.length, 1 in values, values]`)).toEqual([
|
||||||
await value(
|
true,
|
||||||
`const values = [1, 2, 3]; const removed = delete values[1]; return [removed, values.length, 1 in values, values]`,
|
3,
|
||||||
),
|
false,
|
||||||
).toEqual([true, 3, false, [1, null, 3]])
|
[1, null, 3],
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("array length is not configurable", async () => {
|
test("array length is not configurable", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export class Keep extends Schema.Class<Keep>("ConfigV2.Compaction.Keep")({
|
|||||||
|
|
||||||
export class Info extends Schema.Class<Info>("ConfigV2.Compaction")({
|
export class Info extends Schema.Class<Info>("ConfigV2.Compaction")({
|
||||||
auto: Schema.Boolean.pipe(Schema.optional),
|
auto: Schema.Boolean.pipe(Schema.optional),
|
||||||
|
prune: Schema.Boolean.pipe(Schema.optional),
|
||||||
keep: Keep.pipe(Schema.optional),
|
keep: Keep.pipe(Schema.optional),
|
||||||
buffer: NonNegativeInt.pipe(Schema.optional),
|
buffer: NonNegativeInt.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -168,12 +168,6 @@ export interface Interface extends State.Transformable<Draft> {
|
|||||||
readonly resolve: (
|
readonly resolve: (
|
||||||
connection: IntegrationConnection.Info,
|
connection: IntegrationConnection.Info,
|
||||||
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
|
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
|
||||||
/** Resolves with a refresh implementation that may not be materialized in the registry yet. */
|
|
||||||
readonly resolveWithRefresh: (
|
|
||||||
connection: IntegrationConnection.Info,
|
|
||||||
methodID: MethodID,
|
|
||||||
refresh: NonNullable<OAuthImplementation["refresh"]>,
|
|
||||||
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
|
|
||||||
/** Runs a key method and stores the resulting credential. */
|
/** Runs a key method and stores the resulting credential. */
|
||||||
readonly key: (input: {
|
readonly key: (input: {
|
||||||
/** Integration receiving the credential. */
|
/** Integration receiving the credential. */
|
||||||
@@ -662,34 +656,6 @@ const layer = Layer.effect(
|
|||||||
return CommandAttempt.make({ attemptID, time })
|
return CommandAttempt.make({ attemptID, time })
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolveConnection = Effect.fnUntraced(function* (
|
|
||||||
connection: IntegrationConnection.Info,
|
|
||||||
fallback?: {
|
|
||||||
readonly methodID: MethodID
|
|
||||||
readonly refresh: NonNullable<OAuthImplementation["refresh"]>
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (connection.type === "env") {
|
|
||||||
const key = process.env[connection.name]
|
|
||||||
return key ? Credential.Key.make({ type: "key", key }) : undefined
|
|
||||||
}
|
|
||||||
const credential = yield* credentials.get(connection.id)
|
|
||||||
if (!credential) return undefined
|
|
||||||
if (credential.value.type === "key") return credential.value
|
|
||||||
const implementation = state
|
|
||||||
.get()
|
|
||||||
.integrations.get(credential.integrationID)
|
|
||||||
?.implementations.get(credential.value.methodID)
|
|
||||||
const refresh =
|
|
||||||
implementation?.refresh ?? (fallback?.methodID === credential.value.methodID ? fallback.refresh : undefined)
|
|
||||||
if (!refresh) return credential.value
|
|
||||||
const now = yield* Clock.currentTimeMillis
|
|
||||||
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
|
|
||||||
const value = yield* authorize(refresh(credential.value))
|
|
||||||
yield* credentials.update(credential.id, { value })
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
transform: state.transform,
|
transform: state.transform,
|
||||||
reload: state.reload,
|
reload: state.reload,
|
||||||
@@ -709,10 +675,25 @@ const layer = Layer.effect(
|
|||||||
const entry = state.get().integrations.get(id)
|
const entry = state.get().integrations.get(id)
|
||||||
return resolveConnections(entry, yield* credentials.list(id))[0]
|
return resolveConnections(entry, yield* credentials.list(id))[0]
|
||||||
}),
|
}),
|
||||||
resolve: Effect.fn("Integration.connection.resolve")((connection) => resolveConnection(connection)),
|
resolve: Effect.fn("Integration.connection.resolve")(function* (connection) {
|
||||||
resolveWithRefresh: Effect.fn("Integration.connection.resolveWithRefresh")((connection, methodID, refresh) =>
|
if (connection.type === "env") {
|
||||||
resolveConnection(connection, { methodID, refresh }),
|
const key = process.env[connection.name]
|
||||||
),
|
return key ? Credential.Key.make({ type: "key", key }) : undefined
|
||||||
|
}
|
||||||
|
const credential = yield* credentials.get(connection.id)
|
||||||
|
if (!credential) return undefined
|
||||||
|
if (credential.value.type === "key") return credential.value
|
||||||
|
const implementation = state
|
||||||
|
.get()
|
||||||
|
.integrations.get(credential.integrationID)
|
||||||
|
?.implementations.get(credential.value.methodID)
|
||||||
|
if (!implementation?.refresh) return credential.value
|
||||||
|
const now = yield* Clock.currentTimeMillis
|
||||||
|
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
|
||||||
|
const value = yield* authorize(implementation.refresh(credential.value))
|
||||||
|
yield* credentials.update(credential.id, { value })
|
||||||
|
return value
|
||||||
|
}),
|
||||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||||
const method = state
|
const method = state
|
||||||
.get()
|
.get()
|
||||||
|
|||||||
@@ -176,7 +176,6 @@ export const OpenAIPlugin = define({
|
|||||||
draft.method.update(browser)
|
draft.method.update(browser)
|
||||||
draft.method.update(headless)
|
draft.method.update(headless)
|
||||||
})
|
})
|
||||||
yield* load()
|
|
||||||
yield* ctx.catalog.transform((evt) => {
|
yield* ctx.catalog.transform((evt) => {
|
||||||
for (const item of evt.provider.list()) {
|
for (const item of evt.provider.list()) {
|
||||||
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
if (!ProviderV2.isAISDK(item.provider.package)) continue
|
||||||
@@ -214,6 +213,7 @@ export const OpenAIPlugin = define({
|
|||||||
Stream.runForEach(refresh),
|
Stream.runForEach(refresh),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
|
yield* refresh().pipe(Effect.forkScoped)
|
||||||
yield* ctx.aisdk.hook(
|
yield* ctx.aisdk.hook(
|
||||||
"sdk",
|
"sdk",
|
||||||
Effect.fn(function* (evt) {
|
Effect.fn(function* (evt) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
|
|||||||
import type { Scope } from "effect"
|
import type { Scope } from "effect"
|
||||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import type { CredentialOAuth, CredentialValue } from "@opencode-ai/sdk/v2/types"
|
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
||||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
@@ -79,40 +79,24 @@ function oauth(http: HttpClient.HttpClient) {
|
|||||||
label: (credential) => {
|
label: (credential) => {
|
||||||
return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined
|
return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined
|
||||||
},
|
},
|
||||||
} satisfies Integration.OAuthImplementation
|
} satisfies IntegrationOAuthMethodRegistration
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Integration.Service | Scope.Scope>({
|
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
|
||||||
id: "opencode.provider.opencode",
|
id: "opencode.provider.opencode",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
const integrations = yield* Integration.Service
|
|
||||||
const method = oauth(http)
|
|
||||||
const coreCredential = (credential: CredentialOAuth) =>
|
|
||||||
Credential.OAuth.make({ ...credential, methodID: Integration.MethodID.make(credential.methodID) })
|
|
||||||
const registration = {
|
|
||||||
...method,
|
|
||||||
refresh: (credential: CredentialOAuth) => method.refresh(coreCredential(credential)),
|
|
||||||
label: (credential: CredentialOAuth) => method.label(coreCredential(credential)),
|
|
||||||
} satisfies IntegrationOAuthMethodRegistration
|
|
||||||
const loading = Semaphore.makeUnsafe(1)
|
const loading = Semaphore.makeUnsafe(1)
|
||||||
let connected = false
|
let connected = false
|
||||||
let remoteConfigRequired = false
|
|
||||||
let providers: typeof ConfigV1.Info.Type.provider | undefined
|
let providers: typeof ConfigV1.Info.Type.provider | undefined
|
||||||
|
|
||||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||||
const connection = yield* integrations.connection.active(Integration.ID.make("opencode"))
|
const connection = yield* ctx.integration.connection.active("opencode")
|
||||||
const credential = connection
|
const credential = connection
|
||||||
? yield* integrations.connection
|
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
.resolveWithRefresh(connection, method.method.id, method.refresh)
|
|
||||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
: undefined
|
: undefined
|
||||||
connected = connection !== undefined
|
connected = connection !== undefined
|
||||||
// Console credentials require its provider overlay; only legacy Zen `sk-` keys may use the models.dev fallback.
|
|
||||||
remoteConfigRequired =
|
|
||||||
connection !== undefined &&
|
|
||||||
(credential === undefined || credential.type === "oauth" || credential.key.startsWith("oc_sk_"))
|
|
||||||
providers = credential
|
providers = credential
|
||||||
? yield* fetchProviders(http, credential).pipe(
|
? yield* fetchProviders(http, credential).pipe(
|
||||||
Effect.catch((cause) =>
|
Effect.catch((cause) =>
|
||||||
@@ -126,11 +110,11 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | I
|
|||||||
draft.update("opencode", (integration) => {
|
draft.update("opencode", (integration) => {
|
||||||
integration.name = "OpenCode"
|
integration.name = "OpenCode"
|
||||||
})
|
})
|
||||||
draft.method.update(registration)
|
draft.method.update(oauth(http))
|
||||||
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
|
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* load()
|
connected = (yield* ctx.integration.connection.active("opencode")) !== undefined
|
||||||
yield* ctx.catalog.transform((catalog) => {
|
yield* ctx.catalog.transform((catalog) => {
|
||||||
for (const [providerID, item] of Object.entries(providers ?? {})) {
|
for (const [providerID, item] of Object.entries(providers ?? {})) {
|
||||||
catalog.provider.update(providerID, (provider) => {
|
catalog.provider.update(providerID, (provider) => {
|
||||||
@@ -191,12 +175,6 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | I
|
|||||||
|
|
||||||
const item = catalog.provider.get(ProviderV2.ID.opencode)
|
const item = catalog.provider.get(ProviderV2.ID.opencode)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
if (remoteConfigRequired && providers?.[ProviderV2.ID.opencode] === undefined) {
|
|
||||||
catalog.provider.update(item.provider.id, (provider) => {
|
|
||||||
provider.disabled = true
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
|
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
|
||||||
catalog.provider.update(item.provider.id, (provider) => {
|
catalog.provider.update(item.provider.id, (provider) => {
|
||||||
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" }
|
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" }
|
||||||
@@ -216,6 +194,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | I
|
|||||||
Stream.runForEach(refresh),
|
Stream.runForEach(refresh),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
|
yield* refresh()
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -210,11 +210,12 @@ export interface Interface {
|
|||||||
*/
|
*/
|
||||||
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
||||||
/**
|
/**
|
||||||
* Durable, ordered session log read. Replays durable session events after
|
* Durable, ordered session log read. Replays public durable
|
||||||
* the exclusive `after` cursor, emits a `Synced` marker at the captured
|
* session events after the exclusive `after` cursor, emits a `Synced`
|
||||||
* replay watermark, then continues live when `follow` is set.
|
* marker at the captured replay watermark, then continues live when `follow`
|
||||||
* The marker's seq may exceed the last emitted event because other durable
|
* is set.
|
||||||
* events share the aggregate's sequence space.
|
* The marker's seq may exceed the last emitted event because non-public
|
||||||
|
* durable events share the aggregate's sequence space.
|
||||||
*/
|
*/
|
||||||
readonly log: (input: {
|
readonly log: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import { SessionRunnerModel } from "./runner/model"
|
|||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { toSessionError } from "./to-session-error"
|
import { toSessionError } from "./to-session-error"
|
||||||
import { Token } from "../util/token"
|
import { Token } from "../util/token"
|
||||||
import type { ModelV2 } from "../model"
|
|
||||||
import { SessionUsage } from "./usage"
|
|
||||||
|
|
||||||
const DEFAULT_BUFFER = 20_000
|
const DEFAULT_BUFFER = 20_000
|
||||||
const DEFAULT_KEEP_TOKENS = 8_000
|
const DEFAULT_KEEP_TOKENS = 8_000
|
||||||
@@ -72,7 +70,6 @@ export type AutoInput = {
|
|||||||
readonly session: SessionSchema.Info
|
readonly session: SessionSchema.Info
|
||||||
readonly messages: readonly SessionMessage.Info[]
|
readonly messages: readonly SessionMessage.Info[]
|
||||||
readonly model: Model
|
readonly model: Model
|
||||||
readonly cost: ModelV2.Info["cost"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ManualInput = {
|
export type ManualInput = {
|
||||||
@@ -84,7 +81,6 @@ export type ManualInput = {
|
|||||||
type Plan = {
|
type Plan = {
|
||||||
readonly session: SessionSchema.Info
|
readonly session: SessionSchema.Info
|
||||||
readonly model: Model
|
readonly model: Model
|
||||||
readonly cost: ModelV2.Info["cost"]
|
|
||||||
readonly reason: SessionMessage.Compaction["reason"]
|
readonly reason: SessionMessage.Compaction["reason"]
|
||||||
readonly prompt: string
|
readonly prompt: string
|
||||||
readonly recent: string
|
readonly recent: string
|
||||||
@@ -244,16 +240,6 @@ const make = (dependencies: Dependencies) => {
|
|||||||
|
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
let failure: SessionError.Error | undefined
|
let failure: SessionError.Error | undefined
|
||||||
let usage: SessionUsage.Recorded | undefined
|
|
||||||
const recordUsage = Effect.suspend(() =>
|
|
||||||
usage
|
|
||||||
? dependencies.events.publish(SessionEvent.UsageRecorded, {
|
|
||||||
sessionID: plan.session.id,
|
|
||||||
source: "compaction",
|
|
||||||
...usage,
|
|
||||||
})
|
|
||||||
: Effect.void,
|
|
||||||
)
|
|
||||||
yield* dependencies.llm
|
yield* dependencies.llm
|
||||||
.stream(
|
.stream(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
@@ -277,10 +263,6 @@ const make = (dependencies: Dependencies) => {
|
|||||||
text: event.text,
|
text: event.text,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (LLMEvent.is.stepFinish(event)) {
|
|
||||||
const step = SessionUsage.record(event.usage, plan.cost)
|
|
||||||
usage = usage ? SessionUsage.add(usage, step) : step
|
|
||||||
}
|
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
Effect.catchTag("LLM.Error", (error) =>
|
Effect.catchTag("LLM.Error", (error) =>
|
||||||
@@ -289,21 +271,16 @@ const make = (dependencies: Dependencies) => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Effect.onInterrupt(() =>
|
Effect.onInterrupt(() =>
|
||||||
recordUsage.pipe(
|
plan.reason === "auto"
|
||||||
Effect.andThen(
|
? failed({
|
||||||
plan.reason === "auto"
|
sessionID: plan.session.id,
|
||||||
? failed({
|
reason: plan.reason,
|
||||||
sessionID: plan.session.id,
|
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||||
reason: plan.reason,
|
inputID: plan.inputID,
|
||||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
}).pipe(Effect.asVoid)
|
||||||
inputID: plan.inputID,
|
: Effect.void,
|
||||||
}).pipe(Effect.asVoid)
|
|
||||||
: Effect.void,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
yield* recordUsage
|
|
||||||
const summary = chunks.join("")
|
const summary = chunks.join("")
|
||||||
if (failure || !summary.trim()) {
|
if (failure || !summary.trim()) {
|
||||||
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
||||||
@@ -328,7 +305,6 @@ const make = (dependencies: Dependencies) => {
|
|||||||
return yield* execute({
|
return yield* execute({
|
||||||
session: input.session,
|
session: input.session,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
cost: input.cost,
|
|
||||||
reason: "auto",
|
reason: "auto",
|
||||||
...content,
|
...content,
|
||||||
})
|
})
|
||||||
@@ -377,7 +353,6 @@ const make = (dependencies: Dependencies) => {
|
|||||||
return yield* execute({
|
return yield* execute({
|
||||||
session: input.session,
|
session: input.session,
|
||||||
model: resolved.model,
|
model: resolved.model,
|
||||||
cost: resolved.cost,
|
|
||||||
reason: "manual",
|
reason: "manual",
|
||||||
inputID: input.inputID,
|
inputID: input.inputID,
|
||||||
...content,
|
...content,
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
yield* SessionEvent.All.match(event, {
|
yield* SessionEvent.All.match(event, {
|
||||||
"session.usage.updated": () => Effect.void,
|
"session.usage.updated": () => Effect.void,
|
||||||
"session.usage.recorded": () => Effect.void,
|
|
||||||
"session.agent.selected": (event) => {
|
"session.agent.selected": (event) => {
|
||||||
return adapter.appendMessage(
|
return adapter.appendMessage(
|
||||||
SessionMessage.AgentSelected.make({
|
SessionMessage.AgentSelected.make({
|
||||||
|
|||||||
@@ -609,7 +609,6 @@ const layer = Layer.effectDiscard(
|
|||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
|
|
||||||
yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event))
|
yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event))
|
||||||
yield* events.project(SessionEvent.InputPromoted, (event) =>
|
yield* events.project(SessionEvent.InputPromoted, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -782,7 +781,7 @@ const layer = Layer.effectDiscard(
|
|||||||
yield* InstructionState.reset(db, event.data.sessionID)
|
yield* InstructionState.reset(db, event.data.sessionID)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed, SessionEvent.UsageRecorded]).pipe(
|
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe(
|
||||||
Stream.runForEach((event) => {
|
Stream.runForEach((event) => {
|
||||||
if (
|
if (
|
||||||
event.type === SessionEvent.Step.Failed.type &&
|
event.type === SessionEvent.Step.Failed.type &&
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ export * as SessionRunnerLLM from "./llm"
|
|||||||
|
|
||||||
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
|
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||||
import { Database } from "../../database/database"
|
import { Database } from "../../database/database"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
|
import { ModelV2 } from "../../model"
|
||||||
import { PermissionV2 } from "../../permission"
|
import { PermissionV2 } from "../../permission"
|
||||||
import { QuestionTool } from "../../tool/question"
|
import { QuestionTool } from "../../tool/question"
|
||||||
import { ToolOutputStore } from "../../tool-output-store"
|
import { ToolOutputStore } from "../../tool-output-store"
|
||||||
@@ -26,7 +28,30 @@ import { llmClient } from "../../effect/app-node-platform"
|
|||||||
import { StepFailedError } from "../error"
|
import { StepFailedError } from "../error"
|
||||||
import { toSessionError } from "../to-session-error"
|
import { toSessionError } from "../to-session-error"
|
||||||
import { SessionRunnerRetry } from "./retry"
|
import { SessionRunnerRetry } from "./retry"
|
||||||
import { SessionUsage } from "../usage"
|
|
||||||
|
type StepTokens = {
|
||||||
|
readonly input: number
|
||||||
|
readonly output: number
|
||||||
|
readonly reasoning: number
|
||||||
|
readonly cache: { readonly read: number; readonly write: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
|
||||||
|
export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) {
|
||||||
|
const context = tokens.input + tokens.cache.read + tokens.cache.write
|
||||||
|
const tier = costs
|
||||||
|
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
|
||||||
|
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
|
||||||
|
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
|
||||||
|
if (!cost) return Money.USD.zero
|
||||||
|
return Money.USD.make(
|
||||||
|
(tokens.input * cost.input +
|
||||||
|
(tokens.output + tokens.reasoning) * cost.output +
|
||||||
|
tokens.cache.read * cost.cache.read +
|
||||||
|
tokens.cache.write * cost.cache.write) /
|
||||||
|
1_000_000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
@@ -102,7 +127,7 @@ const layer = Layer.effect(
|
|||||||
const agent = loaded.agent
|
const agent = loaded.agent
|
||||||
const resolved = loaded.model
|
const resolved = loaded.model
|
||||||
const model = resolved.model
|
const model = resolved.model
|
||||||
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
|
const compactionInput = { session, messages: loaded.messages, model }
|
||||||
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
|
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
|
||||||
const compacted = yield* compaction.compact(compactionInput)
|
const compacted = yield* compaction.compact(compactionInput)
|
||||||
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
||||||
@@ -191,7 +216,7 @@ const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
|
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
|
||||||
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
|
cost: calculateCost(resolved.cost, settlement.tokens),
|
||||||
tokens: settlement.tokens,
|
tokens: settlement.tokens,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -233,8 +258,7 @@ const layer = Layer.effect(
|
|||||||
recoverOverflow &&
|
recoverOverflow &&
|
||||||
!publisher.hasRetryEvidence() &&
|
!publisher.hasRetryEvidence() &&
|
||||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||||
(yield* restore(recoverOverflow({ session, messages: loaded.messages, model, cost: resolved.cost })))
|
(yield* restore(recoverOverflow({ session, messages: loaded.messages, model }))).status === "completed"
|
||||||
.status === "completed"
|
|
||||||
)
|
)
|
||||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
|
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/ai"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
@@ -9,7 +9,6 @@ import { SessionError } from "@opencode-ai/schema/session-error"
|
|||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { AgentV2 } from "../../agent"
|
import { AgentV2 } from "../../agent"
|
||||||
import { Snapshot } from "../../snapshot"
|
import { Snapshot } from "../../snapshot"
|
||||||
import { SessionUsage } from "../usage"
|
|
||||||
|
|
||||||
type Input = {
|
type Input = {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
@@ -20,6 +19,20 @@ type Input = {
|
|||||||
readonly assistantMessageID?: SessionMessage.ID
|
readonly assistantMessageID?: SessionMessage.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
|
||||||
|
|
||||||
|
const tokens = (usage: Usage | undefined) => {
|
||||||
|
const reasoning = safe(usage?.reasoningTokens)
|
||||||
|
const read = safe(usage?.cacheReadInputTokens)
|
||||||
|
const write = safe(usage?.cacheWriteInputTokens)
|
||||||
|
return {
|
||||||
|
input: safe(usage?.nonCachedInputTokens),
|
||||||
|
output: safe(usage?.visibleOutputTokens),
|
||||||
|
reasoning,
|
||||||
|
cache: { read, write },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const record = (value: unknown): Record<string, unknown> =>
|
const record = (value: unknown): Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
|
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
|
||||||
|
|
||||||
@@ -64,7 +77,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
let stepSettlement:
|
let stepSettlement:
|
||||||
| {
|
| {
|
||||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
|
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
|
||||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
readonly tokens: ReturnType<typeof tokens>
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
@@ -238,7 +251,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
|
|
||||||
const publishStepFailure = Effect.fnUntraced(function* (usage?: {
|
const publishStepFailure = Effect.fnUntraced(function* (usage?: {
|
||||||
readonly cost: Money.USD
|
readonly cost: Money.USD
|
||||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
readonly tokens: ReturnType<typeof tokens>
|
||||||
}) {
|
}) {
|
||||||
if (stepFailed || stepFailure === undefined) return
|
if (stepFailed || stepFailure === undefined) return
|
||||||
const assistantMessageID = yield* startAssistant()
|
const assistantMessageID = yield* startAssistant()
|
||||||
@@ -416,7 +429,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
case "step-finish":
|
case "step-finish":
|
||||||
yield* flush()
|
yield* flush()
|
||||||
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
||||||
stepSettlement = { finish: event.reason, tokens: SessionUsage.tokens(event.usage) }
|
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
|
||||||
if (event.reason === "content-filter") {
|
if (event.reason === "content-filter") {
|
||||||
providerFailed = true
|
providerFailed = true
|
||||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)
|
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export * as SessionTitle from "./title"
|
export * as SessionTitle from "./title"
|
||||||
|
|
||||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||||
import { Context, Effect, Layer, Stream } from "effect"
|
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
@@ -12,7 +12,6 @@ import { SessionHistory } from "./history"
|
|||||||
import { SessionModelHeaders } from "./model-headers"
|
import { SessionModelHeaders } from "./model-headers"
|
||||||
import { SessionRunnerModel } from "./runner/model"
|
import { SessionRunnerModel } from "./runner/model"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { SessionUsage } from "./usage"
|
|
||||||
|
|
||||||
const MAX_LENGTH = 100
|
const MAX_LENGTH = 100
|
||||||
|
|
||||||
@@ -52,16 +51,6 @@ const make = (dependencies: Dependencies) => {
|
|||||||
if (!resolved) return
|
if (!resolved) return
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
let failed = false
|
let failed = false
|
||||||
let usage: SessionUsage.Recorded | undefined
|
|
||||||
const recordUsage = Effect.suspend(() =>
|
|
||||||
usage
|
|
||||||
? dependencies.events.publish(SessionEvent.UsageRecorded, {
|
|
||||||
sessionID: session.id,
|
|
||||||
source: "title",
|
|
||||||
...usage,
|
|
||||||
})
|
|
||||||
: Effect.void,
|
|
||||||
)
|
|
||||||
const streamed = yield* dependencies.llm
|
const streamed = yield* dependencies.llm
|
||||||
.stream(
|
.stream(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
@@ -76,17 +65,11 @@ const make = (dependencies: Dependencies) => {
|
|||||||
Stream.runForEach((event) => {
|
Stream.runForEach((event) => {
|
||||||
if (LLMEvent.is.providerError(event)) failed = true
|
if (LLMEvent.is.providerError(event)) failed = true
|
||||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||||
if (LLMEvent.is.stepFinish(event)) {
|
|
||||||
const step = SessionUsage.record(event.usage, resolved.cost)
|
|
||||||
usage = usage ? SessionUsage.add(usage, step) : step
|
|
||||||
}
|
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
Effect.as(true),
|
Effect.as(true),
|
||||||
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
|
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
|
||||||
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
|
|
||||||
)
|
)
|
||||||
yield* recordUsage
|
|
||||||
if (!streamed || failed) return
|
if (!streamed || failed) return
|
||||||
const title = chunks
|
const title = chunks
|
||||||
.join("")
|
.join("")
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
export * as SessionUsage from "./usage"
|
|
||||||
|
|
||||||
import type { Usage } from "@opencode-ai/ai"
|
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
|
||||||
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
|
|
||||||
import type { ModelV2 } from "../model"
|
|
||||||
|
|
||||||
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
|
|
||||||
|
|
||||||
export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
|
|
||||||
input: safe(usage?.nonCachedInputTokens),
|
|
||||||
output: safe(usage?.visibleOutputTokens),
|
|
||||||
reasoning: safe(usage?.reasoningTokens),
|
|
||||||
cache: {
|
|
||||||
read: safe(usage?.cacheReadInputTokens),
|
|
||||||
write: safe(usage?.cacheWriteInputTokens),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
|
|
||||||
export function calculateCost(costs: ModelV2.Info["cost"], usage: TokenUsage.Info) {
|
|
||||||
const context = usage.input + usage.cache.read + usage.cache.write
|
|
||||||
const tier = costs
|
|
||||||
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
|
|
||||||
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
|
|
||||||
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
|
|
||||||
if (!cost) return Money.USD.zero
|
|
||||||
return Money.USD.make(
|
|
||||||
(usage.input * cost.input +
|
|
||||||
(usage.output + usage.reasoning) * cost.output +
|
|
||||||
usage.cache.read * cost.cache.read +
|
|
||||||
usage.cache.write * cost.cache.write) /
|
|
||||||
1_000_000,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Recorded = { readonly tokens: TokenUsage.Info; readonly cost: Money.USD }
|
|
||||||
|
|
||||||
export const record = (usage: Usage | undefined, costs: ModelV2.Info["cost"]): Recorded => {
|
|
||||||
const normalized = tokens(usage)
|
|
||||||
return { tokens: normalized, cost: calculateCost(costs, normalized) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const add = (a: Recorded, b: Recorded): Recorded => ({
|
|
||||||
cost: Money.USD.make(a.cost + b.cost),
|
|
||||||
tokens: {
|
|
||||||
input: a.tokens.input + b.tokens.input,
|
|
||||||
output: a.tokens.output + b.tokens.output,
|
|
||||||
reasoning: a.tokens.reasoning + b.tokens.reasoning,
|
|
||||||
cache: {
|
|
||||||
read: a.tokens.cache.read + b.tokens.cache.read,
|
|
||||||
write: a.tokens.cache.write + b.tokens.cache.write,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -17,6 +17,7 @@ export const name = "shell"
|
|||||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||||
|
export const PROGRESS_LINES = 25
|
||||||
|
|
||||||
const BACKGROUND_STARTED = "The command was moved to the background."
|
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||||
const BACKGROUND_INSTRUCTION =
|
const BACKGROUND_INSTRUCTION =
|
||||||
@@ -210,6 +211,23 @@ export const Plugin = {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const captureProgress = Effect.fn("ShellTool.captureProgress")(function* () {
|
||||||
|
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||||
|
const start = Math.max(0, latest.size - MAX_CAPTURE_BYTES)
|
||||||
|
const page = yield* shell.output(info.id, { cursor: start, limit: MAX_CAPTURE_BYTES })
|
||||||
|
const trailingNewline = page.output.endsWith("\n")
|
||||||
|
const lines = trailingNewline ? page.output.split("\n").slice(0, -1) : page.output.split("\n")
|
||||||
|
const truncated = start > 0 || lines.length > PROGRESS_LINES
|
||||||
|
const output = lines.slice(-PROGRESS_LINES).join("\n") + (trailingNewline ? "\n" : "")
|
||||||
|
const notice = truncated
|
||||||
|
? `[output truncated; showing last ${PROGRESS_LINES} lines. Full output saved to: ${info.file}]\n\n`
|
||||||
|
: ""
|
||||||
|
return {
|
||||||
|
output: `${notice}${output || "(no output)"}`,
|
||||||
|
truncated,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||||
const final = yield* shell.wait(info.id)
|
const final = yield* shell.wait(info.id)
|
||||||
|
|
||||||
@@ -258,7 +276,7 @@ export const Plugin = {
|
|||||||
|
|
||||||
const progress = yield* Effect.sleep("1 second").pipe(
|
const progress = yield* Effect.sleep("1 second").pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
captureShell().pipe(
|
captureProgress().pipe(
|
||||||
Effect.flatMap((capture) =>
|
Effect.flatMap((capture) =>
|
||||||
context.progress({
|
context.progress({
|
||||||
structured: { truncated: capture.truncated },
|
structured: { truncated: capture.truncated },
|
||||||
|
|||||||
@@ -754,6 +754,7 @@ describe("Config", () => {
|
|||||||
})
|
})
|
||||||
expect(documents[0]?.info.compaction).toEqual({
|
expect(documents[0]?.info.compaction).toEqual({
|
||||||
auto: true,
|
auto: true,
|
||||||
|
prune: false,
|
||||||
keep: { tokens: 2000 },
|
keep: { tokens: 2000 },
|
||||||
buffer: 10000,
|
buffer: 10000,
|
||||||
})
|
})
|
||||||
@@ -967,6 +968,7 @@ describe("Config", () => {
|
|||||||
})
|
})
|
||||||
expect(documents[0]?.info.compaction).toEqual({
|
expect(documents[0]?.info.compaction).toEqual({
|
||||||
auto: true,
|
auto: true,
|
||||||
|
prune: undefined,
|
||||||
keep: { tokens: 2000 },
|
keep: { tokens: 2000 },
|
||||||
buffer: 10000,
|
buffer: 10000,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -188,7 +188,6 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe
|
|||||||
connection: {
|
connection: {
|
||||||
active: unusedIntegration,
|
active: unusedIntegration,
|
||||||
resolve: unusedIntegration,
|
resolve: unusedIntegration,
|
||||||
resolveWithRefresh: unusedIntegration,
|
|
||||||
key: unusedIntegration,
|
key: unusedIntegration,
|
||||||
update: unusedIntegration,
|
update: unusedIntegration,
|
||||||
remove: unusedIntegration,
|
remove: unusedIntegration,
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ function required<T>(value: T | undefined): T {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function eventually<A>(
|
||||||
|
effect: Effect.Effect<A>,
|
||||||
|
predicate: (value: A) => boolean,
|
||||||
|
remaining = 1000,
|
||||||
|
): Effect.Effect<A, Error> {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const value = yield* effect
|
||||||
|
if (predicate(value)) return value
|
||||||
|
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||||
|
yield* Effect.promise(() => Bun.sleep(1))
|
||||||
|
return yield* eventually(effect, predicate, remaining - 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function fakeSelectorSdk(calls: string[]) {
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
const make = (method: string) => (id: string) => {
|
const make = (method: string) => (id: string) => {
|
||||||
calls.push(`${method}:${id}`)
|
calls.push(`${method}:${id}`)
|
||||||
@@ -205,8 +219,12 @@ describe("OpenAIPlugin", () => {
|
|||||||
})
|
})
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
|
||||||
const eligible = required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")))
|
const eligible = required(
|
||||||
expect(eligible.cost).toEqual([])
|
yield* eventually(
|
||||||
|
catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")),
|
||||||
|
(model) => model?.cost.length === 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
expect(eligible.enabled).toBe(true)
|
expect(eligible.enabled).toBe(true)
|
||||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||||
false,
|
false,
|
||||||
@@ -242,6 +260,9 @@ describe("OpenAIPlugin", () => {
|
|||||||
value: Credential.Key.make({ type: "key", key: "sk-test" }),
|
value: Credential.Key.make({ type: "key", key: "sk-test" }),
|
||||||
})
|
})
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
// The connection refresh is asynchronous; give it time to settle before
|
||||||
|
// asserting nothing was filtered.
|
||||||
|
yield* Effect.promise(() => Bun.sleep(25))
|
||||||
|
|
||||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))).enabled).toBe(true)
|
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))).enabled).toBe(true)
|
||||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true)
|
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
|
|||||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { State } from "@opencode-ai/core/state"
|
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { PluginTestLayer } from "./fixture"
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
@@ -21,11 +20,9 @@ const addPlugin = Effect.fn(function* () {
|
|||||||
const host = yield* PluginHost.make(plugin)
|
const host = yield* PluginHost.make(plugin)
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const integration = yield* Integration.Service
|
const integration = yield* Integration.Service
|
||||||
yield* State.batch(
|
yield* OpencodePlugin.effect(host).pipe(
|
||||||
OpencodePlugin.effect(host).pipe(
|
Effect.provideService(EventV2.Service, events),
|
||||||
Effect.provideService(EventV2.Service, events),
|
Effect.provideService(Integration.Service, integration),
|
||||||
Effect.provideService(Integration.Service, integration),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,50 +77,6 @@ const cost = (input: number, output = 0) => [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const fableID = ModelV2.ID.make("claude-fable-5")
|
|
||||||
|
|
||||||
const seedFallback = Effect.fnUntraced(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
const provider = ProviderV2.Info.make({
|
|
||||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
|
||||||
integrationID: Integration.ID.make("opencode"),
|
|
||||||
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
|
|
||||||
settings: { baseURL: "https://opencode.ai/zen/v1" },
|
|
||||||
})
|
|
||||||
const model = ModelV2.Info.make({
|
|
||||||
...ModelV2.Info.empty(provider.id, fableID),
|
|
||||||
modelID: fableID,
|
|
||||||
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
|
|
||||||
cost: cost(1, 1),
|
|
||||||
})
|
|
||||||
yield* catalog.transform((draft) => {
|
|
||||||
draft.provider.update(provider.id, (current) => Object.assign(current, provider))
|
|
||||||
draft.model.update(provider.id, model.id, (current) => Object.assign(current, model))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const remoteConfig = (origin: string) => ({
|
|
||||||
config: {
|
|
||||||
enterprise: { url: origin },
|
|
||||||
provider: {
|
|
||||||
opencode: {
|
|
||||||
name: "OpenCode",
|
|
||||||
npm: "@ai-sdk/openai-compatible",
|
|
||||||
api: `${origin}/inference/openai/v1`,
|
|
||||||
env: ["OPENCODE_API_KEY"],
|
|
||||||
options: { apiKey: "{env:OPENCODE_API_KEY}" },
|
|
||||||
models: {
|
|
||||||
"claude-fable-5": {
|
|
||||||
name: "Claude Fable 5",
|
|
||||||
provider: { npm: "@ai-sdk/anthropic", api: `${origin}/inference/anthropic/v1` },
|
|
||||||
cost: { input: 1, output: 1 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("OpencodePlugin", () => {
|
describe("OpencodePlugin", () => {
|
||||||
it.effect("registers account and service account methods", () =>
|
it.effect("registers account and service account methods", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -330,176 +283,6 @@ describe("OpencodePlugin", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("refreshes expired OAuth before loading the Console catalog", () =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.sync(() => {
|
|
||||||
const requests: Array<{ path: string; authorization: string | null }> = []
|
|
||||||
const server = Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch: (request) => {
|
|
||||||
const path = new URL(request.url).pathname
|
|
||||||
requests.push({ path, authorization: request.headers.get("authorization") })
|
|
||||||
if (path === "/auth/device/token")
|
|
||||||
return Response.json({
|
|
||||||
access_token: "fresh-access",
|
|
||||||
refresh_token: "fresh-refresh",
|
|
||||||
expires_in: 3600,
|
|
||||||
})
|
|
||||||
if (path === "/api/config") {
|
|
||||||
if (request.headers.get("authorization") !== "Bearer fresh-access")
|
|
||||||
return new Response("Unauthorized", { status: 401 })
|
|
||||||
return Response.json(remoteConfig(new URL(request.url).origin))
|
|
||||||
}
|
|
||||||
return new Response("Not found", { status: 404 })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return { requests, server }
|
|
||||||
}),
|
|
||||||
({ requests, server }) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const credentials = yield* Credential.Service
|
|
||||||
yield* seedFallback()
|
|
||||||
yield* credentials.create({
|
|
||||||
integrationID: Integration.ID.make("opencode"),
|
|
||||||
value: Credential.OAuth.make({
|
|
||||||
type: "oauth",
|
|
||||||
methodID: Integration.MethodID.make("device"),
|
|
||||||
access: "expired-access",
|
|
||||||
refresh: "expired-refresh",
|
|
||||||
expires: 1,
|
|
||||||
metadata: { server: server.url.origin },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* addPlugin()
|
|
||||||
|
|
||||||
expect(requests).toEqual([
|
|
||||||
{ path: "/auth/device/token", authorization: null },
|
|
||||||
{ path: "/api/config", authorization: "Bearer fresh-access" },
|
|
||||||
])
|
|
||||||
expect((yield* credentials.list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
|
|
||||||
type: "oauth",
|
|
||||||
access: "fresh-access",
|
|
||||||
refresh: "fresh-refresh",
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
required(yield* (yield* Catalog.Service).model.get(ProviderV2.ID.opencode, fableID)).settings?.baseURL,
|
|
||||||
).toBe(`${server.url.origin}/inference/anthropic/v1`)
|
|
||||||
}),
|
|
||||||
({ server }) => Effect.promise(() => server.stop(true)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("does not expose the legacy fallback when Console OAuth config is unavailable", () =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.sync(() =>
|
|
||||||
Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch: () => new Response("Unavailable", { status: 503 }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
(server) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const credentials = yield* Credential.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
yield* seedFallback()
|
|
||||||
yield* credentials.create({
|
|
||||||
integrationID: Integration.ID.make("opencode"),
|
|
||||||
value: Credential.OAuth.make({
|
|
||||||
type: "oauth",
|
|
||||||
methodID: Integration.MethodID.make("device"),
|
|
||||||
access: "access",
|
|
||||||
refresh: "refresh",
|
|
||||||
expires: Date.now() + 60 * 60 * 1000,
|
|
||||||
metadata: { server: server.url.origin },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* addPlugin()
|
|
||||||
|
|
||||||
expect((yield* catalog.provider.available()).some((provider) => provider.id === ProviderV2.ID.opencode)).toBe(
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
(yield* catalog.model.available()).some(
|
|
||||||
(model) => model.providerID === ProviderV2.ID.opencode && model.id === fableID,
|
|
||||||
),
|
|
||||||
).toBe(false)
|
|
||||||
}),
|
|
||||||
(server) => Effect.promise(() => server.stop(true)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("does not expose the legacy fallback when Console service-account config is unavailable", () =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.sync(() =>
|
|
||||||
Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch: () => new Response("Unavailable", { status: 503 }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
(server) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const credentials = yield* Credential.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
yield* seedFallback()
|
|
||||||
yield* credentials.create({
|
|
||||||
integrationID: Integration.ID.make("opencode"),
|
|
||||||
value: Credential.Key.make({
|
|
||||||
type: "key",
|
|
||||||
key: "oc_sk_0123456789ab_secret",
|
|
||||||
metadata: { server: server.url.origin },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* addPlugin()
|
|
||||||
|
|
||||||
expect((yield* catalog.provider.available()).some((provider) => provider.id === ProviderV2.ID.opencode)).toBe(
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
(yield* catalog.model.available()).some(
|
|
||||||
(model) => model.providerID === ProviderV2.ID.opencode && model.id === fableID,
|
|
||||||
),
|
|
||||||
).toBe(false)
|
|
||||||
}),
|
|
||||||
(server) => Effect.promise(() => server.stop(true)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("keeps the legacy fallback for an API key when remote provider config is unavailable", () =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.sync(() =>
|
|
||||||
Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch: () => new Response("Unauthorized", { status: 401 }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
(server) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const credentials = yield* Credential.Service
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
yield* seedFallback()
|
|
||||||
yield* credentials.create({
|
|
||||||
integrationID: Integration.ID.make("opencode"),
|
|
||||||
value: Credential.Key.make({
|
|
||||||
type: "key",
|
|
||||||
key: "sk-legacy-key",
|
|
||||||
metadata: { server: server.url.origin },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* addPlugin()
|
|
||||||
|
|
||||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).disabled).toBeUndefined()
|
|
||||||
expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, fableID)).settings?.baseURL).toBe(
|
|
||||||
"https://opencode.ai/zen/v1",
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
(server) => Effect.promise(() => server.stop(true)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("uses a public key and disables paid models without credentials", () =>
|
it.effect("uses a public key and disables paid models without credentials", () =>
|
||||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
|
||||||
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
|
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
|
||||||
import { asc, eq } from "drizzle-orm"
|
import { asc, eq } from "drizzle-orm"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
@@ -32,44 +31,17 @@ const model = Model.make({
|
|||||||
provider: "test",
|
provider: "test",
|
||||||
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
|
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
|
||||||
})
|
})
|
||||||
const cost = [
|
|
||||||
{
|
|
||||||
input: Money.USDPerMillionTokens.make(1),
|
|
||||||
output: Money.USDPerMillionTokens.make(2),
|
|
||||||
cache: {
|
|
||||||
read: Money.USDPerMillionTokens.make(0.1),
|
|
||||||
write: Money.USDPerMillionTokens.make(0.5),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const client = Layer.mock(LLMClient.Service)({
|
const client = Layer.mock(LLMClient.Service)({
|
||||||
prepare: () => Effect.die("unused"),
|
prepare: () => Effect.die("unused"),
|
||||||
stream: (request: LLMRequest) => {
|
stream: (request: LLMRequest) => {
|
||||||
requests.push(request)
|
requests.push(request)
|
||||||
return Stream.make(
|
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual summary" }))
|
||||||
LLMEvent.textDelta({ id: "summary", text: "manual summary" }),
|
|
||||||
LLMEvent.stepFinish({
|
|
||||||
index: 0,
|
|
||||||
reason: "stop",
|
|
||||||
usage: {
|
|
||||||
inputTokens: 15,
|
|
||||||
outputTokens: 6,
|
|
||||||
nonCachedInputTokens: 10,
|
|
||||||
cacheReadInputTokens: 3,
|
|
||||||
cacheWriteInputTokens: 2,
|
|
||||||
reasoningTokens: 2,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
LLMEvent.finish({
|
|
||||||
reason: "stop",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
generate: () => Effect.die("unused"),
|
generate: () => Effect.die("unused"),
|
||||||
})
|
})
|
||||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||||
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
|
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
|
||||||
})
|
})
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
@@ -197,10 +169,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
|||||||
expect(yield* store.context(sessionID)).toMatchObject([
|
expect(yield* store.context(sessionID)).toMatchObject([
|
||||||
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
|
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
|
||||||
])
|
])
|
||||||
expect(yield* store.get(sessionID)).toMatchObject({
|
|
||||||
cost: 0.0000233,
|
|
||||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } },
|
|
||||||
})
|
|
||||||
expect(
|
expect(
|
||||||
yield* db
|
yield* db
|
||||||
.select({ type: EventTable.type })
|
.select({ type: EventTable.type })
|
||||||
@@ -211,7 +179,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
|||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{ type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) },
|
{ type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) },
|
||||||
{ type: EventV2.versionedType(SessionEvent.UsageRecorded.type, 1) },
|
|
||||||
{ type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1) },
|
{ type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1) },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator
|
|||||||
import { SessionRunner } from "@opencode-ai/core/session/runner"
|
import { SessionRunner } from "@opencode-ai/core/session/runner"
|
||||||
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
||||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||||
import { SessionUsage } from "@opencode-ai/core/session/usage"
|
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||||
@@ -166,7 +165,7 @@ const recoveryModel = Model.make({
|
|||||||
|
|
||||||
test("calculates step cost using the matching context tier", () => {
|
test("calculates step cost using the matching context tier", () => {
|
||||||
expect(
|
expect(
|
||||||
SessionUsage.calculateCost(
|
SessionRunnerLLM.calculateCost(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
input: Money.USDPerMillionTokens.make(1),
|
input: Money.USDPerMillionTokens.make(1),
|
||||||
@@ -193,7 +192,7 @@ test("calculates step cost using the matching context tier", () => {
|
|||||||
|
|
||||||
test("does not apply an ineligible tier without base pricing", () => {
|
test("does not apply an ineligible tier without base pricing", () => {
|
||||||
expect(
|
expect(
|
||||||
SessionUsage.calculateCost(
|
SessionRunnerLLM.calculateCost(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
tier: { type: "context", size: 100 },
|
tier: { type: "context", size: 100 },
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { DateTime, Effect, Layer, Stream } from "effect"
|
||||||
import { Effect, Layer, Stream } from "effect"
|
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
let requests: LLMRequest[] = []
|
let requests: LLMRequest[] = []
|
||||||
@@ -30,43 +29,16 @@ const model = Model.make({
|
|||||||
provider: "test",
|
provider: "test",
|
||||||
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
|
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
|
||||||
})
|
})
|
||||||
const cost = [
|
|
||||||
{
|
|
||||||
input: Money.USDPerMillionTokens.make(1),
|
|
||||||
output: Money.USDPerMillionTokens.make(2),
|
|
||||||
cache: {
|
|
||||||
read: Money.USDPerMillionTokens.make(0.1),
|
|
||||||
write: Money.USDPerMillionTokens.make(0.5),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const client = Layer.mock(LLMClient.Service)({
|
const client = Layer.mock(LLMClient.Service)({
|
||||||
prepare: () => Effect.die("unused"),
|
prepare: () => Effect.die("unused"),
|
||||||
stream: (request: LLMRequest) => {
|
stream: (request: LLMRequest) => {
|
||||||
requests.push(request)
|
requests.push(request)
|
||||||
return Stream.make(
|
return Stream.make(LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }))
|
||||||
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
|
|
||||||
LLMEvent.stepFinish({
|
|
||||||
index: 0,
|
|
||||||
reason: "stop",
|
|
||||||
usage: {
|
|
||||||
inputTokens: 15,
|
|
||||||
outputTokens: 6,
|
|
||||||
nonCachedInputTokens: 10,
|
|
||||||
cacheReadInputTokens: 3,
|
|
||||||
cacheWriteInputTokens: 2,
|
|
||||||
reasoningTokens: 2,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
LLMEvent.finish({
|
|
||||||
reason: "stop",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
generate: () => Effect.die("unused"),
|
generate: () => Effect.die("unused"),
|
||||||
})
|
})
|
||||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||||
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
|
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
|
||||||
})
|
})
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
@@ -158,8 +130,6 @@ it.effect("generates a title from the sole user message and renames the session"
|
|||||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
|
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
|
||||||
const renamed = yield* store.get(sessionID)
|
const renamed = yield* store.get(sessionID)
|
||||||
expect(renamed?.title).toBe("Generated Title")
|
expect(renamed?.title).toBe("Generated Title")
|
||||||
expect(renamed?.tokens).toEqual({ input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } })
|
|
||||||
expect(renamed?.cost).toBeCloseTo(0.0000233)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -166,10 +166,10 @@ const overflowCommand = (bytes: number) =>
|
|||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
||||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
||||||
const progressOverflowCommand = (bytes: number) =>
|
const progressLinesCommand = (lines: number) =>
|
||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500`
|
? `1..${lines} | ForEach-Object { [Console]::Out.WriteLine(('line {0:d2}' -f $_)) }; Start-Sleep -Milliseconds 1500`
|
||||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5`
|
: `i=1; while [ $i -le ${lines} ]; do printf 'line %02d\\n' "$i"; i=$((i+1)); done; sleep 1.5`
|
||||||
|
|
||||||
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
|
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -417,17 +417,17 @@ describe("ShellTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("reports bounded output progress for a running command", () =>
|
it.live("reports the latest output lines for a running command", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
const lines = ShellTool.PROGRESS_LINES + 10
|
||||||
return withSession(tmp.path, (registry) =>
|
return withSession(tmp.path, (registry) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const progress: ToolRegistry.Progress[] = []
|
const progress: ToolRegistry.Progress[] = []
|
||||||
yield* settleTool(registry, {
|
yield* settleTool(registry, {
|
||||||
...call({ command: progressOverflowCommand(bytes) }, "call-progress"),
|
...call({ command: progressLinesCommand(lines) }, "call-progress"),
|
||||||
progress: (update) => Effect.sync(() => progress.push(update)),
|
progress: (update) => Effect.sync(() => progress.push(update)),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -436,9 +436,12 @@ describe("ShellTool", () => {
|
|||||||
const content = progress[0]?.content[0]
|
const content = progress[0]?.content[0]
|
||||||
expect(content?.type).toBe("text")
|
expect(content?.type).toBe("text")
|
||||||
if (content?.type !== "text") return
|
if (content?.type !== "text") return
|
||||||
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
|
expect(content.text).toStartWith(
|
||||||
ShellTool.MAX_CAPTURE_BYTES,
|
`[output truncated; showing last ${ShellTool.PROGRESS_LINES} lines. Full output saved to:`,
|
||||||
)
|
)
|
||||||
|
expect(content.text).not.toContain("line 10\n")
|
||||||
|
expect(content.text).toContain("line 11\n")
|
||||||
|
expect(content.text).toContain(`line ${lines}\n`)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -382,8 +382,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
|
|||||||
function App() {
|
function App() {
|
||||||
const wslServers = useWslServers()
|
const wslServers = useWslServers()
|
||||||
const ready = createMemo(
|
const ready = createMemo(
|
||||||
() =>
|
() => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
|
||||||
!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading && !wslServers.isLoading,
|
|
||||||
)
|
)
|
||||||
const servers = createMemo(() => {
|
const servers = createMemo(() => {
|
||||||
const data = initializationData(sidecar)
|
const data = initializationData(sidecar)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
"azure-cognitive-services": Effect.fnUntraced(function* (provider: Info) {
|
"azure-cognitive-services": Effect.fnUntraced(function* () {
|
||||||
const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
|
const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
|
||||||
return {
|
return {
|
||||||
autoload: false,
|
autoload: false,
|
||||||
@@ -285,9 +285,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
|||||||
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
|
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
baseURL: resourceName
|
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
|
||||||
? `https://${resourceName}.cognitiveservices.azure.com/openai${provider.options?.useDeploymentBasedUrls ? "" : "/v1"}`
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core"
|
import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core"
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender, useRenderer } from "@opentui/solid"
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import { Keymap } from "@opencode-ai/tui/context/keymap"
|
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
||||||
import {
|
import {
|
||||||
RUN_COMMAND_PANEL_ROWS,
|
RUN_COMMAND_PANEL_ROWS,
|
||||||
RUN_SUBAGENT_PANEL_ROWS,
|
RUN_SUBAGENT_PANEL_ROWS,
|
||||||
@@ -173,9 +174,15 @@ async function renderFooter(
|
|||||||
)
|
)
|
||||||
const state = footerState(input.state)
|
const state = footerState(input.state)
|
||||||
const config = input.tuiConfig ?? tuiConfig
|
const config = input.tuiConfig ?? tuiConfig
|
||||||
|
let offKeymap: (() => void) | undefined
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
|
const renderer = useRenderer()
|
||||||
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
|
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Keymap.Provider config={config}>
|
<OpencodeKeymapProvider keymap={keymap}>
|
||||||
<RunFooterView
|
<RunFooterView
|
||||||
directory="/tmp"
|
directory="/tmp"
|
||||||
findFiles={async () => []}
|
findFiles={async () => []}
|
||||||
@@ -208,7 +215,7 @@ async function renderFooter(
|
|||||||
onStatus={() => {}}
|
onStatus={() => {}}
|
||||||
onQueuedRemove={async () => true}
|
onQueuedRemove={async () => true}
|
||||||
/>
|
/>
|
||||||
</Keymap.Provider>
|
</OpencodeKeymapProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +233,8 @@ async function renderFooter(
|
|||||||
cleanup() {
|
cleanup() {
|
||||||
app.renderer.currentFocusedRenderable?.blur()
|
app.renderer.currentFocusedRenderable?.blur()
|
||||||
app.renderer.currentFocusedEditor?.blur()
|
app.renderer.currentFocusedEditor?.blur()
|
||||||
|
offKeymap?.()
|
||||||
|
offKeymap = undefined
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -632,42 +641,6 @@ test("direct subagent panel renders active subagents", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("direct subagent panel closes when moving up from the first item", async () => {
|
|
||||||
const [tabs] = createSignal([
|
|
||||||
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
|
|
||||||
subagent({ sessionID: "s-2", label: "General", description: "Write migration plan" }),
|
|
||||||
])
|
|
||||||
const [current] = createSignal<string | undefined>()
|
|
||||||
let closed = 0
|
|
||||||
|
|
||||||
const app = await testRender(
|
|
||||||
() => (
|
|
||||||
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
|
|
||||||
<RunSubagentSelectBody
|
|
||||||
theme={() => RUN_THEME_FALLBACK.footer}
|
|
||||||
tabs={tabs}
|
|
||||||
current={current}
|
|
||||||
onClose={() => closed++}
|
|
||||||
onSelect={() => {}}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
),
|
|
||||||
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await app.renderOnce()
|
|
||||||
app.mockInput.pressKey("ARROW_DOWN")
|
|
||||||
app.mockInput.pressKey("ARROW_UP")
|
|
||||||
expect(closed).toBe(0)
|
|
||||||
|
|
||||||
app.mockInput.pressKey("ARROW_UP")
|
|
||||||
expect(closed).toBe(1)
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("direct queued prompt panel renders pending prompt actions", async () => {
|
test("direct queued prompt panel renders pending prompt actions", async () => {
|
||||||
const [prompts] = createSignal([
|
const [prompts] = createSignal([
|
||||||
{ messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } },
|
{ messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } },
|
||||||
@@ -994,9 +967,14 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
permissions: [],
|
permissions: [],
|
||||||
questions: [],
|
questions: [],
|
||||||
})
|
})
|
||||||
|
let offKeymap: (() => void) | undefined
|
||||||
function Harness() {
|
function Harness() {
|
||||||
|
const renderer = useRenderer()
|
||||||
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
|
offKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Keymap.Provider config={tuiConfig}>
|
<OpencodeKeymapProvider keymap={keymap}>
|
||||||
<RunFooterView
|
<RunFooterView
|
||||||
directory="/tmp"
|
directory="/tmp"
|
||||||
findFiles={async () => []}
|
findFiles={async () => []}
|
||||||
@@ -1035,7 +1013,7 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
onStatus={() => {}}
|
onStatus={() => {}}
|
||||||
onQueuedRemove={async () => true}
|
onQueuedRemove={async () => true}
|
||||||
/>
|
/>
|
||||||
</Keymap.Provider>
|
</OpencodeKeymapProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1071,7 +1049,7 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
expect(frame).toContain("3 queued")
|
expect(frame).toContain("3 queued")
|
||||||
expect(frame).toContain("ctrl+b background")
|
expect(frame).toContain("ctrl+b background")
|
||||||
expect(frame).toContain("ctrl+x q 3 queued")
|
expect(frame).toContain("ctrl+x q 3 queued")
|
||||||
expect(frame).toContain("↓ subagents")
|
expect(frame).toContain("ctrl+x down subagents")
|
||||||
expect(frame).toContain("ctrl+p cmd")
|
expect(frame).toContain("ctrl+p cmd")
|
||||||
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
|
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
|
||||||
expect(frame).toContain("subagents · ctrl+p cmd")
|
expect(frame).toContain("subagents · ctrl+p cmd")
|
||||||
@@ -1085,6 +1063,7 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||||||
} finally {
|
} finally {
|
||||||
app.renderer.currentFocusedRenderable?.blur()
|
app.renderer.currentFocusedRenderable?.blur()
|
||||||
app.renderer.currentFocusedEditor?.blur()
|
app.renderer.currentFocusedEditor?.blur()
|
||||||
|
offKeymap?.()
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1108,7 +1087,7 @@ test("direct footer always offers backgrounding for a foreground subagent", asyn
|
|||||||
const frame = app.captureCharFrame()
|
const frame = app.captureCharFrame()
|
||||||
|
|
||||||
expect(frame).toContain("GPT-5")
|
expect(frame).toContain("GPT-5")
|
||||||
expect(frame).toContain("xhigh · ctrl+b background · ↓ subagents · ctrl+p cmd")
|
expect(frame).toContain("xhigh · ctrl+b background · ctrl+x down subagents · ctrl+p cmd")
|
||||||
expect(frame).toContain("ctrl+b background")
|
expect(frame).toContain("ctrl+b background")
|
||||||
expect(frame).not.toContain("queued")
|
expect(frame).not.toContain("queued")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1136,7 +1115,7 @@ test("direct footer hides the subagent hint when only completed subagents remain
|
|||||||
|
|
||||||
expect(frame).toContain("GPT-5")
|
expect(frame).toContain("GPT-5")
|
||||||
expect(frame).toContain("xhigh · ctrl+p cmd")
|
expect(frame).toContain("xhigh · ctrl+p cmd")
|
||||||
expect(frame).not.toContain("↓ subagents")
|
expect(frame).not.toContain("ctrl+x down subagents")
|
||||||
} finally {
|
} finally {
|
||||||
app.cleanup()
|
app.cleanup()
|
||||||
}
|
}
|
||||||
@@ -1254,9 +1233,15 @@ test.skip("direct custom answer submits through keymap return binding", async ()
|
|||||||
],
|
],
|
||||||
} satisfies QuestionRequest
|
} satisfies QuestionRequest
|
||||||
const questions: unknown[] = []
|
const questions: unknown[] = []
|
||||||
|
let off: (() => void) | undefined
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
|
const renderer = useRenderer()
|
||||||
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
|
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Keymap.Provider config={tuiConfig}>
|
<OpencodeKeymapProvider keymap={keymap}>
|
||||||
<RunQuestionBody
|
<RunQuestionBody
|
||||||
request={question}
|
request={question}
|
||||||
theme={RUN_THEME_FALLBACK.footer}
|
theme={RUN_THEME_FALLBACK.footer}
|
||||||
@@ -1265,7 +1250,7 @@ test.skip("direct custom answer submits through keymap return binding", async ()
|
|||||||
}}
|
}}
|
||||||
onReject={() => {}}
|
onReject={() => {}}
|
||||||
/>
|
/>
|
||||||
</Keymap.Provider>
|
</OpencodeKeymapProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1290,6 +1275,7 @@ test.skip("direct custom answer submits through keymap return binding", async ()
|
|||||||
} finally {
|
} finally {
|
||||||
app.renderer.currentFocusedRenderable?.blur()
|
app.renderer.currentFocusedRenderable?.blur()
|
||||||
app.renderer.currentFocusedEditor?.blur()
|
app.renderer.currentFocusedEditor?.blur()
|
||||||
|
off?.()
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1297,9 +1283,15 @@ test.skip("direct custom answer submits through keymap return binding", async ()
|
|||||||
test("direct permission rejection submits through keymap return binding", async () => {
|
test("direct permission rejection submits through keymap return binding", async () => {
|
||||||
let text = ""
|
let text = ""
|
||||||
const submits: string[] = []
|
const submits: string[] = []
|
||||||
|
let off: (() => void) | undefined
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
|
const renderer = useRenderer()
|
||||||
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
|
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Keymap.Provider config={tuiConfig}>
|
<OpencodeKeymapProvider keymap={keymap}>
|
||||||
<RejectField
|
<RejectField
|
||||||
theme={RUN_THEME_FALLBACK.footer}
|
theme={RUN_THEME_FALLBACK.footer}
|
||||||
text=""
|
text=""
|
||||||
@@ -1312,7 +1304,7 @@ test("direct permission rejection submits through keymap return binding", async
|
|||||||
}}
|
}}
|
||||||
onCancel={() => {}}
|
onCancel={() => {}}
|
||||||
/>
|
/>
|
||||||
</Keymap.Provider>
|
</OpencodeKeymapProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,6 +1328,7 @@ test("direct permission rejection submits through keymap return binding", async
|
|||||||
} finally {
|
} finally {
|
||||||
app.renderer.currentFocusedRenderable?.blur()
|
app.renderer.currentFocusedRenderable?.blur()
|
||||||
app.renderer.currentFocusedEditor?.blur()
|
app.renderer.currentFocusedEditor?.blur()
|
||||||
|
off?.()
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type {
|
|||||||
ShellInfo,
|
ShellInfo,
|
||||||
SkillInfo,
|
SkillInfo,
|
||||||
} from "@opencode-ai/client"
|
} from "@opencode-ai/client"
|
||||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
import type { Renderable } from "@opentui/core"
|
||||||
import type { JSX } from "@opentui/solid"
|
import type { JSX } from "@opentui/solid"
|
||||||
|
|
||||||
interface LocationCollection<Value> {
|
interface LocationCollection<Value> {
|
||||||
@@ -139,8 +139,8 @@ export interface KeymapCommand {
|
|||||||
}
|
}
|
||||||
/** Promotes the command in discovery UI. */
|
/** Promotes the command in discovery UI. */
|
||||||
readonly suggested?: boolean | (() => boolean)
|
readonly suggested?: boolean | (() => boolean)
|
||||||
/** Executes the command. Keyboard dispatch includes its event; programmatic dispatch does not. Return false to continue. */
|
/** Executes the command. Return false to let keymap dispatch continue. */
|
||||||
readonly run: (input?: string, event?: KeyEvent) => void | false | Promise<void>
|
readonly run: (input?: string) => void | false | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeymapLayer {
|
export interface KeymapLayer {
|
||||||
|
|||||||
@@ -86,18 +86,6 @@ export const Renamed = Event.durable({
|
|||||||
})
|
})
|
||||||
export type Renamed = typeof Renamed.Type
|
export type Renamed = typeof Renamed.Type
|
||||||
|
|
||||||
export const UsageRecorded = Event.durable({
|
|
||||||
type: "session.usage.recorded",
|
|
||||||
...options,
|
|
||||||
schema: {
|
|
||||||
...Base,
|
|
||||||
source: Schema.Literals(["title", "compaction"]),
|
|
||||||
cost: Money.USD,
|
|
||||||
tokens: TokenUsage.Info,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export type UsageRecorded = typeof UsageRecorded.Type
|
|
||||||
|
|
||||||
export const UsageUpdated = Event.ephemeral({
|
export const UsageUpdated = Event.ephemeral({
|
||||||
type: "session.usage.updated",
|
type: "session.usage.updated",
|
||||||
schema: {
|
schema: {
|
||||||
@@ -581,10 +569,8 @@ export const Definitions = Event.inventory(
|
|||||||
RevertEvent.Committed,
|
RevertEvent.Committed,
|
||||||
)
|
)
|
||||||
|
|
||||||
// UsageRecorded is durable but internal: excluded from Definitions so it never reaches the public manifest.
|
|
||||||
export const DurableDefinitions = Event.inventory(
|
export const DurableDefinitions = Event.inventory(
|
||||||
...Definitions.filter((definition) => definition.durability === "durable"),
|
...Definitions.filter((definition) => definition.durability === "durable"),
|
||||||
UsageRecorded,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
|
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
|
||||||
@@ -592,8 +578,6 @@ export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
|
|||||||
.annotate({ identifier: "Session.Event.Durable" })
|
.annotate({ identifier: "Session.Event.Durable" })
|
||||||
export type DurableEvent = typeof Durable.Type
|
export type DurableEvent = typeof Durable.Type
|
||||||
|
|
||||||
export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe(
|
export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
|
||||||
Schema.toTaggedUnion("type"),
|
|
||||||
)
|
|
||||||
export type Event = typeof All.Type
|
export type Event = typeof All.Type
|
||||||
export type Type = Event["type"]
|
export type Type = Event["type"]
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ describe("public event manifest", () => {
|
|||||||
"session.model.selected.1",
|
"session.model.selected.1",
|
||||||
"session.moved.1",
|
"session.moved.1",
|
||||||
"session.renamed.1",
|
"session.renamed.1",
|
||||||
"session.usage.recorded.1",
|
|
||||||
"session.forked.2",
|
"session.forked.2",
|
||||||
"session.input.promoted.1",
|
"session.input.promoted.1",
|
||||||
"session.input.admitted.1",
|
"session.input.admitted.1",
|
||||||
@@ -140,16 +139,9 @@ describe("public event manifest", () => {
|
|||||||
"session.revert.committed.1",
|
"session.revert.committed.1",
|
||||||
].toSorted(),
|
].toSorted(),
|
||||||
)
|
)
|
||||||
expect(SessionEvent.DurableDefinitions).toEqual([
|
expect(SessionEvent.DurableDefinitions).toEqual(
|
||||||
...SessionEvent.Definitions.filter((definition) => definition.durability === "durable"),
|
SessionEvent.Definitions.filter((definition) => definition.durability === "durable"),
|
||||||
SessionEvent.UsageRecorded,
|
)
|
||||||
])
|
|
||||||
expect(SessionEvent.UsageRecorded.durability).toBe("durable")
|
|
||||||
expect(EventManifest.Durable.get("session.usage.recorded.1")).toBe(SessionEvent.UsageRecorded)
|
|
||||||
expect(SessionEvent.Definitions).not.toContain(SessionEvent.UsageRecorded)
|
|
||||||
expect(EventManifest.Definitions).not.toContain(SessionEvent.UsageRecorded)
|
|
||||||
expect(EventManifest.ServerDefinitions).not.toContain(SessionEvent.UsageRecorded)
|
|
||||||
expect(EventManifest.Latest.has("session.usage.recorded")).toBe(false)
|
|
||||||
expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral")
|
expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral")
|
||||||
expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral")
|
expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral")
|
||||||
expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false)
|
expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.18.3",
|
"version": "1.18.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerRe
|
|||||||
import { createServer } from "node:http"
|
import { createServer } from "node:http"
|
||||||
import { ServerAuth } from "./auth"
|
import { ServerAuth } from "./auth"
|
||||||
import { authorizedRequest } from "./middleware/authorization"
|
import { authorizedRequest } from "./middleware/authorization"
|
||||||
import { withoutParentSpan } from "./request-tracing"
|
|
||||||
import { createRoutes } from "./routes"
|
import { createRoutes } from "./routes"
|
||||||
import { ServerInfo } from "./server-info"
|
import { ServerInfo } from "./server-info"
|
||||||
import { Status } from "./service-status"
|
import { Status } from "./service-status"
|
||||||
@@ -40,10 +39,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
|
|||||||
})
|
})
|
||||||
const bound = yield* listen(options)
|
const bound = yield* listen(options)
|
||||||
const application = yield* Ref.make(Option.none<App>())
|
const application = yield* Ref.make(Option.none<App>())
|
||||||
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
|
yield* bound.http.serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger)
|
||||||
yield* bound.http
|
|
||||||
.serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger)
|
|
||||||
.pipe(withoutParentSpan)
|
|
||||||
if (options.service) yield* options.service.onListen(bound.http.address)
|
if (options.service) yield* options.service.onListen(bound.http.address)
|
||||||
|
|
||||||
const parentScope = yield* Scope.Scope
|
const parentScope = yield* Scope.Scope
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { Context, Effect, Scope, Tracer } from "effect"
|
|
||||||
|
|
||||||
export const withoutParentSpan = <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) =>
|
|
||||||
effect.pipe(
|
|
||||||
Effect.updateContext((context: Context.Context<Scope.Scope>) => Context.omit(Tracer.ParentSpan)(context)),
|
|
||||||
)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import { Effect, Option, Scope, Tracer } from "effect"
|
|
||||||
import { HttpMiddleware, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
|
||||||
import { withoutParentSpan } from "../src/request-tracing"
|
|
||||||
|
|
||||||
test("requests ignore ambient parents and continue inbound trace context", async () => {
|
|
||||||
const spans: Tracer.NativeSpan[] = []
|
|
||||||
const tracer = Tracer.make({
|
|
||||||
span(options) {
|
|
||||||
const span = new Tracer.NativeSpan(options)
|
|
||||||
spans.push(span)
|
|
||||||
return span
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const app = Effect.gen(function* () {
|
|
||||||
yield* Scope.Scope
|
|
||||||
return yield* HttpMiddleware.tracer(Effect.succeed(HttpServerResponse.empty()))
|
|
||||||
})
|
|
||||||
const request = (traceparent?: string) =>
|
|
||||||
app.pipe(
|
|
||||||
Effect.provideService(
|
|
||||||
HttpServerRequest.HttpServerRequest,
|
|
||||||
HttpServerRequest.fromWeb(
|
|
||||||
new Request("http://localhost/trace", {
|
|
||||||
headers: traceparent === undefined ? undefined : { traceparent },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
withoutParentSpan,
|
|
||||||
)
|
|
||||||
|
|
||||||
await Effect.runPromise(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* request()
|
|
||||||
yield* request()
|
|
||||||
yield* request("00-11111111111111111111111111111111-2222222222222222-01")
|
|
||||||
yield* Effect.yieldNow
|
|
||||||
}).pipe(Effect.withSpan("fixture.lifecycle"), Effect.provideService(Tracer.Tracer, tracer), Effect.scoped),
|
|
||||||
)
|
|
||||||
|
|
||||||
const requests = spans.filter((span) => span.kind === "server")
|
|
||||||
expect(requests).toHaveLength(3)
|
|
||||||
expect(requests[0]?.traceId).not.toBe(requests[1]?.traceId)
|
|
||||||
expect(Option.getOrUndefined(requests[0]?.parent ?? Option.none())).toBeUndefined()
|
|
||||||
expect(Option.getOrUndefined(requests[1]?.parent ?? Option.none())).toBeUndefined()
|
|
||||||
expect(requests[2]?.traceId).toBe("11111111111111111111111111111111")
|
|
||||||
expect(Option.getOrUndefined(requests[2]?.parent ?? Option.none())?.spanId).toBe("2222222222222222")
|
|
||||||
})
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user