Compare commits

..

17 Commits

Author SHA1 Message Date
Kit Langton 650d5a5e92 feat(core): add workspace lifecycle 2026-07-17 22:56:27 -04:00
Kit Langton d1b9b6c9ce docs(core): record Modal sandbox tracer 2026-07-17 22:25:16 -04:00
Kit Langton fd92aeac66 feat(core): add remote workspace environment seam 2026-07-17 00:04:13 -04:00
Kit Langton 09903e120f docs: plan remote workspace execution 2026-07-16 23:17:30 -04:00
Kit Langton 4a7f760d25 fix(tui): preserve prompt footer actions (#37180) 2026-07-16 22:08:22 -04:00
James Long f76e18201a fix(tui): derive session surface colors from hues (#37401) 2026-07-16 20:16:13 -04:00
Dustin Deus 331533deff fix(cli): isolate server request traces (#37395) 2026-07-17 00:52:14 +02:00
James Long 309860558d refactor(tui): remove secondary action styles (#37378) 2026-07-16 17:44:11 -04:00
opencode-agent[bot] b4a4ef0b3c refactor(tui): remove legacy keymap layer (#37206)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-16 21:33:15 +00:00
opencode-agent[bot] e916b99742 chore: merge dev into v2 (#37370)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
2026-07-16 15:44:26 -05:00
James Long 88b8883122 feat(tui): resolve action state priority (#37373) 2026-07-16 16:40:34 -04:00
James Long ac1b802820 fix(tui): restore selected action styling, fix subagent list (#37371) 2026-07-16 16:20:16 -04:00
Dustin Deus 5ee4c1082a feat(tui): add message navigation shortcuts (#37362) 2026-07-16 16:18:18 -04:00
starptech 8a70d70006 revert(tui): remove message navigation shortcuts 2026-07-16 21:45:40 +02:00
James Long 91b4634363 fix(tui): remove debug theme toggle (#37359) 2026-07-16 15:31:42 -04:00
starptech 4f60bde502 feat(tui): add message navigation shortcuts 2026-07-16 21:27:13 +02:00
Dax Raad 0d3b6d430e fix(tui): restore expandable tool hover state 2026-07-16 15:00:49 -04:00
237 changed files with 3827 additions and 3524 deletions
+27 -27
View File
@@ -53,7 +53,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -119,7 +119,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.2",
"version": "1.18.3",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -179,7 +179,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -193,7 +193,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -229,7 +229,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -256,7 +256,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -278,7 +278,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -302,7 +302,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -322,7 +322,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.18.2",
"version": "1.18.3",
"bin": {
"opencode": "./bin/opencode",
},
@@ -418,7 +418,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -478,7 +478,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -492,7 +492,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"effect": "catalog:",
},
@@ -504,7 +504,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -536,7 +536,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -552,7 +552,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.83",
},
@@ -584,7 +584,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.18.2",
"version": "1.18.3",
"bin": {
"opencode": "./bin/opencode",
},
@@ -717,7 +717,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/ai": "workspace:*",
@@ -806,7 +806,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -821,7 +821,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -838,7 +838,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -899,7 +899,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -912,7 +912,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -946,7 +946,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -965,7 +965,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -1006,7 +1006,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
@@ -1036,7 +1036,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1087,7 +1087,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.18.2",
"version": "1.18.3",
"description": "",
"type": "module",
"exports": {
+90 -136
View File
@@ -1,17 +1,18 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useNavigate } from "@solidjs/router"
import { createMemo, onCleanup } from "solid-js"
import { useCommand, type CommandOption } from "@/context/command"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
export type CommandPaletteEntry = {
id: string
@@ -24,6 +25,8 @@ export type CommandPaletteEntry = {
path?: string
directory?: string
sessionID?: string
server?: ServerConnection.Key
project?: LocalProject
archived?: number
updated?: number
}
@@ -75,28 +78,25 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const global = useGlobal()
const language = useLanguage()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()()
const serverSync = useServerSync()
const { params, tabs } = useSessionLayout()
const serverCtx = global.ensureServerCtx(serverSDK.server)
const appTabs = useTabs()
const { tabs: sessionTabs } = useSessionLayout()
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const filesOnly = () => props.filesOnly?.() ?? false
const allowedCommands = createMemo(() => {
if (filesOnly()) return []
return command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
return commandPaletteOptions(command.options)
})
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return allowedCommands().map((option) => createCommandEntry(option, category))
return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category))
})
const preferredCommandEntries = createMemo(() => {
const all = allowedCommands()
@@ -105,11 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
})
const tabState = createSessionTabs({
tabs,
tabs: sessionTabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
@@ -140,36 +140,12 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
.map((path) => createCommandPaletteFileEntry(path, category))
})
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return undefined
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const sessions = createSessionEntries({
workspaces,
label: (directory) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
},
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
const sessions = createServerSessionEntries({
server: ServerConnection.key(serverSDK.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) =>
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -191,8 +167,17 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
if (!item.sessionID || !item.server) return
const directory = item.project?.worktree ?? item.directory
if (directory) {
serverCtx.projects.open(directory)
serverCtx.projects.touch(directory)
}
const tab = appTabs.addSessionTab({
server: item.server,
sessionId: item.sessionID,
})
appTabs.select(tab)
return
}
if (!item.path) return
@@ -218,7 +203,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
}
}
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
return {
id: "command:" + option.id,
type: "command",
@@ -230,96 +215,65 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal
}
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
export function createServerSessionEntries(props: {
server: ServerConnection.Key
opened: () => LocalProject[]
stored: () => Project[]
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
untitled: () => string
category: () => string
}) {
const state: {
token: number
inflight: Promise<CommandPaletteEntry[]> | undefined
cached: CommandPaletteEntry[] | undefined
} = { token: 0, inflight: undefined, cached: undefined }
let abort: AbortController | undefined
return (text: string) => {
if (!text.trim()) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as CommandPaletteEntry[]
onCleanup(() => abort?.abort())
return async (text: string): Promise<CommandPaletteEntry[]> => {
const search = text.trim()
if (!search) {
abort?.abort()
return []
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as CommandPaletteEntry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props
.load(directory)
.then((result) =>
(result.data ?? [])
.filter((session) => !!session?.id)
.map((session) => ({
id: session.id,
title: session.title ?? props.untitled(),
description,
directory,
archived: session.time?.archived,
updated: session.time?.updated,
})),
)
.catch(() => [] as SessionEntryInput[])
}),
)
.then((results) => {
if (state.token !== current) return [] as CommandPaletteEntry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, props.category()))
state.cached = next
return next
})
abort?.abort()
const current = new AbortController()
abort = current
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 100)
current.signal.addEventListener(
"abort",
() => {
clearTimeout(timer)
resolve()
},
{ once: true },
)
})
if (current.signal.aborted) return []
const opened = props.opened()
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
return props
.load(search, current.signal)
.then((result) =>
(result.data ?? [])
.filter((session) => !session.time.archived)
.map((session) => {
const project =
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
return {
id: `session:${props.server}:${session.id}`,
type: "session" as const,
title: session.title || props.untitled(),
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
category: props.category(),
directory: session.directory,
sessionID: session.id,
server: props.server,
project,
updated: session.time.updated,
}
}),
)
.catch(() => [] as CommandPaletteEntry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
}
type SessionEntryInput = {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
return {
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
}
}
@@ -5,13 +5,20 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { formatKeybindParts } from "@/context/command"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteCommandEntry,
createCommandPaletteFileEntry,
createCommandPaletteModel,
createServerSessionEntries,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
@@ -30,9 +37,6 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const loadItems = async (text: string) => {
const q = text.trim()
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
@@ -41,16 +45,105 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
const category = palette.language.t("palette.group.files")
return [
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions.filter((entry) => matchesEntry(entry, q)),
...nextSessions,
...files.map((path) => createCommandPaletteFileEntry(path, category)),
]
}
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
return (
<CommandPaletteView
placeholder={palette.language.t("palette.search.placeholder")}
loadItems={loadItems}
highlight={palette.highlight}
select={palette.select}
close={palette.close}
/>
)
}
export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.Any
onSelectSession: (entry: CommandPaletteEntry) => void
}) {
const command = useCommand()
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage()
const serverCtx = global.ensureServerCtx(props.server)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
})
const sessions = createServerSessionEntries({
server: ServerConnection.key(props.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) =>
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
state.cleanup?.()
state.cleanup = undefined
if (item?.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const select = (item: CommandPaletteEntry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") props.onSelectSession(item)
}
const loadItems = async (text: string) => {
const query = text.trim()
if (!query) return commandEntries().slice(0, 5)
return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return (
<CommandPaletteView
placeholder={language.t("palette.search.placeholder.home")}
loadItems={loadItems}
highlight={highlight}
select={select}
close={() => dialog.close()}
/>
)
}
function CommandPaletteView(props: {
placeholder: string
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
highlight: (item: CommandPaletteEntry | undefined) => void
select: (item: CommandPaletteEntry | undefined) => void
close: () => void
}) {
const language = useLanguage()
const tabs = useTabs()
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()])
const openSessions = createMemo(
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
)
createEffect(() => {
query()
@@ -59,7 +152,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
})
createEffect(() => {
palette.highlight(activeEntry())
props.highlight(activeEntry())
})
let resultsRef: HTMLDivElement | undefined
@@ -86,12 +179,12 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
}
if (event.key === "Enter") {
event.preventDefault()
palette.select(activeEntry())
props.select(activeEntry())
return
}
if (event.key === "Escape") {
event.preventDefault()
palette.close()
props.close()
}
}
@@ -105,7 +198,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
autocomplete="off"
spellcheck={false}
appearance="large"
placeholder={palette.language.t("palette.search.placeholder")}
placeholder={props.placeholder}
leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)}
onKeyDown={handleKeyDown}
@@ -117,7 +210,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
when={visibleEntries().length > 0}
fallback={
<div class="command-palette-v2-state">
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
</div>
}
>
@@ -132,9 +225,14 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
<PaletteRow
item={item}
active={activeEntry()?.id === item.id}
language={palette.language}
language={language}
sessionOpen={
item.server && item.sessionID
? openSessions().has(`${item.server}\0${item.sessionID}`)
: false
}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onSelect={() => palette.select(item)}
onSelect={() => props.select(item)}
/>
)}
</For>
@@ -153,13 +251,19 @@ function PaletteRow(props: {
item: CommandPaletteEntry
active: boolean
language: ReturnType<typeof useLanguage>
sessionOpen: boolean
onActive: () => void
onSelect: () => void
}) {
const session = () =>
props.item.server && props.item.directory && props.item.sessionID
? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
: undefined
return (
<button
type="button"
class="command-palette-v2-row"
class="command-palette-v2-row group"
role="option"
aria-selected={props.active}
data-active={props.active ? "" : undefined}
@@ -197,7 +301,25 @@ function PaletteRow(props: {
</Match>
<Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main">
<Icon name="status" class="command-palette-v2-row-icon" />
<div class="relative shrink-0">
<Show when={props.sessionOpen}>
<span
aria-hidden="true"
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 4px)" }}
/>
</Show>
<Show when={session()}>
{(session) => (
<SessionTabAvatar
project={props.item.project}
directory={session().directory}
sessionId={session().sessionID}
server={session().server}
/>
)}
</Show>
</div>
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title}
+1 -41
View File
@@ -8,49 +8,9 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png"
const helpIcon = (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
data-slot="icon-svg"
>
<path
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
fill="var(--v2-icon-icon-base)"
/>
</svg>
)
const triggerClass =
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
// TODO: wire to changelog / seen-state when available
const showPopover = () => true
export function HelpButton() {
const platform = usePlatform()
return (
<a
href="https://opencode.ai"
aria-label="Open the OpenCode website"
data-component="icon-button-v2"
data-size="large"
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
onClick={(event) => {
event.preventDefault()
platform.openLink(event.currentTarget.href)
}}
>
{helpIcon}
</a>
)
}
// can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() {
const settings = useSettings()
@@ -61,7 +21,7 @@ export function TabsInfoPopup() {
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={settings.general.shouldDisplayTabsToast()}>
<div
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
>
<button
@@ -10,7 +10,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast"
import fuzzysort from "fuzzysort"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list"
@@ -20,7 +20,6 @@ const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
+15 -1
View File
@@ -1,5 +1,19 @@
import { describe, expect, test } from "bun:test"
import { resolveKeybindOption, upsertCommandRegistration } from "./command"
import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command"
const paletteOptions: CommandOption[] = [
{ id: "settings.open", title: "Open settings" },
{ id: "session.undo", title: "Undo" },
{ id: "file.open", title: "Open file" },
{ id: "hidden", title: "Hidden", hidden: true },
{ id: "disabled", title: "Disabled", disabled: true },
]
describe("commandPaletteOptions", () => {
test("keeps visible enabled commands", () => {
expect(commandPaletteOptions(paletteOptions).map((option) => option.id)).toEqual(["settings.open", "session.undo"])
})
})
describe("upsertCommandRegistration", () => {
test("replaces keyed registrations", () => {
+9 -2
View File
@@ -11,7 +11,7 @@ import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
@@ -87,6 +87,13 @@ export interface CommandOption {
onHighlight?: () => (() => void) | void
}
export function commandPaletteOptions(options: CommandOption[]) {
return options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith(SUGGESTED_PREFIX) && option.id !== "file.open",
)
}
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
}
@@ -375,7 +382,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
const showPalette = () => {
run("file.open", "palette")
run(PALETTE_ID, "palette")
}
const handleKeyDown = (event: KeyboardEvent) => {
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { hasCustomAgent, resolveAgent } from "./local-agent"
describe("hasCustomAgent", () => {
test("detects explicitly custom agents", () => {
expect(hasCustomAgent([{ native: true }, { native: false }])).toBe(true)
})
test("ignores built-in and unclassified agents", () => {
expect(hasCustomAgent([{ native: true }, {}])).toBe(false)
})
})
describe("resolveAgent", () => {
const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }]
test("uses the requested available agent", () => {
expect(resolveAgent(agents, "custom")?.name).toBe("custom")
})
test("defaults to build", () => {
expect(resolveAgent(agents)?.name).toBe("build")
expect(resolveAgent(agents, "missing")?.name).toBe("build")
})
test("uses the first agent when build is unavailable", () => {
expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom")
})
})
+7
View File
@@ -0,0 +1,7 @@
export function hasCustomAgent(items: Array<{ native?: boolean }>) {
return items.some((item) => item.native === false)
}
export function resolveAgent<T extends { name: string }>(items: T[], name?: string) {
return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0]
}
+7 -4
View File
@@ -4,8 +4,10 @@ import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings"
import { useProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { hasCustomAgent, resolveAgent } from "./local-agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
@@ -62,9 +64,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory)
const models = useModels()
const settings = useSettings()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted(
@@ -107,9 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const pickAgent = (name: string | undefined) => {
const items = list()
if (items.length === 0) return
return items.find((item) => item.name === name) ?? items[0]
return resolveAgent(list(), name)
}
createEffect(() => {
@@ -180,8 +182,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = {
list,
visible: agentsVisible,
current() {
return pickAgent(scope()?.agent ?? store.current)
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
},
set(name: string | undefined) {
const item = pickAgent(name)
+1
View File
@@ -86,6 +86,7 @@ export const dict = {
"command.session.unshare": "إلغاء مشاركة الجلسة",
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
"palette.search.placeholder.home": "البحث في الأوامر والجلسات",
"palette.empty": "لا توجد نتائج",
"palette.group.commands": "الأوامر",
"palette.group.files": "الملفات",
+1
View File
@@ -86,6 +86,7 @@ export const dict = {
"command.session.unshare": "Parar de compartilhar sessão",
"command.session.unshare.description": "Parar de compartilhar esta sessão",
"palette.search.placeholder": "Buscar arquivos, comandos e sessões",
"palette.search.placeholder.home": "Buscar comandos e sessões",
"palette.empty": "Nenhum resultado encontrado",
"palette.group.commands": "Comandos",
"palette.group.files": "Arquivos",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "Zaustavi dijeljenje ove sesije",
"palette.search.placeholder": "Pretraži datoteke, komande i sesije",
"palette.search.placeholder.home": "Pretraži komande i sesije",
"palette.empty": "Nema rezultata",
"palette.group.commands": "Komande",
"palette.group.files": "Datoteke",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "Stop med at dele denne session",
"palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
"palette.search.placeholder.home": "Søg i kommandoer og sessioner",
"palette.empty": "Ingen resultater fundet",
"palette.group.commands": "Kommandoer",
"palette.group.files": "Filer",
+1
View File
@@ -90,6 +90,7 @@ export const dict = {
"command.session.unshare": "Teilen der Sitzung aufheben",
"command.session.unshare.description": "Teilen dieser Sitzung beenden",
"palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen",
"palette.search.placeholder.home": "Befehle und Sitzungen durchsuchen",
"palette.empty": "Keine Ergebnisse gefunden",
"palette.group.commands": "Befehle",
"palette.group.files": "Dateien",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "Stop sharing this session",
"palette.search.placeholder": "Search files, commands, and sessions",
"palette.search.placeholder.home": "Search commands and sessions",
"palette.empty": "No results found",
"palette.group.commands": "Commands",
"palette.group.files": "Files",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "Dejar de compartir esta sesión",
"palette.search.placeholder": "Buscar archivos, comandos y sesiones",
"palette.search.placeholder.home": "Buscar comandos y sesiones",
"palette.empty": "No se encontraron resultados",
"palette.group.commands": "Comandos",
"palette.group.files": "Archivos",
+1
View File
@@ -86,6 +86,7 @@ export const dict = {
"command.session.unshare": "Ne plus partager la session",
"command.session.unshare.description": "Arrêter de partager cette session",
"palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions",
"palette.search.placeholder.home": "Rechercher des commandes et des sessions",
"palette.empty": "Aucun résultat trouvé",
"palette.group.commands": "Commandes",
"palette.group.files": "Fichiers",
+1
View File
@@ -86,6 +86,7 @@ export const dict = {
"command.session.unshare": "セッションの共有を停止",
"command.session.unshare.description": "このセッションの共有を停止",
"palette.search.placeholder": "ファイル、コマンド、セッションを検索",
"palette.search.placeholder.home": "コマンドとセッションを検索",
"palette.empty": "結果が見つかりません",
"palette.group.commands": "コマンド",
"palette.group.files": "ファイル",
+1
View File
@@ -82,6 +82,7 @@ export const dict = {
"command.session.unshare": "세션 공유 중지",
"command.session.unshare.description": "이 세션 공유 중지",
"palette.search.placeholder": "파일, 명령어 및 세션 검색",
"palette.search.placeholder.home": "명령어 및 세션 검색",
"palette.empty": "결과 없음",
"palette.group.commands": "명령어",
"palette.group.files": "파일",
+1
View File
@@ -92,6 +92,7 @@ export const dict = {
"command.session.unshare.description": "Slutt å dele denne sesjonen",
"palette.search.placeholder": "Søk i filer, kommandoer og sesjoner",
"palette.search.placeholder.home": "Søk i kommandoer og sesjoner",
"palette.empty": "Ingen resultater funnet",
"palette.group.commands": "Kommandoer",
"palette.group.files": "Filer",
+1
View File
@@ -86,6 +86,7 @@ export const dict = {
"command.session.unshare": "Przestań udostępniać sesję",
"command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji",
"palette.search.placeholder": "Szukaj plików, poleceń i sesji",
"palette.search.placeholder.home": "Szukaj poleceń i sesji",
"palette.empty": "Brak wyników",
"palette.group.commands": "Polecenia",
"palette.group.files": "Pliki",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "Прекратить публикацию сессии",
"palette.search.placeholder": "Поиск файлов, команд и сессий",
"palette.search.placeholder.home": "Поиск команд и сессий",
"palette.empty": "Ничего не найдено",
"palette.group.commands": "Команды",
"palette.group.files": "Файлы",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "หยุดการแชร์เซสชันนี้",
"palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน",
"palette.search.placeholder.home": "ค้นหาคำสั่งและเซสชัน",
"palette.empty": "ไม่พบผลลัพธ์",
"palette.group.commands": "คำสั่ง",
"palette.group.files": "ไฟล์",
+1
View File
@@ -97,6 +97,7 @@ export const dict = {
"command.session.unshare.description": "Bu oturumun paylaşımını durdur",
"palette.search.placeholder": "Dosya, komut ve oturum ara",
"palette.search.placeholder.home": "Komut ve oturum ara",
"palette.empty": "Sonuç bulunamadı",
"palette.group.commands": "Komutlar",
"palette.group.files": "Dosyalar",
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.unshare.description": "Припинити поширення цієї сесії",
"palette.search.placeholder": "Пошук файлів, команд і сесій",
"palette.search.placeholder.home": "Пошук команд і сесій",
"palette.empty": "Результатів не знайдено",
"palette.group.commands": "Команди",
"palette.group.files": "Файли",
+1
View File
@@ -120,6 +120,7 @@ export const dict = {
"command.session.unshare.description": "停止分享此会话",
"palette.search.placeholder": "搜索文件、命令和会话",
"palette.search.placeholder.home": "搜索命令和会话",
"palette.empty": "未找到结果",
"palette.group.commands": "命令",
"palette.group.files": "文件",
+1
View File
@@ -97,6 +97,7 @@ export const dict = {
"command.session.unshare.description": "停止分享此工作階段",
"palette.search.placeholder": "搜尋檔案、命令和工作階段",
"palette.search.placeholder.home": "搜尋命令和工作階段",
"palette.empty": "找不到結果",
"palette.group.commands": "命令",
"palette.group.files": "檔案",
+211 -118
View File
@@ -5,6 +5,7 @@ import {
createMemo,
createResource,
createRoot,
createSignal,
For,
Match,
on,
@@ -74,6 +75,23 @@ const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
function containHomeWheel(event: WheelEvent, viewport: HTMLElement) {
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
if (!(event.target instanceof Element)) return
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
if (
scrollable !== viewport &&
scrollable &&
(event.deltaY < 0
? scrollable.scrollTop > 0
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
)
return
event.preventDefault()
}
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
@@ -144,6 +162,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({
@@ -202,6 +221,13 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
function updatePositionCache() {
if (!viewport) return
const header = groups()
.map((group) => headerRefs.get(group.id))
.find((el) => el !== undefined)
if (header && typeof getComputedStyle === "function") {
const top = Number.parseFloat(getComputedStyle(header).top)
if (Number.isFinite(top)) stickyTop = top
}
groups().forEach((group) => {
const el = headerRefs.get(group.id)
if (!el) return
@@ -217,7 +243,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
.slice(index + 1)
.map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined)
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
@@ -271,6 +297,9 @@ export function NewHome() {
const marked = useMarked()
const openSettings = useSettingsCommand()
let focusSessionSearch: (() => void) | undefined
let sessionViewport: HTMLDivElement | undefined
const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
const [state, setState] = createStore({
search: "",
searchFocused: false,
@@ -402,6 +431,34 @@ export function NewHome() {
}
command.register("home", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const conn = focusedServer()
if (!conn) return
const ctx = global.ensureServerCtx(conn)
const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
void dialog.show(() => (
<DialogHomeCommandPaletteV2
server={conn}
onSelectSession={(entry) => {
if (!entry.sessionID || !entry.directory || !entry.server) return
const sessionID = entry.sessionID
const server = entry.server
const directory = entry.project?.worktree ?? entry.directory
ctx.projects.open(directory)
ctx.projects.touch(directory)
void startTransition(() => {
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
tabs.select(tab)
})
}}
/>
))
},
},
{
id: "home.sessions.search.focus",
title: searchPlaceholder(),
@@ -541,127 +598,158 @@ export function NewHome() {
}
return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
<HomeProjectColumn
projects={projects()}
recentlyClosed={recentlyClosed()}
homedir={homedir()}
selected={selection()}
focusServer={focusServer}
selectProject={selectProject}
openNewSession={openProjectNewSession}
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
chooseProject={(conn) => void chooseProject(conn)}
editProject={editProject}
closeProject={(conn, directory) => {
const next = closeHomeProject(
selection(),
ServerConnection.key(conn),
global.ensureServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
<section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
aria-label={language.t("sidebar.project.recentSessions")}
>
<HomeSessionSearch
value={state.search}
placeholder={searchPlaceholder()}
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
showProjectName={!selectedProject()}
server={selection().server}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<ScrollView
class="h-full [container-type:size]"
thumbContainer={sessionThumbTrack}
thumbHoverTarget={sessionHoverTarget}
viewportRef={(el) => {
sessionViewport = el
sessionHeaderOpacity.setViewport(el)
}}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
onWheel={(event) => {
if (!sessionViewport) return
if (event.target instanceof Node && sessionViewport.contains(event.target)) return
containHomeWheel(event, sessionViewport)
}}
>
<div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
<HomeProjectColumn
projects={projects()}
recentlyClosed={recentlyClosed()}
homedir={homedir()}
selected={selection()}
focusServer={focusServer}
selectProject={selectProject}
openNewSession={openProjectNewSession}
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
chooseProject={(conn) => void chooseProject(conn)}
editProject={editProject}
closeProject={(conn, directory) => {
const next = closeHomeProject(
selection(),
ServerConnection.key(conn),
global.ensureServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
onWheel={(event) => {
if (sessionViewport) containHomeWheel(event, sessionViewport)
}}
onInput={(value) => setState("search", value)}
onFocus={() => setState("searchFocused", true)}
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<ScrollView
class="mt-3 -mr-3 min-h-0 flex-1 relative"
viewportRef={sessionHeaderOpacity.setViewport}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
<section
ref={setSessionHoverTarget}
class="min-h-0 min-w-0 flex-1 flex flex-col"
aria-label={language.t("sidebar.project.recentSessions")}
>
<Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
<Show
when={!sessionLoad.isLoading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={language.t("common.loading")} />
</div>
}
<div
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
onWheel={(event) => {
if (sessionViewport) containHomeWheel(event, sessionViewport)
}}
>
<Show
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
>
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
<For each={groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>
{(record) => (
<HomeSessionRow
record={record}
showProjectName={!selectedProject()}
server={selection().server}
openSession={openSession}
archiveSession={archiveSession}
/>
)}
</For>
</div>
</>
)}
</For>
<HomeSessionSearch
value={state.search}
placeholder={searchPlaceholder()}
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
showProjectName={!selectedProject()}
server={selection().server}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
}}
onInput={(value) => setState("search", value)}
onFocus={() => setState("searchFocused", true)}
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
</Show>
</ScrollView>
</section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
</div>
</div>
{/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
ref={setSessionThumbTrack}
data-component="home-session-scroll-track"
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!sessionLoad.isLoading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={language.t("common.loading")} />
</div>
}
>
<Show
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
>
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
<For each={groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>
{(record) => (
<HomeSessionRow
record={record}
showProjectName={!selectedProject()}
server={selection().server}
openSession={openSession}
archiveSession={archiveSession}
/>
)}
</For>
</div>
</>
)}
</For>
</div>
</Show>
</Show>
</div>
</section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
</div>
</ScrollView>
</div>
)
}
@@ -683,6 +771,7 @@ function HomeProjectColumn(props: {
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
onWheel: (event: WheelEvent) => void
}) {
const global = useGlobal()
const dialog = useDialog()
@@ -699,8 +788,12 @@ function HomeProjectColumn(props: {
return (
<aside
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
aria-label={props.language.t("home.projects")}
onWheel={(event) => {
if (event.target === event.currentTarget) return
props.onWheel(event)
}}
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
@@ -1418,7 +1511,7 @@ function HomeSessionGroupHeader(props: {
return (
<div
ref={props.ref}
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
>
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
{props.title}
+1 -2
View File
@@ -1,7 +1,7 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
import { TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
@@ -38,7 +38,6 @@ export default function NewLayout(props: ParentProps) {
</main>
{import.meta.env.DEV && <DebugBar inline />}
<TabsInfoPopup />
<HelpButton />
<ToastRegion v2 />
</div>
)
+1 -2
View File
@@ -54,7 +54,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
import { TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useDirectoryPicker } from "@/components/directory-picker"
import { ServerConnection, useServer } from "@/context/server"
@@ -2396,7 +2396,6 @@ export default function LegacyLayout(props: ParentProps) {
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div>
<TabsInfoPopup />
<HelpButton />
<ToastRegion v2={false} />
</div>
)
+11
View File
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useSearchParams } from "@solidjs/router"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { NewSessionDesignView } from "@/components/session"
@@ -51,6 +52,7 @@ export default function NewSessionPage() {
const comments = useComments()
const language = useLanguage()
const settings = useSettings()
const dialog = useDialog()
const command = useCommand()
const providers = useProviders(() => sdk().directory)
const openProviderSettings = useSettingsDialog("providers")
@@ -77,6 +79,15 @@ export default function NewSessionPage() {
})
command.register("new-session", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const { DialogSelectFile } = await import("@/components/dialog-select-file")
void dialog.show(() => <DialogSelectFile />)
},
},
{
id: "input.focus",
title: language.t("command.input.focus"),
+10
View File
@@ -41,6 +41,7 @@ import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/r
import { NewSessionView, SessionHeader } from "@/components/session"
import { ErrorPage } from "@/pages/error"
import { CommentsProvider, useComments } from "@/context/comments"
import { useCommand } from "@/context/command"
import { DirectoryDataProvider } from "@/pages/directory-layout"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -362,6 +363,7 @@ export default function Page() {
const platform = usePlatform()
const prompt = usePrompt()
const comments = useComments()
const command = useCommand()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
@@ -1098,6 +1100,14 @@ export default function Page() {
review: reviewTab,
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
})
command.register("session-palette", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: () => command.trigger("file.open", "palette"),
},
])
const openReviewFile = createOpenReviewFile({
showAllFiles,
@@ -41,7 +41,7 @@ export function createPromptInputController(input: {
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
loading: agentsQuery.isLoading,
visible: settings.visibility.customAgents(),
visible: local.agent.visible(),
select: local.agent.set,
},
model: {
@@ -1,7 +1,6 @@
import { useCommand, type CommandOption } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local"
import { useSettings } from "@/context/settings"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
import { useSessionLayout } from "./session-layout"
@@ -19,7 +18,6 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
const dialog = useDialog()
const language = useLanguage()
const local = useLocal()
const settings = useSettings()
const { sessionKey } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
const model = input.model ?? local.model
@@ -70,7 +68,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
disabled: !settings.visibility.customAgents(),
disabled: !local.agent.visible(),
onSelect: () => local.agent.move(1),
}),
agentCommand({
@@ -78,7 +76,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
disabled: !settings.visibility.customAgents(),
disabled: !local.agent.visible(),
onSelect: () => local.agent.move(-1),
}),
])
@@ -468,7 +468,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
keybind: "mod+p",
slash: "open",
onSelect: openFile,
}),
@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test"
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
import { createRoot } from "solid-js"
import { createServerSessionEntries } from "@/components/command-palette"
import type { LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
const stored: Project = {
id: "project-1",
name: "Palette project",
worktree: "/workspace/project",
sandboxes: [],
time: { created: 1, updated: 1 },
}
const session: GlobalSession = {
id: "session-1",
slug: "session-1",
projectID: stored.id,
directory: stored.worktree,
title: "Palette session",
version: "1",
time: { created: 1, updated: 2 },
project: { id: stored.id, name: stored.name, worktree: stored.worktree },
}
describe("command palette sessions", () => {
test("uses the home project avatar and cancels superseded searches", async () => {
const server = ServerConnection.Key.make("selected-server")
const opened: LocalProject = {
...stored,
icon: { override: "home-project-avatar" },
expanded: true,
}
const searches: string[] = []
const result = await new Promise<Awaited<ReturnType<ReturnType<typeof createServerSessionEntries>>>>(
(resolve, reject) => {
createRoot((dispose) => {
const search = createServerSessionEntries({
server,
opened: () => [opened],
stored: () => [{ ...stored, icon: { url: "stored-project-avatar" } }],
load: async (text) => {
searches.push(text)
return {
data: [session, { ...session, id: "archived-session", time: { ...session.time, archived: 3 } }],
}
},
untitled: () => "Untitled",
category: () => "Sessions",
})
const first = search("palette")
const second = search("palette session")
Promise.all([first, second])
.then(([cancelled, entries]) => {
expect(cancelled).toEqual([])
resolve(entries)
})
.catch(reject)
.finally(dispose)
})
},
)
expect(searches).toEqual(["palette session"])
expect(result).toHaveLength(1)
expect(getProjectAvatarSource(result[0]?.project?.id, result[0]?.project?.icon)).toBe("home-project-avatar")
expect(result[0]).toMatchObject({
server,
sessionID: session.id,
description: stored.name,
project: { id: stored.id, icon: opened.icon },
})
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -5,7 +5,7 @@ import { ServerProcess } from "../../server-process"
export default Runtime.handler(
Commands.commands.serve,
Effect.fn("cli.serve")(function* (input) {
Effect.fnUntraced(function* (input) {
if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined"))
return yield* ServerProcess.run({
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
+6
View File
@@ -633,6 +633,12 @@ export function RunSubagentSelectBody(props: {
return
}
if (event.name.toLowerCase() === "up" && menu.selected() === 0) {
event.preventDefault()
props.onClose()
return
}
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
})
+45 -64
View File
@@ -23,7 +23,7 @@ import {
movePromptHistory,
pushPromptHistory,
} from "./prompt.shared"
import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
@@ -993,93 +993,83 @@ export function createPromptState(input: PromptInput): PromptState {
return true
}
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: baseBindingsEnabled(),
commands: [
{
name: "prompt.clear",
id: "prompt.clear",
title: "Clear prompt or exit",
category: "Prompt",
group: "Prompt",
run() {
if (requestExit()) return
return false
},
},
],
bindings: input.tuiConfig.keybinds.get("prompt.clear"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt(),
commands: [
{
name: "session.interrupt",
id: "session.interrupt",
title: "Interrupt session",
category: "Session",
group: "Session",
run() {
if (input.onInterrupt()) return
return false
},
},
],
bindings: input.tuiConfig.keybinds.get("session.interrupt"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt() && !visible(),
commands: [
{
name: "prompt.editor",
id: "prompt.editor",
title: "Open editor",
category: "Prompt",
group: "Prompt",
run() {
void openEditor()
},
},
],
bindings: input.tuiConfig.keybinds.get("prompt.editor"),
}))
useBindings(() => ({
Keymap.createLayer(() => ({
priority: 1,
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(),
commands: [
{
name: "prompt.history.previous",
id: "prompt.history.previous",
title: "Previous prompt history",
category: "Prompt",
run(ctx: { event: KeyEvent }) {
return historyCommand(-1, ctx.event)
group: "Prompt",
run(_input: string | undefined, event?: KeyEvent) {
if (!event) return false
return historyCommand(-1, event)
},
},
{
name: "prompt.history.next",
id: "prompt.history.next",
title: "Next prompt history",
category: "Prompt",
run(ctx: { event: KeyEvent }) {
return historyCommand(1, ctx.event)
group: "Prompt",
run(_input: string | undefined, event?: KeyEvent) {
if (!event) return false
return historyCommand(1, event)
},
},
],
bindings: [
...input.tuiConfig.keybinds.get("prompt.history.previous"),
...input.tuiConfig.keybinds.get("prompt.history.next"),
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt() && !visible(),
bindings: [
commands: [
{
key: "!",
desc: "Shell mode",
bind: "!",
title: "Shell mode",
group: "Prompt",
cmd() {
run() {
if (shell()) return false
if (!area || area.isDestroyed) return false
if (area.cursorOffset !== 0) return false
@@ -1089,21 +1079,20 @@ export function createPromptState(input: PromptInput): PromptState {
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt() && shell() && !visible(),
bindings: [
commands: [
{
key: "escape",
desc: "Exit shell mode",
bind: "escape",
title: "Exit shell mode",
group: "Prompt",
cmd: () => setShellMode(false),
run: () => setShellMode(false),
},
{
key: "backspace",
desc: "Exit shell mode",
bind: "backspace",
title: "Exit shell mode",
group: "Prompt",
cmd() {
run() {
if (!area || area.isDestroyed) return false
if (area.cursorOffset !== 0) return false
setShellMode(false)
@@ -1112,32 +1101,31 @@ export function createPromptState(input: PromptInput): PromptState {
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt() && visible(),
commands: [
{
name: "prompt.autocomplete.prev",
id: "prompt.autocomplete.prev",
title: "Previous autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run: () => menu.move(-1),
},
{
name: "prompt.autocomplete.next",
id: "prompt.autocomplete.next",
title: "Next autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run: () => menu.move(1),
},
{
name: "prompt.autocomplete.hide",
id: "prompt.autocomplete.hide",
title: "Hide autocomplete",
category: "Autocomplete",
group: "Autocomplete",
run: cancelAutocomplete,
},
{
name: "prompt.autocomplete.select",
id: "prompt.autocomplete.select",
title: "Select autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
if (mode() === "slash" && options().length === 0) {
hide()
@@ -1147,9 +1135,9 @@ export function createPromptState(input: PromptInput): PromptState {
},
},
{
name: "prompt.autocomplete.complete",
id: "prompt.autocomplete.complete",
title: "Complete autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
if (mode() === "slash" && options().length === 0) {
hide()
@@ -1164,13 +1152,6 @@ export function createPromptState(input: PromptInput): PromptState {
},
},
],
bindings: [
"prompt.autocomplete.prev",
"prompt.autocomplete.next",
"prompt.autocomplete.hide",
"prompt.autocomplete.select",
"prompt.autocomplete.complete",
].flatMap((command) => input.tuiConfig.keybinds.get(command)),
}))
const onKeyDown = (event: KeyEvent) => {
+4 -6
View File
@@ -24,12 +24,11 @@
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
// two-press pattern where the first press shows a hint and the second press
// within 5 seconds actually fires the action.
import { CliRenderEvents, type CliRenderer, type KeyEvent, type Renderable, type TreeSitterClient } from "@opentui/core"
import type { Keymap } from "@opentui/keymap"
import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core"
import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { OpencodeKeymapProvider } from "@opencode-ai/tui/keymap"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
@@ -82,7 +81,6 @@ type RunFooterOptions = {
first: boolean
history?: RunPrompt[]
theme: RunTheme
keymap: Keymap<Renderable, KeyEvent>
tuiConfig: RunTuiConfig
diffStyle: RunDiffStyle
onPermissionReply: (input: PermissionReply) => void | Promise<void>
@@ -305,8 +303,8 @@ export class RunFooter implements FooterApi {
const footer = this
void render(
() =>
createComponent(OpencodeKeymapProvider, {
keymap: options.keymap,
createComponent(Keymap.Provider, {
config: options.tuiConfig,
get children() {
return createComponent(RunFooterView, {
directory: options.directory,
+29 -107
View File
@@ -27,14 +27,8 @@ import { RunPromptBody, createPromptState } from "./footer.prompt"
import { RunPermissionBody } from "./footer.permission"
import { RunQuestionBody } from "./footer.question"
import { footerWidthPolicy } from "./footer.width"
import {
OPENCODE_BASE_MODE,
formatKeyBindings,
formatKeySequence,
useBindings,
useKeymapSelector,
type OpenTuiKeymap,
} from "@opencode-ai/tui/keymap"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import type {
FooterPromptRoute,
FooterQueuedPrompt,
@@ -177,75 +171,15 @@ export function RunFooterView(props: RunFooterViewProps) {
const current = route()
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
})
const command = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] })
.get("command.palette.show")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const subagentShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] })
.get("session.child.first")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const queuedShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.queued_prompts"] })
.get("session.queued_prompts")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const backgroundShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.background"] })
.get("session.background")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const subagentInterruptShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["subagent.interrupt"] })
.get("subagent.interrupt")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const interrupt = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
.get("session.interrupt")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const variantCycle = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
keymap.getCommandBindings({ visibility: "registered", commands: ["variant.cycle"] }).get("variant.cycle"),
props.tuiConfig,
) ?? "",
)
const clearShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap.getCommandBindings({ visibility: "registered", commands: ["prompt.clear"] }).get("prompt.clear")?.[0]
?.sequence,
props.tuiConfig,
) ?? "",
)
const shortcuts = Keymap.useShortcuts()
const command = () => shortcuts.get("command.palette.show") ?? ""
const subagentShortcut = () => shortcuts.get("session.child.first") ?? ""
const queuedShortcut = () => shortcuts.get("session.queued_prompts") ?? ""
const backgroundShortcut = () => shortcuts.get("session.background") ?? ""
const subagentInterruptShortcut = () => shortcuts.get("subagent.interrupt") ?? ""
const interrupt = () => shortcuts.get("session.interrupt") ?? ""
const variantCycle = () => shortcuts.all("variant.cycle") ?? ""
const clearShortcut = () => shortcuts.get("prompt.clear") ?? ""
const busy = createMemo(() => props.state().phase === "running")
const armed = createMemo(() => props.state().interrupt > 0)
const exiting = createMemo(() => props.state().exit > 0)
@@ -504,74 +438,62 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onRequestExit?.(undefined)
})
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
commands: [
{
name: "command.palette.show",
id: "command.palette.show",
title: "Open command palette",
category: "Prompt",
group: "Prompt",
run: openCommand,
},
{
name: "variant.cycle",
id: "variant.cycle",
title: "Cycle model variant",
category: "Model",
group: "Model",
run: props.onCycle,
},
],
bindings: [
...props.tuiConfig.keybinds.get("command.palette.show"),
...props.tuiConfig.keybinds.get("variant.cycle"),
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
priority: 1,
commands: [
{
name: "session.background",
id: "session.background",
title: "Background subagents",
category: "Session",
group: "Session",
run: () => props.onBackground?.(),
},
],
bindings: props.tuiConfig.keybinds.get("session.background"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
commands: [
{
name: "session.child.first",
id: "session.child.first",
title: "View subagents",
category: "Session",
group: "Session",
run: openSubagentMenu,
},
],
bindings: props.tuiConfig.keybinds.get("session.child.first"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
commands: [
{
name: "session.queued_prompts",
id: "session.queued_prompts",
title: "Manage queued prompts",
category: "Session",
group: "Session",
run: openQueuedMenu,
},
],
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled:
active().type === "prompt" &&
route().type === "subagent" &&
@@ -580,9 +502,10 @@ export function RunFooterView(props: RunFooterViewProps) {
priority: 1,
commands: [
{
name: "subagent.interrupt",
id: "subagent.interrupt",
title: "Interrupt subagent",
category: "Session",
group: "Session",
bind: "ctrl+d",
run: () => {
const current = selectedTab()
if (current?.status !== "running") {
@@ -593,7 +516,6 @@ export function RunFooterView(props: RunFooterViewProps) {
},
},
],
bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }],
}))
createEffect(() => {
@@ -10,9 +10,7 @@
// back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Global } from "@opencode-ai/core/global"
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { isDefaultTitle } from "@opencode-ai/tui/util/session"
import { Locale } from "@opencode-ai/tui/util/locale"
import { resolveInteractiveStdin } from "./runtime.stdin"
@@ -167,8 +165,6 @@ function queueSplash(
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
const source = resolveInteractiveStdin()
const footerTask = import("./footer")
let unregisterKeymap: (() => void) | undefined
try {
const renderer = await createCliRenderer({
stdin: source.stdin,
@@ -187,8 +183,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
})
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
renderer.setBackgroundColor(theme.background)
const keymap = createDefaultOpenTuiKeymap(renderer)
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
const state: SplashState = {
entry: false,
exit: false,
@@ -233,7 +227,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
history: input.history,
theme,
wrote,
keymap,
tuiConfig,
diffStyle: tuiConfig.diff_style ?? "auto",
onPermissionReply: input.onPermissionReply,
@@ -333,7 +326,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
footer.close()
await footer.idle().catch(() => {})
footer.destroy()
unregisterKeymap?.()
shutdown(renderer)
if (!wroteExit) {
process.stdout.write("\n")
@@ -391,7 +383,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
close,
}
} catch (error) {
unregisterKeymap?.()
source.cleanup?.()
throw error
}
+5 -4
View File
@@ -24,13 +24,14 @@ export type Options = {
readonly port?: number
}
export const run = Effect.fn("cli.server-process.run")((options: Options) =>
processEffect(options).pipe(
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
export const run = Effect.fnUntraced(function* (options: Options) {
return yield* processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(NodeServices.layer),
),
)
)
})
const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
+39 -50
View File
@@ -1,10 +1,9 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { testRender } from "@opentui/solid"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import { resolve } from "@opencode-ai/tui/config/v1"
import { expect, test } from "bun:test"
import { createComponent, createSignal } from "solid-js"
import { createSignal } from "solid-js"
import { RunFooterView } from "../src/mini/footer.view"
import { RUN_THEME_FALLBACK } from "../src/mini/theme"
import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types"
@@ -42,52 +41,43 @@ test("down opens subagents from an empty prompt", async () => {
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
{ terminalSuspend: true },
)
let offKeymap: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
return createComponent(OpencodeKeymapProvider, {
keymap,
get children() {
return (
<RunFooterView
directory="/tmp"
findFiles={async () => []}
agents={() => []}
references={() => []}
commands={() => []}
providers={() => undefined}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
state={state}
view={view}
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
agent="opencode"
onSubmit={() => true}
onPermissionReply={() => {}}
onQuestionReply={() => {}}
onQuestionReject={() => {}}
onCycle={() => {}}
onInterrupt={() => false}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
)
},
})
return (
<Keymap.Provider config={config}>
<RunFooterView
directory="/tmp"
findFiles={async () => []}
agents={() => []}
references={() => []}
commands={() => []}
providers={() => undefined}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
state={state}
view={view}
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
agent="opencode"
onSubmit={() => true}
onPermissionReply={() => {}}
onQuestionReply={() => {}}
onQuestionReject={() => {}}
onCycle={() => {}}
onInterrupt={() => false}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</Keymap.Provider>
)
}
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
@@ -100,7 +90,6 @@ test("down opens subagents from an empty prompt", async () => {
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
app.renderer.destroy()
}
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.18.2",
"version": "1.18.3",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.18.2",
"version": "1.18.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.18.2",
"version": "1.18.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.18.2",
"version": "1.18.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.2",
"version": "1.18.3",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { Workspace } from "@opencode-ai/schema/workspace"
import { ProjectTable } from "../project/sql"
import { ProjectV2 } from "../project"
import { WorkspaceV2 } from "../workspace"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<WorkspaceV2.ID>().primaryKey(),
id: text().$type<Workspace.ID>().primaryKey(),
type: text().notNull(),
name: text().notNull().default(""),
branch: text(),
+6 -1
View File
@@ -16,6 +16,7 @@ import { Image } from "./image"
import { LocationWatcher } from "./filesystem/location-watcher"
import { Integration } from "./integration"
import { Location } from "./location"
import { WorkspaceEnvironment } from "./workspace/environment"
import { LocationMutation } from "./location-mutation"
import { LocationServiceMap } from "./location-service-map"
import { MCP } from "./mcp/index"
@@ -50,6 +51,7 @@ export { LocationServiceMap } from "./location-service-map"
const locationServiceNodes = [
Location.node,
WorkspaceEnvironment.node,
Config.node,
AgentV2.node,
CommandV2.node,
@@ -115,7 +117,10 @@ export function buildLocationServiceMap(
LayerMap.make(
(ref: Location.Ref) => {
const startedAt = performance.now()
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
const allReplacements = replacements.concat([
[Location.node, Location.boundNode(ref)],
[WorkspaceEnvironment.node, WorkspaceEnvironment.boundNode(ref)],
])
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
+35 -4
View File
@@ -1,8 +1,10 @@
import { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location"
import path from "path"
import { Project } from "./project"
import { LayerNode } from "./effect/layer-node"
import { makeLocationNode, tags } from "./effect/app-node"
import { WorkspaceV2 } from "./workspace"
export * as Location from "./location"
@@ -16,7 +18,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
export const node = LayerNode.unbound(Service, tags.values.location)
const layer = (ref: Ref) =>
const localLayer = (ref: Ref) =>
Layer.effect(
Service,
Effect.gen(function* () {
@@ -31,9 +33,38 @@ const layer = (ref: Ref) =>
}),
)
export const boundNode = (ref: Ref) =>
makeLocationNode({
const hostedLayer = (ref: Ref & { readonly workspaceID: WorkspaceV2.ID }) =>
Layer.effect(
Service,
Effect.gen(function* () {
const workspace = yield* WorkspaceV2.Service
const info = yield* workspace.get(ref.workspaceID)
const relative = path.posix.relative(info.directory, ref.directory)
if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative)) {
return yield* new WorkspaceV2.InvalidError({
id: ref.workspaceID,
message: `Location directory is outside Workspace root: ${ref.directory}`,
})
}
return Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: info.project,
})
}),
).pipe(Layer.orDie)
export const boundNode = (ref: Ref) => {
if (ref.workspaceID) {
return makeLocationNode({
service: Service,
layer: hostedLayer({ ...ref, workspaceID: ref.workspaceID }),
deps: [WorkspaceV2.node],
})
}
return makeLocationNode({
service: Service,
layer: layer(ref),
layer: localLayer(ref),
deps: [Project.node],
})
}
+269
View File
@@ -1,6 +1,275 @@
export * as WorkspaceV2 from "./workspace"
import { Context, Effect, Equal, Exit, Latch, Layer, RcMap, Schema, Scope, Semaphore } from "effect"
import { Workspace } from "@opencode-ai/schema/workspace"
import { eq } from "drizzle-orm"
import { Database } from "./database/database"
import { makeGlobalNode } from "./effect/app-node"
import { AbsolutePath } from "./schema"
import { WorkspaceTable } from "./control-plane/workspace.sql"
import { Sandbox } from "./workspace/sandbox"
import type { WorkspaceEnvironment } from "./workspace/environment"
export const ID = Workspace.ID
export type ID = typeof ID.Type
export const Info = Workspace.Info
export type Info = Workspace.Info
export interface CreateInput {
readonly provider: string
readonly name: string
readonly directory: AbsolutePath
readonly projectID: Info["project"]["id"]
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Workspace.NotFoundError", {
id: ID,
}) {}
export class InvalidError extends Schema.TaggedErrorClass<InvalidError>()("Workspace.InvalidError", {
id: ID,
message: Schema.String,
}) {}
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, Sandbox.Error | Sandbox.ProviderNotFoundError>
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError | InvalidError>
readonly connect: (
id: ID,
) => Effect.Effect<void, NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError>
readonly borrow: (
id: ID,
) => Effect.Effect<
WorkspaceEnvironment.Interface,
NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError,
Scope.Scope
>
readonly suspend: (
id: ID,
) => Effect.Effect<void, NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError>
readonly remove: (
id: ID,
) => Effect.Effect<void, NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const registry = yield* Sandbox.RegistryService
const lifecycle = yield* RcMap.make({
idleTimeToLive: 0,
lookup: () => Effect.succeed(makeLifecycleGate()),
})
const row = Effect.fn("Workspace.row")(function* (id: ID) {
const value = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie)
if (!value) return yield* new NotFoundError({ id })
if (!value.directory) return yield* new InvalidError({ id, message: "Workspace has no directory" })
return { ...value, directory: value.directory }
})
const get = Effect.fn("Workspace.get")(function* (id: ID) {
const value = yield* row(id)
const directory = AbsolutePath.make(value.directory)
return Info.make({
id,
name: value.name,
directory,
project: { id: value.project_id, directory },
})
})
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = ID.create()
const provider = yield* registry.get(input.provider)
return yield* Effect.acquireUseRelease(
provider.create({ identity: id }),
(binding) =>
db
.insert(WorkspaceTable)
.values({
id,
type: provider.key,
name: input.name,
directory: input.directory,
extra: Sandbox.Placement.make({ kind: "sandbox", version: 1, binding }),
project_id: input.projectID,
})
.run()
.pipe(
Effect.orDie,
Effect.as(
Info.make({
id,
name: input.name,
directory: input.directory,
project: { id: input.projectID, directory: input.directory },
}),
),
),
(binding, exit) => (Exit.isFailure(exit) ? Effect.uninterruptible(provider.delete(binding)) : Effect.void),
)
})
const persistBinding = Effect.fnUntraced(function* (id: ID, previous: Sandbox.Binding, next: Sandbox.Binding) {
if (Equal.equals(previous, next)) return
yield* db
.update(WorkspaceTable)
.set({ extra: Sandbox.Placement.make({ kind: "sandbox", version: 1, binding: next }) })
.where(eq(WorkspaceTable.id, id))
.run()
.pipe(Effect.orDie)
})
const placement = Effect.fnUntraced(function* (id: ID) {
const value = yield* row(id)
if (!Schema.is(Sandbox.Placement)(value.extra)) {
return yield* new InvalidError({ id, message: "Workspace has no sandbox binding" })
}
const provider = yield* registry.get(value.type)
return { provider, binding: yield* provider.decode(value.extra.binding) }
})
const reconcile = Effect.fnUntraced(function* (id: ID, provider: Sandbox.Provider, binding: Sandbox.Binding) {
const next = yield* provider.reconcile(binding)
yield* persistBinding(id, binding, next)
return next
})
const connections = yield* RcMap.make({
idleTimeToLive: "1 minute",
lookup: Effect.fn("Workspace.connect")(function* (id: ID) {
const current = yield* placement(id)
const provider = current.provider
const binding = current.binding
const connection = yield* provider.connect(binding)
yield* persistBinding(id, binding, connection.binding)
yield* reconcile(id, provider, connection.binding)
return { provider, connection }
}),
})
const lease = (id: ID) => RcMap.get(lifecycle, id).pipe(Effect.flatMap((gate) => gate.read))
const borrow = (id: ID) =>
lease(id).pipe(
Effect.andThen(RcMap.get(connections, id)),
Effect.onExit((exit) => (Exit.isFailure(exit) ? RcMap.invalidate(connections, id) : Effect.void)),
Effect.map((value) => value.connection.environment),
)
const transition = <A, E, R>(id: ID, effect: Effect.Effect<A, E, R>) =>
Effect.scoped(RcMap.get(lifecycle, id).pipe(Effect.flatMap((gate) => gate.write(effect))))
const suspend = Effect.fn("Workspace.suspend")((id: ID) =>
transition(
id,
Effect.scoped(
RcMap.get(connections, id).pipe(
Effect.flatMap((value) =>
Effect.gen(function* () {
const binding = yield* Effect.uninterruptible(
value.provider
.suspend(value.connection)
.pipe(Effect.tap((binding) => persistBinding(id, value.connection.binding, binding))),
)
yield* reconcile(id, value.provider, binding)
}),
),
),
).pipe(Effect.ensuring(RcMap.invalidate(connections, id))),
),
)
const remove = Effect.fn("Workspace.remove")((id: ID) =>
transition(
id,
Effect.gen(function* () {
yield* RcMap.invalidate(connections, id)
const current = yield* placement(id)
const binding = yield* reconcile(id, current.provider, current.binding)
yield* Effect.uninterruptible(
current.provider
.delete(binding)
.pipe(
Effect.andThen(db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run().pipe(Effect.orDie)),
),
)
}),
),
)
return Service.of({
create,
get,
connect: (id) => Effect.scoped(borrow(id)).pipe(Effect.asVoid),
borrow,
suspend,
remove,
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [Database.node, Sandbox.registryNode],
})
function makeLifecycleGate() {
const admission = Semaphore.makeUnsafe(1)
const transition = Semaphore.makeUnsafe(1)
const open = Latch.makeUnsafe(true)
const drained = Latch.makeUnsafe(true)
let active = 0
let blocked = false
const read: Effect.Effect<void, never, Scope.Scope> = Effect.suspend(() =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
yield* restore(open.await)
yield* restore(admission.take(1))
if (blocked) {
yield* admission.release(1)
return yield* restore(read)
}
active++
if (active === 1) drained.closeUnsafe()
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(
scope,
Effect.sync(() => {
active--
if (active === 0) drained.openUnsafe()
}),
)
yield* admission.release(1)
}),
),
)
const write = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
transition.withPermit(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
yield* restore(admission.take(1))
blocked = true
open.closeUnsafe()
yield* admission.release(1)
return yield* restore(drained.await.pipe(Effect.andThen(effect))).pipe(
Effect.ensuring(
Effect.sync(() => {
blocked = false
}).pipe(Effect.andThen(open.open)),
),
)
}),
),
)
return { read, write }
}
+271
View File
@@ -0,0 +1,271 @@
export * as WorkspaceEnvironment from "./environment"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import { PlatformError, systemError } from "effect/PlatformError"
import { ChildProcessSpawner, make } from "effect/unstable/process/ChildProcessSpawner"
import { AppProcess } from "../process"
import { makeLocationNode, tags } from "../effect/app-node"
import { LayerNode } from "../effect/layer-node"
import { FSUtil } from "../fs-util"
import { KeyedMutex } from "../effect/keyed-mutex"
import { Location } from "../location"
import { RipgrepBinary } from "../ripgrep/binary"
import { ShellSelect } from "../shell/select"
import { WorkspaceV2 } from "../workspace"
import path from "path"
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceEnvironment.Error", {
operation: Schema.String,
path: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {}
export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()(
"WorkspaceEnvironment.StaleContentError",
{ path: Schema.String },
) {}
export interface FileInfo {
readonly type: FileSystem.File.Type
}
export interface ResolvedPath extends FileInfo {
readonly canonical: string
readonly directory: string
}
export interface DirectoryEntry {
readonly name: string
readonly type: "file" | "directory" | "symlink" | "other"
}
export interface FileBackend {
readonly inspect: (path: string) => Effect.Effect<FileInfo, Error>
readonly resolve: (path: string) => Effect.Effect<ResolvedPath, Error>
readonly read: (path: string) => Effect.Effect<Uint8Array, Error>
readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], Error>
readonly ensureDirectory: (path: string) => Effect.Effect<void, Error>
readonly createExclusive: (path: string, content: Uint8Array) => Effect.Effect<void, Error>
readonly write: (path: string, content: Uint8Array) => Effect.Effect<void, Error>
readonly writeIfUnchanged: (
path: string,
expected: Uint8Array,
content: Uint8Array,
) => Effect.Effect<void, Error | StaleContentError>
readonly remove: (path: string) => Effect.Effect<void, Error>
}
export interface Shell {
readonly executable: string
readonly args: (command: string) => readonly string[]
readonly environmentOverrides: Readonly<Record<string, string>>
readonly detached: boolean
}
export interface Interface {
readonly platform: NodeJS.Platform
readonly directory: string
readonly files: FileBackend
readonly process: ChildProcessSpawner["Service"]
readonly shell: Shell
readonly ripgrep: Effect.Effect<string, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/WorkspaceEnvironment") {}
export const node = LayerNode.unbound(Service, tags.values.location)
const wrap = <A>(operation: string, path: string | undefined, effect: Effect.Effect<A, unknown>) =>
effect.pipe(Effect.mapError((cause) => new Error({ operation, path, cause })))
const sameBytes = (left: Uint8Array, right: Uint8Array) =>
left.length === right.length && left.every((byte, index) => byte === right[index])
const local = Effect.fnUntraced(function* (directory: string) {
const fs = yield* FSUtil.Service
const proc = yield* AppProcess.Service
const ripgrep = yield* RipgrepBinary.Service
const locks = KeyedMutex.makeUnsafe<string>()
const mutate = (path: string, effect: Effect.Effect<void, unknown>) =>
locks.withLock(path)(Effect.uninterruptible(effect))
const notFound = <A>(effect: Effect.Effect<A, FSUtil.Error>) =>
effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
const resolve = Effect.fn("WorkspaceEnvironment.resolve")(function* (absolute: string) {
const existing = yield* notFound(fs.realPath(absolute))
if (existing) {
const info = yield* fs.stat(existing)
return {
canonical: existing,
directory: info.type === "Directory" ? existing : path.dirname(existing),
type: info.type,
}
}
let anchor = path.dirname(absolute)
while (true) {
const canonical = yield* notFound(fs.realPath(anchor))
if (canonical) {
const info = yield* fs.stat(canonical)
if (info.type !== "Directory") {
return yield* new Error({ operation: "resolve", path: absolute, cause: "Non-directory ancestor" })
}
return {
canonical: path.resolve(canonical, path.relative(anchor, absolute)),
directory: canonical,
type: "Unknown" as const,
}
}
const parent = path.dirname(anchor)
if (parent === anchor) return yield* new Error({ operation: "resolve", path: absolute, cause: "No ancestor" })
anchor = parent
}
})
const files: FileBackend = {
inspect: (path) => wrap("inspect", path, fs.stat(path)),
resolve: (path) =>
resolve(path).pipe(
Effect.mapError((cause) => (cause instanceof Error ? cause : new Error({ operation: "resolve", path, cause }))),
),
read: (path) => wrap("read", path, fs.readFile(path)),
list: (path) => wrap("list", path, fs.readDirectoryEntries(path)),
ensureDirectory: (path) => wrap("ensureDirectory", path, fs.ensureDir(path)),
createExclusive: (path, content) =>
wrap("createExclusive", path, mutate(path, fs.writeFile(path, content, { flag: "wx" }))),
write: (path, content) => wrap("write", path, mutate(path, fs.writeFile(path, content))),
writeIfUnchanged: (path, expected, content) =>
mutate(
path,
Effect.gen(function* () {
const current = yield* fs.readFile(path)
if (!sameBytes(current, expected)) return yield* new StaleContentError({ path })
yield* fs.writeFile(path, content)
}),
).pipe(
Effect.mapError((cause) =>
cause instanceof StaleContentError ? cause : new Error({ operation: "writeIfUnchanged", path, cause }),
),
),
remove: (path) => wrap("remove", path, mutate(path, fs.remove(path))),
}
const executable = ShellSelect.preferred() ?? "/bin/sh"
return Service.of({
platform: process.platform,
directory,
files,
process: proc,
shell: {
executable,
args: (command) => ShellSelect.args(executable, command),
environmentOverrides: {
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
detached: process.platform !== "win32",
},
ripgrep: ripgrep.filepath.pipe(Effect.mapError((cause) => new Error({ operation: "ripgrep", cause }))),
})
})
const borrow = <A, E>(
workspace: WorkspaceV2.Interface,
id: WorkspaceV2.ID,
use: (environment: Interface) => Effect.Effect<A, E>,
) =>
Effect.scoped(
workspace.borrow(id).pipe(
Effect.mapError((cause) => new Error({ operation: "connect", cause })),
Effect.flatMap(use),
),
)
const contains = (root: string, target: string) => {
const relative = path.posix.relative(root, target)
return relative !== ".." && !relative.startsWith("../") && !path.posix.isAbsolute(relative)
}
const localLayer = (ref: Location.Ref) => Layer.effect(Service, local(ref.directory))
const hostedLayer = (ref: Location.Ref & { readonly workspaceID: WorkspaceV2.ID }) =>
Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const workspace = yield* WorkspaceV2.Service
const id = ref.workspaceID
const useFile = <A, E>(
target: string,
use: (files: FileBackend, resolved: ResolvedPath) => Effect.Effect<A, E>,
) =>
borrow(workspace, id, (environment) =>
Effect.gen(function* () {
const absolute = path.posix.resolve(location.directory, target)
const [root, resolved] = yield* Effect.all([
environment.files.resolve(location.project.directory),
environment.files.resolve(absolute),
])
if (!contains(root.canonical, resolved.canonical)) {
return yield* new Error({ operation: "containment", path: target })
}
return yield* use(environment.files, resolved)
}),
)
const files: FileBackend = {
inspect: (path) => useFile(path, (files, resolved) => files.inspect(resolved.canonical)),
resolve: (path) => useFile(path, (_files, resolved) => Effect.succeed(resolved)),
read: (path) => useFile(path, (files, resolved) => files.read(resolved.canonical)),
list: (path) => useFile(path, (files, resolved) => files.list(resolved.canonical)),
ensureDirectory: (path) => useFile(path, (files, resolved) => files.ensureDirectory(resolved.canonical)),
createExclusive: (path, content) =>
useFile(path, (files, resolved) => files.createExclusive(resolved.canonical, content)),
write: (path, content) => useFile(path, (files, resolved) => files.write(resolved.canonical, content)),
writeIfUnchanged: (path, expected, content) =>
useFile(path, (files, resolved) => files.writeIfUnchanged(resolved.canonical, expected, content)),
remove: (path) => useFile(path, (files, resolved) => files.remove(resolved.canonical)),
}
return Service.of({
platform: "linux",
directory: location.directory,
files,
process: make((command) =>
workspace.borrow(id).pipe(
Effect.flatMap((environment) => environment.process.spawn(command)),
Effect.mapError((cause) =>
cause instanceof PlatformError
? cause
: systemError({
_tag: "Unknown",
module: "WorkspaceEnvironment",
method: "spawn",
cause,
}),
),
),
),
shell: {
executable: "/bin/sh",
args: (command) => ["-c", command],
environmentOverrides: {
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
detached: false,
},
ripgrep: borrow(workspace, id, (environment) => environment.ripgrep),
})
}),
)
export const boundNode = (ref: Location.Ref) => {
if (ref.workspaceID) {
return makeLocationNode({
service: Service,
layer: hostedLayer({ ...ref, workspaceID: ref.workspaceID }),
deps: [Location.node, WorkspaceV2.node],
})
}
return makeLocationNode({
service: Service,
layer: localLayer(ref),
deps: [FSUtil.node, AppProcess.node, RipgrepBinary.node],
})
}
+77
View File
@@ -0,0 +1,77 @@
export * as Sandbox from "./sandbox"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { makeGlobalNode } from "../effect/app-node"
import type { WorkspaceEnvironment } from "./environment"
export const Binding = Schema.Record(Schema.String, Schema.Json).annotate({ identifier: "Sandbox.Binding" })
export type Binding = typeof Binding.Type
export const Placement = Schema.Struct({
kind: Schema.Literal("sandbox"),
version: Schema.Literal(1),
binding: Binding,
}).annotate({ identifier: "Sandbox.Placement" })
export type Placement = typeof Placement.Type
export class Error extends Schema.TaggedErrorClass<Error>()("Sandbox.Error", {
provider: Schema.String,
operation: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export interface Connection {
readonly binding: Binding
readonly environment: WorkspaceEnvironment.Interface
}
export interface CreateInput {
readonly identity: string
}
export interface Provider {
readonly key: string
readonly create: (input: CreateInput) => Effect.Effect<Binding, Error>
readonly decode: (binding: Binding) => Effect.Effect<Binding, Error>
readonly connect: (binding: Binding) => Effect.Effect<Connection, Error, Scope.Scope>
readonly suspend: (connection: Connection) => Effect.Effect<Binding, Error>
readonly reconcile: (binding: Binding) => Effect.Effect<Binding, Error>
readonly delete: (binding: Binding) => Effect.Effect<void, Error>
}
export class DuplicateProviderError extends Schema.TaggedErrorClass<DuplicateProviderError>()(
"Sandbox.DuplicateProviderError",
{ provider: Schema.String },
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"Sandbox.ProviderNotFoundError",
{ provider: Schema.String },
) {}
export interface Registry {
readonly register: (provider: Provider) => Effect.Effect<void, DuplicateProviderError, Scope.Scope>
readonly get: (key: string) => Effect.Effect<Provider, ProviderNotFoundError>
}
export class RegistryService extends Context.Service<RegistryService, Registry>()("@opencode/SandboxRegistry") {}
export const registryLayer = Layer.sync(RegistryService, () => {
const providers = new Map<string, Provider>()
return RegistryService.of({
register: (provider) =>
Effect.acquireRelease(
Effect.sync(() => {
if (providers.has(provider.key)) return new DuplicateProviderError({ provider: provider.key })
providers.set(provider.key, provider)
}).pipe(Effect.flatMap((error) => (error ? Effect.fail(error) : Effect.void))),
() => Effect.sync(() => providers.delete(provider.key)),
),
get: (key) => {
const provider = providers.get(key)
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ provider: key }))
},
})
})
export const registryNode = makeGlobalNode({ service: RegistryService, layer: registryLayer, deps: [] })
+126 -3
View File
@@ -1,14 +1,20 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import fs from "fs/promises"
import path from "path"
import { Effect, Exit, Layer } from "effect"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const workspaceID = WorkspaceV2.ID.make("wrk_test")
const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID }
const ref = { directory: AbsolutePath.make("/repo/packages/app") }
const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
@@ -31,7 +37,7 @@ describe("Location", () => {
const location = yield* Location.Service
expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app"))
expect(location.workspaceID).toBe(workspaceID)
expect(location.workspaceID).toBeUndefined()
expect(location.project.id).toBe(Project.ID.make("project"))
expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
expect(location.vcs).toEqual({
@@ -40,4 +46,121 @@ describe("Location", () => {
})
}),
)
it.live("resolves hosted metadata without reading the host path", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const directory = AbsolutePath.make(path.join(tmp.path, "hosted-checkout"))
const connections = { count: 0 }
const reads = { count: 0 }
const unsupported = () => Effect.die("Unsupported fake environment operation")
const providerEnvironment = WorkspaceEnvironment.Service.of({
platform: "linux",
directory,
process: make(() => unsupported()),
shell: {
executable: "/bin/sh",
args: (command) => ["-c", command],
environmentOverrides: {},
detached: false,
},
ripgrep: Effect.succeed("/usr/bin/rg"),
files: {
resolve: (target) =>
Effect.succeed({
canonical: target.includes("symlink") ? "/outside/secret" : target,
directory: path.posix.dirname(target),
type: "File",
}),
inspect: unsupported,
read: () =>
Effect.sync(() => {
reads.count++
return new Uint8Array([1])
}),
list: unsupported,
ensureDirectory: unsupported,
createExclusive: unsupported,
write: unsupported,
writeIfUnchanged: unsupported,
remove: unsupported,
},
})
const workspaceLayer = Layer.succeed(
WorkspaceV2.Service,
WorkspaceV2.Service.of({
create: () => Effect.die("Unsupported fake Workspace operation"),
get: () =>
Effect.succeed(
WorkspaceV2.Info.make({
id: workspaceID,
name: "Hosted",
directory,
project: {
id: Project.ID.make("hosted-project"),
directory,
},
}),
),
borrow: () =>
Effect.sync(() => {
connections.count++
return providerEnvironment
}),
connect: () => Effect.die("Unsupported fake Workspace operation"),
suspend: () => Effect.die("Unsupported fake Workspace operation"),
remove: () => Effect.die("Unsupported fake Workspace operation"),
}),
)
const hostedRef = { directory, workspaceID }
const layer = AppNodeBuilder.build(LayerNode.group([Location.node, WorkspaceEnvironment.node]), [
[Location.node, Location.boundNode(hostedRef)],
[WorkspaceEnvironment.node, WorkspaceEnvironment.boundNode(hostedRef)],
[WorkspaceV2.node, workspaceLayer],
])
const invalidLayer = AppNodeBuilder.build(
Location.boundNode({ directory: AbsolutePath.make(path.join(tmp.path, "outside")), workspaceID }),
[[WorkspaceV2.node, workspaceLayer]],
)
return Effect.gen(function* () {
expect(
yield* Effect.promise(() =>
fs.stat(directory).then(
() => true,
() => false,
),
),
).toBe(false)
const location = yield* Location.Service
const environment = yield* WorkspaceEnvironment.Service
expect(location.directory).toBe(directory)
expect(location.workspaceID).toBe(workspaceID)
expect(location.project).toEqual({
id: Project.ID.make("hosted-project"),
directory,
})
expect(environment.directory).toBe(directory)
expect(environment.platform).toBe("linux")
expect(connections.count).toBe(0)
expect(yield* environment.files.read(path.posix.join(directory, "file.txt"))).toEqual(new Uint8Array([1]))
expect(connections.count).toBe(1)
expect(reads.count).toBe(1)
const outsideFile = yield* environment.files.read("/outside/secret").pipe(Effect.flip)
expect(outsideFile.operation).toBe("containment")
const symlink = yield* environment.files.read(path.posix.join(directory, "symlink")).pipe(Effect.flip)
expect(symlink.operation).toBe("containment")
expect(reads.count).toBe(1)
const invalid = yield* Location.Service.pipe(Effect.provide(invalidLayer), Effect.exit)
expect(Exit.isFailure(invalid)).toBe(true)
}).pipe(Effect.provide(layer))
}),
),
)
})
+275
View File
@@ -0,0 +1,275 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { adjust } from "effect/testing/TestClock"
import { eq } from "drizzle-orm"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Database } from "@opencode-ai/core/database/database"
import { AppProcess } from "@opencode-ai/core/process"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { Sandbox } from "@opencode-ai/core/workspace/sandbox"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Sandbox.registryNode, WorkspaceV2.node, AppProcess.node])),
)
describe("WorkspaceV2", () => {
it.effect("loads metadata without connecting and shares a scoped connection", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const process = yield* AppProcess.Service
const registry = yield* Sandbox.RegistryService
const workspace = yield* WorkspaceV2.Service
const id = WorkspaceV2.ID.make("wrk_hosted")
const projectID = Project.ID.make("hosted-project")
const directory = AbsolutePath.make("/workspace/repo")
const lifecycle = {
created: [] as string[],
connected: [] as Sandbox.Binding[],
reconciled: 0,
released: 0,
suspended: 0,
deleted: [] as Sandbox.Binding[],
failDelete: false,
}
const suspendStarted = yield* Deferred.make<void>()
const finishSuspend = yield* Deferred.make<void>()
const unsupported = (operation: string) => Effect.fail(new WorkspaceEnvironment.Error({ operation }))
const environment = WorkspaceEnvironment.Service.of({
platform: "linux",
directory,
process,
shell: {
executable: "/bin/sh",
args: (command) => ["-c", command],
environmentOverrides: {},
detached: false,
},
ripgrep: Effect.succeed("/usr/bin/rg"),
files: {
inspect: () => unsupported("inspect"),
resolve: () => unsupported("resolve"),
read: () => unsupported("read"),
list: () => unsupported("list"),
ensureDirectory: () => unsupported("ensureDirectory"),
createExclusive: () => unsupported("createExclusive"),
write: () => unsupported("write"),
writeIfUnchanged: () => unsupported("writeIfUnchanged"),
remove: () => unsupported("remove"),
},
})
yield* db
.insert(ProjectTable)
.values({
id: projectID,
worktree: directory,
sandboxes: [],
time_created: 1,
time_updated: 1,
})
.run()
yield* db
.insert(WorkspaceTable)
.values({
id,
type: "fake",
name: "Hosted",
directory,
extra: { kind: "sandbox", version: 1, binding: { sandbox: "one" } },
project_id: projectID,
time_used: 1,
})
.run()
yield* registry.register({
key: "fake",
create: (input) =>
Effect.sync(() => {
lifecycle.created.push(input.identity)
return { sandbox: input.identity }
}),
decode: Effect.succeed,
connect: (binding) =>
Effect.acquireRelease(
Effect.sync(() => {
lifecycle.connected.push(binding)
return { binding: { sandbox: "live", retired: "one" }, environment }
}),
() => Effect.sync(() => lifecycle.released++),
),
suspend: () =>
Deferred.succeed(suspendStarted, undefined).pipe(
Effect.andThen(Deferred.await(finishSuspend)),
Effect.tap(() => Effect.sync(() => lifecycle.suspended++)),
Effect.as({ snapshot: "snap-one", retired: "live" }),
),
reconcile: (binding) =>
Effect.sync(() => {
lifecycle.reconciled++
const next: Sandbox.Binding = "snapshot" in binding ? { snapshot: binding.snapshot } : { sandbox: "live" }
return next
}),
delete: (binding) => {
if (lifecycle.failDelete) {
return Effect.fail(new Sandbox.Error({ provider: "fake", operation: "delete" }))
}
return Effect.sync(() => lifecycle.deleted.push(binding))
},
})
expect(yield* workspace.get(id)).toEqual({
id,
name: "Hosted",
directory,
project: { id: projectID, directory },
})
expect(lifecycle.connected).toHaveLength(0)
const borrowed = yield* Effect.all([workspace.borrow(id), workspace.borrow(id)]).pipe(Effect.scoped)
expect(borrowed[0]).toBe(environment)
expect(borrowed[1]).toBe(environment)
expect(lifecycle.connected).toHaveLength(1)
expect(lifecycle.reconciled).toBe(1)
expect(lifecycle.released).toBe(0)
const placement = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()
expect(placement?.extra).toEqual({
kind: "sandbox",
version: 1,
binding: { sandbox: "live" },
})
yield* adjust("1 minute")
yield* Effect.yieldNow
expect(lifecycle.released).toBe(1)
const invalidID = WorkspaceV2.ID.make("wrk_invalid")
yield* db
.insert(WorkspaceTable)
.values({
id: invalidID,
type: "fake",
name: "Legacy",
directory,
extra: { sandbox: "legacy-adapter-state" },
project_id: projectID,
time_used: 1,
})
.run()
const invalid = yield* workspace.borrow(invalidID).pipe(Effect.scoped, Effect.flip)
expect(invalid._tag).toBe("Workspace.InvalidError")
expect(lifecycle.connected).toHaveLength(1)
const retryID = WorkspaceV2.ID.make("wrk_retry")
const retry = { attempts: 0 }
yield* db
.insert(WorkspaceTable)
.values({
id: retryID,
type: "flaky",
name: "Retry",
directory,
extra: { kind: "sandbox", version: 1, binding: { sandbox: "retry" } },
project_id: projectID,
time_used: 1,
})
.run()
yield* registry.register({
key: "flaky",
create: () => Effect.die("Unexpected create"),
decode: Effect.succeed,
connect: (binding) =>
Effect.sync(() => ++retry.attempts).pipe(
Effect.flatMap((attempt) =>
attempt === 1 ? Effect.die("Transient provider defect") : Effect.succeed({ binding, environment }),
),
),
suspend: () => Effect.die("Unexpected suspend"),
reconcile: Effect.succeed,
delete: () => Effect.die("Unexpected delete"),
})
expect(Exit.isFailure(yield* workspace.borrow(retryID).pipe(Effect.scoped, Effect.exit))).toBe(true)
expect(yield* workspace.borrow(retryID).pipe(Effect.scoped)).toBe(environment)
expect(retry.attempts).toBe(2)
const created = yield* workspace.create({
provider: "fake",
name: "Created",
directory,
projectID,
})
expect(created).toEqual({
id: created.id,
name: "Created",
directory,
project: { id: projectID, directory },
})
expect(lifecycle.created.at(-1)).toBe(created.id)
const failedCreate = yield* workspace
.create({
provider: "fake",
name: "Invalid project",
directory,
projectID: Project.ID.make("missing-project"),
})
.pipe(Effect.exit)
expect(Exit.isFailure(failedCreate)).toBe(true)
const failedIdentity = lifecycle.created.at(-1)
if (!failedIdentity) return yield* Effect.die("Missing failed create identity")
expect(lifecycle.deleted).toContainEqual({ sandbox: failedIdentity })
const active = yield* Scope.make()
yield* workspace.borrow(id).pipe(Scope.provide(active))
const suspendInvoked = yield* Deferred.make<void>()
const suspendFiber = yield* Deferred.succeed(suspendInvoked, undefined).pipe(
Effect.andThen(workspace.suspend(id)),
Effect.forkChild,
)
yield* Deferred.await(suspendInvoked)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(suspendStarted)).toBe(false)
const admitted = yield* Deferred.make<void>()
const borrowFiber = yield* workspace.borrow(id).pipe(
Effect.tap(() => Deferred.succeed(admitted, undefined)),
Effect.scoped,
Effect.forkChild,
)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(admitted)).toBe(false)
yield* Scope.close(active, Exit.void)
yield* Deferred.await(suspendStarted)
expect(yield* Deferred.isDone(admitted)).toBe(false)
yield* Deferred.succeed(finishSuspend, undefined)
yield* Fiber.join(suspendFiber)
yield* Fiber.join(borrowFiber)
expect(lifecycle.suspended).toBe(1)
expect(lifecycle.connected).toContainEqual({ snapshot: "snap-one" })
expect((yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())?.extra).toEqual({
kind: "sandbox",
version: 1,
binding: { sandbox: "live" },
})
lifecycle.failDelete = true
const deleteError = yield* workspace.remove(created.id).pipe(Effect.flip)
expect(deleteError._tag).toBe("Sandbox.Error")
if (deleteError._tag !== "Sandbox.Error") return yield* Effect.die("Unexpected delete error")
expect(deleteError.operation).toBe("delete")
expect((yield* workspace.get(created.id)).id).toBe(created.id)
lifecycle.failDelete = false
yield* workspace.remove(created.id)
expect((yield* workspace.get(created.id).pipe(Effect.flip))._tag).toBe("Workspace.NotFoundError")
expect(lifecycle.deleted).toContainEqual({ sandbox: "live" })
}),
)
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+2 -1
View File
@@ -382,7 +382,8 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
function App() {
const wslServers = useWslServers()
const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
() =>
!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading && !wslServers.isLoading,
)
const servers = createMemo(() => {
const data = initializationData(sidecar)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.2",
"version": "1.18.3",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.2",
"version": "1.18.3",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.18.2",
"version": "1.18.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.18.2",
"version": "1.18.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.2",
"version": "1.18.3",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.2",
"version": "1.18.3",
"name": "opencode",
"type": "module",
"license": "MIT",
+4 -2
View File
@@ -277,7 +277,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
},
}
}),
"azure-cognitive-services": Effect.fnUntraced(function* () {
"azure-cognitive-services": Effect.fnUntraced(function* (provider: Info) {
const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
return {
autoload: false,
@@ -285,7 +285,9 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
},
options: {
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
baseURL: resourceName
? `https://${resourceName}.cognitiveservices.azure.com/openai${provider.options?.useDeploymentBasedUrls ? "" : "/v1"}`
: undefined,
},
}
}),
@@ -1,11 +1,10 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import {
RUN_COMMAND_PANEL_ROWS,
RUN_SUBAGENT_PANEL_ROWS,
@@ -174,15 +173,9 @@ async function renderFooter(
)
const state = footerState(input.state)
const config = input.tuiConfig ?? tuiConfig
let offKeymap: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
return (
<OpencodeKeymapProvider keymap={keymap}>
<Keymap.Provider config={config}>
<RunFooterView
directory="/tmp"
findFiles={async () => []}
@@ -215,7 +208,7 @@ async function renderFooter(
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</OpencodeKeymapProvider>
</Keymap.Provider>
)
}
@@ -233,8 +226,6 @@ async function renderFooter(
cleanup() {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
offKeymap = undefined
app.renderer.destroy()
},
}
@@ -641,6 +632,42 @@ test("direct subagent panel renders active subagents", async () => {
}
})
test("direct subagent panel closes when moving up from the first item", async () => {
const [tabs] = createSignal([
subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" }),
subagent({ sessionID: "s-2", label: "General", description: "Write migration plan" }),
])
const [current] = createSignal<string | undefined>()
let closed = 0
const app = await testRender(
() => (
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
<RunSubagentSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
tabs={tabs}
current={current}
onClose={() => closed++}
onSelect={() => {}}
/>
</box>
),
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
)
try {
await app.renderOnce()
app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_UP")
expect(closed).toBe(0)
app.mockInput.pressKey("ARROW_UP")
expect(closed).toBe(1)
} finally {
app.renderer.destroy()
}
})
test("direct queued prompt panel renders pending prompt actions", async () => {
const [prompts] = createSignal([
{ messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } },
@@ -967,14 +994,9 @@ test("direct footer shows editable prompts and additional queued work while runn
permissions: [],
questions: [],
})
let offKeymap: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
return (
<OpencodeKeymapProvider keymap={keymap}>
<Keymap.Provider config={tuiConfig}>
<RunFooterView
directory="/tmp"
findFiles={async () => []}
@@ -1013,7 +1035,7 @@ test("direct footer shows editable prompts and additional queued work while runn
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</OpencodeKeymapProvider>
</Keymap.Provider>
)
}
@@ -1049,7 +1071,7 @@ test("direct footer shows editable prompts and additional queued work while runn
expect(frame).toContain("3 queued")
expect(frame).toContain("ctrl+b background")
expect(frame).toContain("ctrl+x q 3 queued")
expect(frame).toContain("ctrl+x down subagents")
expect(frame).toContain(" subagents")
expect(frame).toContain("ctrl+p cmd")
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
expect(frame).toContain("subagents · ctrl+p cmd")
@@ -1063,7 +1085,6 @@ test("direct footer shows editable prompts and additional queued work while runn
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
app.renderer.destroy()
}
})
@@ -1087,7 +1108,7 @@ test("direct footer always offers backgrounding for a foreground subagent", asyn
const frame = app.captureCharFrame()
expect(frame).toContain("GPT-5")
expect(frame).toContain("xhigh · ctrl+b background · ctrl+x down subagents · ctrl+p cmd")
expect(frame).toContain("xhigh · ctrl+b background · subagents · ctrl+p cmd")
expect(frame).toContain("ctrl+b background")
expect(frame).not.toContain("queued")
} finally {
@@ -1115,7 +1136,7 @@ test("direct footer hides the subagent hint when only completed subagents remain
expect(frame).toContain("GPT-5")
expect(frame).toContain("xhigh · ctrl+p cmd")
expect(frame).not.toContain("ctrl+x down subagents")
expect(frame).not.toContain(" subagents")
} finally {
app.cleanup()
}
@@ -1233,15 +1254,9 @@ test.skip("direct custom answer submits through keymap return binding", async ()
],
} satisfies QuestionRequest
const questions: unknown[] = []
let off: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
return (
<OpencodeKeymapProvider keymap={keymap}>
<Keymap.Provider config={tuiConfig}>
<RunQuestionBody
request={question}
theme={RUN_THEME_FALLBACK.footer}
@@ -1250,7 +1265,7 @@ test.skip("direct custom answer submits through keymap return binding", async ()
}}
onReject={() => {}}
/>
</OpencodeKeymapProvider>
</Keymap.Provider>
)
}
@@ -1275,7 +1290,6 @@ test.skip("direct custom answer submits through keymap return binding", async ()
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
off?.()
app.renderer.destroy()
}
})
@@ -1283,15 +1297,9 @@ test.skip("direct custom answer submits through keymap return binding", async ()
test("direct permission rejection submits through keymap return binding", async () => {
let text = ""
const submits: string[] = []
let off: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
return (
<OpencodeKeymapProvider keymap={keymap}>
<Keymap.Provider config={tuiConfig}>
<RejectField
theme={RUN_THEME_FALLBACK.footer}
text=""
@@ -1304,7 +1312,7 @@ test("direct permission rejection submits through keymap return binding", async
}}
onCancel={() => {}}
/>
</OpencodeKeymapProvider>
</Keymap.Provider>
)
}
@@ -1328,7 +1336,6 @@ test("direct permission rejection submits through keymap return binding", async
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
off?.()
app.renderer.destroy()
}
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"scripts": {
+3 -3
View File
@@ -19,7 +19,7 @@ import type {
ShellInfo,
SkillInfo,
} from "@opencode-ai/client"
import type { Renderable } from "@opentui/core"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { JSX } from "@opentui/solid"
interface LocationCollection<Value> {
@@ -139,8 +139,8 @@ export interface KeymapCommand {
}
/** Promotes the command in discovery UI. */
readonly suggested?: boolean | (() => boolean)
/** Executes the command. Return false to let keymap dispatch continue. */
readonly run: (input?: string) => void | false | Promise<void>
/** Executes the command. Keyboard dispatch includes its event; programmatic dispatch does not. Return false to continue. */
readonly run: (input?: string, event?: KeyEvent) => void | false | Promise<void>
}
export interface KeymapLayer {
+11
View File
@@ -2,8 +2,19 @@ export * as Workspace from "./workspace.js"
import { WorkspaceEvent } from "./workspace-event.js"
import { WorkspaceID } from "./workspace-id.js"
import { Project } from "./project.js"
import { AbsolutePath } from "./schema.js"
import { Schema } from "effect"
export const ID = WorkspaceID
export type ID = WorkspaceID
export const Info = Schema.Struct({
id: ID,
name: Schema.String,
directory: AbsolutePath,
project: Project.Current,
}).annotate({ identifier: "Workspace.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Event = WorkspaceEvent
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.18.2",
"version": "1.18.3",
"private": true,
"type": "module",
"license": "MIT",
+5 -1
View File
@@ -10,6 +10,7 @@ import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerRe
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { authorizedRequest } from "./middleware/authorization"
import { withoutParentSpan } from "./request-tracing"
import { createRoutes } from "./routes"
import { ServerInfo } from "./server-info"
import { Status } from "./service-status"
@@ -39,7 +40,10 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
})
const bound = yield* listen(options)
const application = yield* Ref.make(Option.none<App>())
yield* bound.http.serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger)
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
yield* bound.http
.serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger)
.pipe(withoutParentSpan)
if (options.service) yield* options.service.onListen(bound.http.address)
const parentScope = yield* Scope.Scope
+6
View File
@@ -0,0 +1,6 @@
import { Context, Effect, Scope, Tracer } from "effect"
export const withoutParentSpan = <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) =>
effect.pipe(
Effect.updateContext((context: Context.Context<Scope.Scope>) => Context.omit(Tracer.ParentSpan)(context)),
)
@@ -0,0 +1,48 @@
import { expect, test } from "bun:test"
import { Effect, Option, Scope, Tracer } from "effect"
import { HttpMiddleware, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { withoutParentSpan } from "../src/request-tracing"
test("requests ignore ambient parents and continue inbound trace context", async () => {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const app = Effect.gen(function* () {
yield* Scope.Scope
return yield* HttpMiddleware.tracer(Effect.succeed(HttpServerResponse.empty()))
})
const request = (traceparent?: string) =>
app.pipe(
Effect.provideService(
HttpServerRequest.HttpServerRequest,
HttpServerRequest.fromWeb(
new Request("http://localhost/trace", {
headers: traceparent === undefined ? undefined : { traceparent },
}),
),
),
withoutParentSpan,
)
await Effect.runPromise(
Effect.gen(function* () {
yield* request()
yield* request()
yield* request("00-11111111111111111111111111111111-2222222222222222-01")
yield* Effect.yieldNow
}).pipe(Effect.withSpan("fixture.lifecycle"), Effect.provideService(Tracer.Tracer, tracer), Effect.scoped),
)
const requests = spans.filter((span) => span.kind === "server")
expect(requests).toHaveLength(3)
expect(requests[0]?.traceId).not.toBe(requests[1]?.traceId)
expect(Option.getOrUndefined(requests[0]?.parent ?? Option.none())).toBeUndefined()
expect(Option.getOrUndefined(requests[1]?.parent ?? Option.none())).toBeUndefined()
expect(requests[2]?.traceId).toBe("11111111111111111111111111111111")
expect(Option.getOrUndefined(requests[2]?.parent ?? Option.none())?.spanId).toBe("2222222222222222")
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.18.2",
"version": "1.18.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.18.2",
"version": "1.18.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.18.2",
"version": "1.18.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -5,7 +5,7 @@ import { Suspense } from "solid-js"
import { I18nProvider, useI18n } from "./context/i18n"
import { LanguageProvider } from "./context/language"
import { strip } from "./lib/language"
import "./app.css"
import "./routes/index.css"
function AppMeta() {
const i18n = useI18n()
@@ -1,4 +1,3 @@
import "../routes/index.css"
import { Link, Meta, Title } from "@solidjs/meta"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import {
@@ -263,9 +262,8 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp
}}
/>
</Show>
<ComparisonRadar models={models()} catalogModels={catalog()?.models ?? []} />
<div
data-component="compare-detail-table"
data-component="compare-detail-comparison"
data-model-count={models().length}
style={`--compare-detail-grid: ${comparisonDetailGridTemplate(models().length)}`}
>
@@ -276,6 +274,7 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp
>
<ComparisonPairSelector catalogModels={selectorModels()} models={models()} />
</div>
<ComparisonRadar models={models()} catalogModels={catalog()?.models ?? []} />
<div
data-component="compare-detail-body-scroll"
ref={(element) => (comparisonBodyScroll = element)}
@@ -1,4 +1,3 @@
import "../index.css"
import { Meta, Title } from "@solidjs/meta"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { geoEquirectangular, geoPath } from "d3-geo"
@@ -1,4 +1,3 @@
import "../index.css"
import { Meta, Title } from "@solidjs/meta"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import {
@@ -1,4 +1,3 @@
import "../index.css"
import { Meta, Title } from "@solidjs/meta"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { createAsync } from "@solidjs/router"
+17 -11
View File
@@ -42,6 +42,11 @@
U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02;
}
body {
min-width: 320px;
margin: 0;
}
[data-page="stats"] {
color-scheme: light;
--color-background: #ffffff;
@@ -6076,6 +6081,7 @@
height: 800px;
box-sizing: border-box;
border-right: 1px solid var(--stats-line);
border-bottom: 1px solid var(--stats-line);
border-left: 1px solid var(--stats-line);
color: var(--stats-text);
background: var(--stats-bg);
@@ -6273,7 +6279,7 @@
white-space: nowrap;
}
[data-page="stats"] [data-component="compare-detail-table"] {
[data-page="stats"] [data-component="compare-detail-comparison"] {
--compare-detail-label-column: 292px;
--compare-detail-model-column-min: 360px;
position: relative;
@@ -6330,10 +6336,10 @@
}
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="3"]
[data-component="compare-detail-comparison"][data-model-count="3"]
[data-section="compare-detail-selector"],
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="3"]
[data-component="compare-detail-comparison"][data-model-count="3"]
[data-section="compare-detail-matrix"] {
min-width: calc(
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) +
@@ -6342,10 +6348,10 @@
}
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="4"]
[data-component="compare-detail-comparison"][data-model-count="4"]
[data-section="compare-detail-selector"],
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="4"]
[data-component="compare-detail-comparison"][data-model-count="4"]
[data-section="compare-detail-matrix"] {
min-width: calc(
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) +
@@ -6355,10 +6361,10 @@
}
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="5"]
[data-component="compare-detail-comparison"][data-model-count="5"]
[data-section="compare-detail-selector"],
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="5"]
[data-component="compare-detail-comparison"][data-model-count="5"]
[data-section="compare-detail-matrix"] {
min-width: calc(
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) +
@@ -6368,10 +6374,10 @@
}
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="6"]
[data-component="compare-detail-comparison"][data-model-count="6"]
[data-section="compare-detail-selector"],
[data-page="stats"]
[data-component="compare-detail-table"][data-model-count="6"]
[data-component="compare-detail-comparison"][data-model-count="6"]
[data-section="compare-detail-matrix"] {
min-width: calc(
var(--compare-detail-label-column) + var(--compare-detail-model-column-min) +
@@ -6663,7 +6669,7 @@
aspect-ratio: 1;
}
[data-page="stats"] [data-component="compare-detail-table"] {
[data-page="stats"] [data-component="compare-detail-comparison"] {
--compare-detail-label-column: 220px;
--compare-detail-model-column-min: 320px;
}
@@ -6754,7 +6760,7 @@
margin-left: -6px;
}
[data-page="stats"] [data-component="compare-detail-table"] {
[data-page="stats"] [data-component="compare-detail-comparison"] {
--compare-detail-label-column: 188px;
--compare-detail-model-column-min: 246px;
}
-1
View File
@@ -1,4 +1,3 @@
import "./index.css"
import { Link, Meta, Title } from "@solidjs/meta"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { geoEquirectangular, geoPath } from "d3-geo"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
"version": "1.18.2",
"version": "1.18.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server",
"version": "1.18.2",
"version": "1.18.3",
"private": true,
"type": "module",
"license": "MIT",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/tui",
"version": "1.18.2",
"version": "1.18.3",
"private": true,
"type": "module",
"license": "MIT",
@@ -30,7 +30,7 @@
"./editor-zed": "./src/editor-zed.ts",
"./runtime": "./src/runtime.tsx",
"./terminal-win32": "./src/terminal-win32.ts",
"./keymap": "./src/keymap.tsx",
"./context/keymap": "./src/context/keymap.tsx",
"./prompt/content": "./src/prompt/content.ts",
"./prompt/display": "./src/prompt/display.ts",
"./plugin/runtime": "./src/plugin/runtime.tsx",
+39 -34
View File
@@ -80,8 +80,7 @@ import { Config, ConfigProvider, useConfig } from "./config"
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, OPENCODE_BASE_MODE, useBindings, useOpencodeKeymap } from "./keymap"
import { Keymap } from "./context/keymap"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
import { DialogVariant } from "./component/dialog-variant"
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
@@ -416,7 +415,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
const renderer = useRenderer()
const dialog = useDialog()
const local = useLocal()
const keymap = useOpencodeKeymap()
const keymap = Keymap.use()
const event = useEvent()
const client = useClient()
const toast = useToast()
@@ -589,7 +588,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: COMMAND_PALETTE_COMMAND,
title: "Show command palette",
category: "System",
hidden: true,
palette: undefined,
run: () => {
dialog.replace(() => <CommandPaletteDialog />)
},
@@ -621,7 +620,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
category: "Session",
hidden: true,
palette: undefined,
run: () => {
local.session.quickSwitch(i + 1)
},
@@ -641,7 +640,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "model.cycle_recent",
title: "Model cycle",
category: "Agent",
hidden: true,
palette: undefined,
run: () => {
local.model.cycle(1)
},
@@ -650,7 +649,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "model.cycle_recent_reverse",
title: "Model cycle reverse",
category: "Agent",
hidden: true,
palette: undefined,
run: () => {
local.model.cycle(-1)
},
@@ -659,7 +658,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "model.cycle_favorite",
title: "Favorite cycle",
category: "Agent",
hidden: true,
palette: undefined,
run: () => {
local.model.cycleFavorite(1)
},
@@ -668,7 +667,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "model.cycle_favorite_reverse",
title: "Favorite cycle reverse",
category: "Agent",
hidden: true,
palette: undefined,
run: () => {
local.model.cycleFavorite(-1)
},
@@ -695,7 +694,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "agent.cycle",
title: "Agent cycle",
category: "Agent",
hidden: true,
palette: undefined,
run: () => {
local.agent.move(1)
},
@@ -712,7 +711,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "variant.list",
title: "Switch model variant",
category: "Agent",
hidden: local.model.variant.list().length === 0,
palette: local.model.variant.list().length === 0 ? undefined : (true as const),
slash: { name: "variants" },
run: () => {
if (local.model.variant.list().length === 0) {
@@ -729,7 +728,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "agent.cycle.reverse",
title: "Agent cycle reverse",
category: "Agent",
hidden: true,
palette: undefined,
run: () => {
local.agent.move(-1)
},
@@ -818,7 +817,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
{
name: "theme.switch_mode",
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
hidden: true,
palette: undefined,
run: () => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
@@ -828,7 +827,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
{
name: "theme.mode.lock",
title: locked() ? "Unlock theme mode" : "Lock theme mode",
hidden: true,
palette: undefined,
run: () => {
if (locked()) unlock()
else lock()
@@ -883,7 +882,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "terminal.suspend",
title: "Suspend terminal",
category: "System",
hidden: true,
palette: undefined,
enabled: process.platform !== "win32",
run: () => {
renderer.suspend()
@@ -895,7 +894,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "terminal.title.toggle",
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
category: "System",
hidden: true,
palette: undefined,
run: () => {
const next = !terminalTitleEnabled()
if (!next) renderer.setTerminalTitle("")
@@ -911,7 +910,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "app.toggle.animations",
title: (config.data.animations ?? true) ? "Disable animations" : "Enable animations",
category: "System",
hidden: true,
palette: undefined,
run: () => {
void config
.update((draft) => {
@@ -925,7 +924,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "app.toggle.file_context",
title: (config.data.prompt?.editor ?? true) ? "Disable file context" : "Enable file context",
category: "System",
hidden: true,
palette: undefined,
run: () => {
void config
.update((draft) => {
@@ -939,7 +938,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "app.toggle.diffwrap",
title: (config.data.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
category: "System",
hidden: true,
palette: undefined,
run: () => {
void config
.update((draft) => {
@@ -956,7 +955,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "app.toggle.paste_summary",
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
category: "System",
hidden: true,
palette: undefined,
run: () => {
void config
.update((draft) => {
@@ -976,38 +975,44 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
dialog.clear()
},
},
].map((command) => ({
namespace: "palette",
...command,
})),
].map(
({ name, category, ...command }) =>
({
id: name,
group: category,
bind: false,
palette: true as const,
...command,
}) satisfies KeymapCommand,
),
)
useBindings(() => ({
Keymap.createLayer(() => ({
mode: "global",
commands: appCommands(),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: appBindingCommands.flatMap((command) => config.data.keybinds.get(command)),
Keymap.createLayer(() => ({
bindings: appBindingCommands,
}))
useBindings(() => ({
bindings: appGlobalBindingCommands.flatMap((command) => config.data.keybinds.get(command)),
Keymap.createLayer(() => ({
mode: "global",
bindings: appGlobalBindingCommands,
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: () => {
const current = promptRef.current
if (!current?.focused) return true
return current.current.text === ""
},
bindings: config.data.keybinds.get("app.exit"),
bindings: ["app.exit"],
}))
event.on("tui.command.execute", (evt, { workspace }) => {
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
keymap.dispatchCommand(evt.data.command)
keymap.dispatch(evt.data.command)
})
event.on("tui.toast.show", (evt, { workspace }) => {
@@ -1,8 +1,7 @@
import { createMemo } from "solid-js"
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { type DialogContext } from "../ui/dialog"
import { COMMAND_PALETTE_COMMAND } from "../keymap"
import { Keymap, type KeymapCommand } from "../context/keymap"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "../context/keymap"
function isSuggestedPaletteCommand(command: KeymapCommand) {
const suggested = command.suggested
@@ -20,7 +20,7 @@ export function DevToolsSidebar() {
{(group) => (
<box flexShrink={0} marginBottom={1}>
<box marginBottom={1}>
<text fg={themeV2.background.action.primary()} attributes={TextAttributes.BOLD}>
<text fg={themeV2.background.action()} attributes={TextAttributes.BOLD}>
{group.title}
</text>
</box>
@@ -18,7 +18,6 @@ import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale"
import type { PromptInfo, PromptPartRef } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency"
import { useBindings } from "../../keymap"
import { Keymap } from "../../context/keymap"
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/client"
@@ -578,48 +577,49 @@ export function Autocomplete(props: {
setStore("selected", 0)
}
useBindings(() => ({
Keymap.createLayer(() => ({
mode: "autocomplete",
target: props.input,
enabled: () => Boolean(store.visible),
commands: [
{
name: "prompt.autocomplete.prev",
id: "prompt.autocomplete.prev",
title: "Previous autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
setStore("input", "keyboard")
move(-1)
},
},
{
name: "prompt.autocomplete.next",
id: "prompt.autocomplete.next",
title: "Next autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
setStore("input", "keyboard")
move(1)
},
},
{
name: "prompt.autocomplete.hide",
id: "prompt.autocomplete.hide",
title: "Hide autocomplete",
category: "Autocomplete",
group: "Autocomplete",
run() {
hide()
},
},
{
name: "prompt.autocomplete.select",
id: "prompt.autocomplete.select",
title: "Select autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
select()
},
},
{
name: "prompt.autocomplete.complete",
id: "prompt.autocomplete.complete",
title: "Complete autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
const selected = options()[store.selected]
if (selected?.isDirectory) {
@@ -631,13 +631,6 @@ export function Autocomplete(props: {
},
},
],
bindings: [
"prompt.autocomplete.prev",
"prompt.autocomplete.next",
"prompt.autocomplete.hide",
"prompt.autocomplete.select",
"prompt.autocomplete.complete",
].flatMap((command) => config.keybinds.get(command)),
}))
function show(mode: "@" | "/") {
+183 -177
View File
@@ -6,9 +6,7 @@ import {
PasteEvent,
decodePasteBytes,
type KeyEvent,
type Renderable,
} from "@opentui/core"
import type { CommandContext } from "@opentui/keymap"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import { registerOpencodeSpinner } from "../register-spinner"
import path from "path"
@@ -46,7 +44,6 @@ import { useToast } from "../../ui/toast"
import { createFadeIn } from "../../util/signal"
import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
@@ -155,7 +152,7 @@ export function Prompt(props: PromptProps) {
let anchor: BoxRenderable
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const leader = useLeaderActive()
const leader = Keymap.useLeaderActive()
const local = useLocal()
const args = useArgs()
const paths = useTuiPaths()
@@ -183,14 +180,14 @@ export function Prompt(props: PromptProps) {
)
const history = usePromptHistory()
const stash = usePromptStash()
const keymap = useOpencodeKeymap()
const agentShortcut = useCommandShortcut("agent.cycle")
const paletteShortcut = useCommandShortcut("command.palette.show")
const liveWorkShortcut = useCommandShortcut("session.child.first")
const keymap = Keymap.use()
const agentShortcut = Keymap.useShortcut("agent.cycle")
const paletteShortcut = Keymap.useShortcut("command.palette.show")
const liveWorkShortcut = Keymap.useShortcut("session.child.first")
const renderer = useRenderer()
const exit = useExit()
const dimensions = useTerminalDimensions()
const { themeV2, syntax } = useTheme()
const { themeV2, syntax, mode } = useTheme()
const animationsEnabled = createMemo(() => config.animations ?? true)
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
@@ -387,7 +384,7 @@ export function Prompt(props: PromptProps) {
title: "Clear prompt",
name: "prompt.clear",
category: "Prompt",
hidden: true,
palette: undefined,
run: () => {
clearPrompt()
dialog.clear()
@@ -397,8 +394,10 @@ export function Prompt(props: PromptProps) {
title: "Submit prompt",
name: "prompt.submit",
category: "Prompt",
hidden: true,
run: async () => {
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
if (!input.focused) return
const handled = await submit()
if (!handled) return
@@ -420,10 +419,10 @@ export function Prompt(props: PromptProps) {
title: "Paste",
name: "prompt.paste",
category: "Prompt",
hidden: true,
run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
ctx.event.preventDefault()
ctx.event.stopPropagation()
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
const content = await clipboard.read?.()
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
@@ -441,7 +440,7 @@ export function Prompt(props: PromptProps) {
title: "Interrupt session",
name: "session.interrupt",
category: "Session",
hidden: true,
palette: undefined,
enabled: status() === "running",
run: () => {
if (auto()?.visible) return
@@ -472,7 +471,7 @@ export function Prompt(props: PromptProps) {
title: "Background blocking tools",
name: "session.background",
category: "Session",
hidden: true,
palette: undefined,
enabled: status() === "running",
run: () => {
if (auto()?.visible) return
@@ -564,18 +563,24 @@ export function Prompt(props: PromptProps) {
move.open()
},
},
].map((entry) => ({
namespace: "palette",
...entry,
})),
].map(
({ name, category, ...command }) =>
({
id: name,
group: category,
bind: false,
palette: true as const,
...command,
}) satisfies KeymapCommand,
),
)
useBindings(() => ({
Keymap.createLayer(() => ({
mode: "global",
commands: promptCommands(),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
bindings: [
"prompt.submit",
"prompt.editor",
@@ -587,7 +592,7 @@ export function Prompt(props: PromptProps) {
"session.interrupt",
"session.background",
"session.move",
].flatMap((command) => config.keybinds.get(command)),
],
}))
const ref: PromptRef = {
@@ -803,33 +808,40 @@ export function Prompt(props: PromptProps) {
))
},
},
].map((entry) => ({
namespace: "palette",
...entry,
})),
].map(
({ name, category, ...command }) =>
({
id: name,
group: category,
bind: false,
palette: true as const,
...command,
}) satisfies KeymapCommand,
),
)
useBindings(() => ({
Keymap.createLayer(() => ({
mode: "global",
commands: stashCommands(),
}))
useBindings(() => {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
bindings: config.keybinds.get("prompt.paste"),
bindings: ["prompt.paste"],
}
})
useBindings(() => {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
bindings: config.keybinds.get("prompt.clear"),
bindings: ["prompt.clear"],
}
})
useBindings(() => {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: (() => {
@@ -842,12 +854,12 @@ export function Prompt(props: PromptProps) {
input?.visualCursor.offset === 0
)
})(),
bindings: [
commands: [
{
key: "!",
desc: "Shell mode",
bind: "!",
title: "Shell mode",
group: "Prompt",
cmd: () => {
run: () => {
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
},
@@ -856,26 +868,28 @@ export function Prompt(props: PromptProps) {
}
})
useBindings(() => {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && store.mode === "shell",
bindings: [{ key: "escape", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }],
commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }],
}
})
useBindings(() => {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
})(),
bindings: [{ key: "backspace", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }],
commands: [
{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
],
}
})
useBindings(() => {
Keymap.createLayer(() => {
return {
priority: 1,
target: inputTarget,
@@ -885,9 +899,9 @@ export function Prompt(props: PromptProps) {
})(),
commands: [
{
name: "prompt.history.previous",
id: "prompt.history.previous",
title: "Previous prompt history",
category: "Prompt",
group: "Prompt",
run() {
if (input.cursorOffset !== 0) {
if (input.scrollY + input.visualCursor.visualRow === 0) {
@@ -908,11 +922,10 @@ export function Prompt(props: PromptProps) {
},
},
],
bindings: config.keybinds.get("prompt.history.previous"),
}
})
useBindings(() => {
Keymap.createLayer(() => {
return {
priority: 1,
target: inputTarget,
@@ -922,9 +935,9 @@ export function Prompt(props: PromptProps) {
})(),
commands: [
{
name: "prompt.history.next",
id: "prompt.history.next",
title: "Next prompt history",
category: "Prompt",
group: "Prompt",
run() {
if (input.cursorOffset !== input.plainText.length) {
if (
@@ -948,7 +961,6 @@ export function Prompt(props: PromptProps) {
},
},
],
bindings: config.keybinds.get("prompt.history.next"),
}
})
@@ -1307,7 +1319,7 @@ export function Prompt(props: PromptProps) {
const highlight = createMemo(() => {
if (leader()) return themeV2.border()
if (store.mode === "shell") return themeV2.background.action.primary()
if (store.mode === "shell") return themeV2.background.action()
const agent = local.agent.current()
if (!agent) return themeV2.border()
return local.agent.color(agent.id)
@@ -1366,6 +1378,12 @@ export function Prompt(props: PromptProps) {
})
const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
const promptBg = createMemo(() =>
mode() === "light"
? themeV2.increase(themeV2.background.surface.offset(), 1)
: themeV2.decrease(themeV2.background.surface.offset(), 1),
)
return (
<>
<box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false} width="100%">
@@ -1383,7 +1401,7 @@ export function Prompt(props: PromptProps) {
paddingRight={2}
paddingTop={1}
flexShrink={0}
backgroundColor={themeV2.background.action.secondary("focused")}
backgroundColor={promptBg()}
flexGrow={1}
width="100%"
>
@@ -1429,7 +1447,7 @@ export function Prompt(props: PromptProps) {
// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) {
keymap.dispatchCommand("prompt.paste")
keymap.dispatch("prompt.paste")
return
}
@@ -1459,10 +1477,8 @@ export function Prompt(props: PromptProps) {
if (props.disabled) return
r.target?.focus()
}}
focusedBackgroundColor={themeV2.background.action.secondary("focused")}
cursorColor={
props.disabled ? themeV2.background.surface.offset() : themeV2.text()
}
focusedBackgroundColor="transparent"
cursorColor={props.disabled ? themeV2.background.surface.offset() : themeV2.text()}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1481,16 +1497,11 @@ export function Prompt(props: PromptProps) {
<text fg={fadeColor(themeV2.text.subdued(), modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(
leader() ? themeV2.text.subdued() : themeV2.text(),
modelMetaAlpha(),
)}
fg={fadeColor(leader() ? themeV2.text.subdued() : themeV2.text(), modelMetaAlpha())}
>
{local.model.parsed().model}
</text>
<text fg={fadeColor(themeV2.text.subdued(), modelMetaAlpha())}>
{currentProviderLabel()}
</text>
<text fg={fadeColor(themeV2.text.subdued(), modelMetaAlpha())}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<text fg={fadeColor(themeV2.text.subdued(), variantMetaAlpha())}>·</text>
<text>
@@ -1524,15 +1535,15 @@ export function Prompt(props: PromptProps) {
borderColor={borderHighlight()}
customBorderChars={{
...EmptyBorder,
vertical: themeV2.background.action.secondary("focused").a !== 0 ? "╹" : " ",
vertical: promptBg().a !== 0 ? "╹" : " ",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={themeV2.background.action.secondary("focused")}
borderColor={promptBg()}
customBorderChars={
themeV2.background.action.secondary("focused").a !== 0
promptBg().a !== 0
? {
...EmptyBorder,
horizontal: "▀",
@@ -1544,119 +1555,114 @@ export function Prompt(props: PromptProps) {
}
/>
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<Switch>
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show
when={config.animations ?? true}
fallback={<text fg={themeV2.text.subdued()}>[]</text>}
>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<text
fg={store.interrupt > 0 ? themeV2.background.action.primary() : themeV2.text()}
>
esc{" "}
<span
style={{
fg:
store.interrupt > 0
? themeV2.background.action.primary()
: themeV2.text.subdued(),
}}
>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Match>
<Match when={move.progress()}>
{(progress) => (
<box paddingLeft={3}>
<Spinner color={themeV2.hue.accent(500)}>
{progress()}
<span style={{ fg: themeV2.text.subdued() }}>{".".repeat(move.creatingDots())}</span>
</Spinner>
</box>
)}
</Match>
<Match when={move.pendingNew()}>
<box paddingLeft={3}>
<text fg={themeV2.hue.accent(500)}>(new working copy)</text>
</box>
</Match>
<Match when={true}>
<Show
when={!props.hint && locationLabel()}
fallback={props.hint ?? <text />}
>
{(location) => (
<text fg={themeV2.text.subdued()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{location()}
</text>
)}
</Show>
</Match>
</Switch>
<box gap={2} flexDirection="row">
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
{(file) => (
<text
fg={
editorContextLabelState() === "pending"
? themeV2.hue.accent(500)
: themeV2.text.subdued()
}
>
{file()}
</text>
)}
</Show>
<box width="100%" flexDirection="row" justifyContent="space-between" gap={2}>
<box flexGrow={1} flexShrink={1} minWidth={0}>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
<text fg={themeV2.text.subdued()} wrapMode="none">
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
{(shortcut) => <span style={{ fg: themeV2.text() }}>{shortcut()} </span>}
</Show>
<Show when={subagentStatusLabel()}>
{(label) => <span style={{ fg: themeV2.text.subdued() }}>{label()}</span>}
</Show>
<Show when={subagentStatusLabel() && shellStatusLabel()}>
<span style={{ fg: themeV2.text.subdued() }}> · </span>
</Show>
<Show when={shellStatusLabel()}>
{(label) => <span style={{ fg: themeV2.text.subdued() }}>{label()}</span>}
</Show>
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
<span style={{ fg: themeV2.text.subdued() }}> · </span>
</Show>
<Show when={statusItems().length > 0}>
<span style={{ fg: themeV2.text.subdued() }}>{statusItems().join(" · ")}</span>
</Show>
</text>
</Match>
<Match when={true}>
<text fg={themeV2.text()}>
{agentShortcut()} <span style={{ fg: themeV2.text.subdued() }}>agents</span>
</text>
</Match>
</Switch>
<text fg={themeV2.text()}>
{paletteShortcut()} <span style={{ fg: themeV2.text.subdued() }}>commands</span>
</text>
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show when={config.animations ?? true} fallback={<text fg={themeV2.text.subdued()}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<text
fg={store.interrupt > 0 ? themeV2.background.action() : themeV2.text()}
wrapMode="none"
truncate
flexShrink={1}
>
esc{" "}
<span
style={{
fg: store.interrupt > 0 ? themeV2.background.action() : themeV2.text.subdued(),
}}
>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Match>
<Match when={store.mode === "shell"}>
<text fg={themeV2.text()}>
esc <span style={{ fg: themeV2.text.subdued() }}>exit shell mode</span>
</text>
<Match when={move.progress()}>
{(progress) => (
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<Spinner color={themeV2.hue.accent(500)}>
{progress()}
<span style={{ fg: themeV2.text.subdued() }}>{".".repeat(move.creatingDots())}</span>
</Spinner>
</box>
)}
</Match>
<Match when={move.pendingNew()}>
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<text fg={themeV2.hue.accent(500)} wrapMode="none" truncate>
(new working copy)
</text>
</box>
</Match>
<Match when={true}>
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
{(location) => (
<text fg={themeV2.text.subdued()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{location()}
</text>
)}
</Show>
</Match>
</Switch>
</box>
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
{(file) => (
<text
wrapMode="none"
truncate
flexShrink={1}
fg={editorContextLabelState() === "pending" ? themeV2.hue.accent(500) : themeV2.text.subdued()}
>
{file()}
</text>
)}
</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
<text fg={themeV2.text.subdued()} wrapMode="none" truncate flexShrink={1}>
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
{(shortcut) => <span style={{ fg: themeV2.text() }}>{shortcut()} </span>}
</Show>
<Show when={subagentStatusLabel()}>
{(label) => <span style={{ fg: themeV2.text.subdued() }}>{label()}</span>}
</Show>
<Show when={subagentStatusLabel() && shellStatusLabel()}>
<span style={{ fg: themeV2.text.subdued() }}> · </span>
</Show>
<Show when={shellStatusLabel()}>
{(label) => <span style={{ fg: themeV2.text.subdued() }}>{label()}</span>}
</Show>
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
<span style={{ fg: themeV2.text.subdued() }}> · </span>
</Show>
<Show when={statusItems().length > 0}>
<span style={{ fg: themeV2.text.subdued() }}>{statusItems().join(" · ")}</span>
</Show>
</text>
</Match>
<Match when={true}>
<text fg={themeV2.text()} flexShrink={0}>
{agentShortcut()} <span style={{ fg: themeV2.text.subdued() }}>agents</span>
</text>
</Match>
</Switch>
<text fg={themeV2.text()} flexShrink={0}>
{paletteShortcut()} <span style={{ fg: themeV2.text.subdued() }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={themeV2.text()} flexShrink={0}>
esc <span style={{ fg: themeV2.text.subdued() }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</box>
<Autocomplete
+8 -4
View File
@@ -134,11 +134,13 @@ export const Definitions = {
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
messages_first: keybind("ctrl+g,home", "Navigate to first message"),
messages_first: keybind("ctrl+g,home,alt+home", "Navigate to first message"),
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
messages_next: keybind("none", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"),
messages_last_user: keybind("none", "Navigate to last user message"),
messages_next: keybind("alt+down", "Navigate to next message"),
messages_previous: keybind("alt+up", "Navigate to previous message"),
messages_next_user: keybind("alt+shift+down", "Navigate to next user message"),
messages_previous_user: keybind("alt+shift+up", "Navigate to previous user message"),
messages_last_user: keybind("alt+end", "Navigate to last user message"),
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
@@ -335,6 +337,8 @@ export const CommandMap = {
messages_last: "session.last",
messages_next: "session.message.next",
messages_previous: "session.message.previous",
messages_next_user: "session.message.user.next",
messages_previous_user: "session.message.user.previous",
messages_last_user: "session.messages_last_user",
messages_copy: "messages.copy",
messages_undo: "session.undo",
+46 -17
View File
@@ -1,6 +1,6 @@
import type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/context"
import { InputRenderable, TextareaRenderable } from "@opentui/core"
import { stringifyKeyStroke } from "@opentui/keymap"
import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core"
import { stringifyKeyStroke, type Binding, type CommandContext } from "@opentui/keymap"
import {
registerBackspacePopsPendingSequence,
registerBaseLayoutFallback,
@@ -32,17 +32,27 @@ const MODE = { key: "opencode.mode", base: "base" } as const
type OpenTuiKeymap = Parameters<typeof KeymapProvider>[0]["keymap"]
type Mode = ReturnType<typeof createMode>
type KeymapConfig = {
readonly keybinds: {
get(command: string): readonly Binding<Renderable, KeyEvent>[]
}
readonly leader?: { readonly timeout: number }
readonly leader_timeout?: number
}
export const COMMAND_PALETTE_COMMAND = "command.palette.show"
const Context = createContext<{
readonly keymap: OpenTuiKeymap
readonly config: KeymapConfig
readonly mode: Mode
readonly dispatch: (id: string, input?: string) => void
readonly input: (id: string) => string | undefined
}>()
function Provider(props: ParentProps) {
function Provider(props: ParentProps<{ config?: KeymapConfig }>) {
const renderer = useRenderer()
const config = useConfig()
const config: KeymapConfig = props.config ?? useConfig().data
const keymap = createDefaultOpenTuiKeymap(renderer)
const mode = createMode(keymap)
let invocation: { readonly id: string; readonly input?: string } | undefined
@@ -111,16 +121,16 @@ function Provider(props: ParentProps) {
"input.delete.word.backward",
"input.select.all",
"input.submit",
].flatMap((command) => config.data.keybinds.get(command)),
].flatMap((command) => config.keybinds.get(command)),
}),
]
const leader = config.data.keybinds.get("leader")?.[0]?.key
const leader = config.keybinds.get("leader")?.[0]?.key
if (leader) {
dispose.push(
registerTimedLeader(keymap, {
trigger: leader,
name: "leader",
timeoutMs: config.data.leader.timeout,
timeoutMs: config.leader?.timeout ?? ("leader_timeout" in config ? config.leader_timeout : undefined) ?? 2000,
}),
)
}
@@ -131,7 +141,13 @@ function Provider(props: ParentProps) {
return (
<KeymapProvider keymap={keymap}>
<Context.Provider
value={{ keymap, mode, dispatch, input: (id) => (invocation?.id === id ? invocation.input : undefined) }}
value={{
keymap,
config,
mode,
dispatch,
input: (id) => (invocation?.id === id ? invocation.input : undefined),
}}
>
{props.children}
</Context.Provider>
@@ -151,6 +167,8 @@ export interface Keymap {
/** Pushes a mode until the returned cleanup is called. */
push(mode: string): () => void
}
/** Registers a low-level keymap interceptor. */
intercept: OpenTuiKeymap["intercept"]
}
function use(): Keymap {
@@ -160,12 +178,12 @@ function use(): Keymap {
value.dispatch(id, input)
},
mode: value.mode,
intercept: value.keymap.intercept.bind(value.keymap),
}
}
function createLayer(input: () => KeymapLayer) {
const value = useValue()
const config = useConfig()
useBindings(() => {
const layer = input()
const { commands, bindings, mode, ...options } = layer
@@ -199,7 +217,7 @@ function createLayer(input: () => KeymapLayer) {
...definition,
name: id,
opencode: command,
run: () => run(value.input(id)),
run: (context: CommandContext<Renderable, KeyEvent>) => run(value.input(id), context.event),
...(description === undefined ? {} : { desc: description }),
...(group === undefined ? {} : { category: group }),
...(palette === undefined ? {} : { namespace: "palette" }),
@@ -220,20 +238,19 @@ function createLayer(input: () => KeymapLayer) {
})),
...grouped.named.flatMap((command) => {
if (command.bind === false) return []
const configured = config.data.keybinds.get(command.id)
const configured = value.config.keybinds.get(command.id)
if (configured.length) return configured
if (typeof command.bind !== "string") return []
return [{ key: command.bind, cmd: command.id }]
}),
...(bindings ?? []).flatMap((id) => config.data.keybinds.get(id)),
...(bindings ?? []).flatMap((id) => value.config.keybinds.get(id)),
],
}
})
}
function useShortcuts() {
useValue()
const config = useConfig()
const value = useValue()
const shortcuts = useKeymapSelector((keymap) => {
const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name)
const bindings = keymap.getCommandBindings({ visibility: "registered", commands })
@@ -241,8 +258,8 @@ function useShortcuts() {
commands.map((id) => [
id,
{
first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(config.data)),
all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(config.data)),
first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(value.config)),
all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(value.config)),
},
]),
)
@@ -257,6 +274,16 @@ function useShortcuts() {
}
}
function useShortcut(id: string) {
const shortcuts = useShortcuts()
return () => shortcuts.get(id)
}
function useLeaderActive() {
const pending = usePendingSequence()
return () => pending()[0]?.tokenName === "leader"
}
function useCommands(): Accessor<readonly KeymapCommand[]> {
const value = useValue()
return useKeymapSelector((keymap) =>
@@ -312,6 +339,8 @@ export const Keymap = {
use,
createLayer,
useShortcuts,
useShortcut,
useLeaderActive,
useCommands,
usePendingSequence,
useActiveKeys,
@@ -355,7 +384,7 @@ function createMode(keymap: OpenTuiKeymap) {
}
}
function formatOptions(config: ReturnType<typeof useConfig>["data"]) {
function formatOptions(config: KeymapConfig) {
const leader = config.keybinds.get("leader")?.[0]?.key
return {
tokenDisplay: {

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