Compare commits

...

30 Commits

Author SHA1 Message Date
Kit Langton cfc82d6dcb feat(tui): prototype tab scroll controls 2026-08-13 12:58:43 -04:00
Kit Langton d63db95c07 fix(tui): remember session scroll position 2026-08-13 12:50:09 -04:00
Stefan Avram c72cca8def docs: route enterprise contact links (#34080) 2026-06-26 12:05:24 -05:00
Adam 05ce6bc275 fix(data): canonicalize locale pages 2026-06-26 12:00:32 -05:00
Shoubhit Dash 1de7368580 fix(acp): send partial completed tool updates (#34091) 2026-06-26 22:21:35 +05:30
Brendan Allan 36f901588a fix(app): wait for persisted home state before reading collapsed state (#34088) 2026-06-27 00:25:43 +08:00
Ariane Emory 92f1a17b67 docs(providers): document blacklist and whitelist model filtering (#33792) 2026-06-26 11:09:14 -05:00
Shoubhit Dash ebf4007efd fix(acp): enrich permission prompts (#34079) 2026-06-26 21:25:52 +05:30
Brendan Allan 1aea999d7c fix(app): consolidate add project action (#34086) 2026-06-26 23:54:31 +08:00
Brendan Allan 4a8fee3b2d feat(app): add new session workspace controls (#34085) 2026-06-26 23:43:41 +08:00
Aiden Cline 0d3e0fc8f4 fix(core): enlarge OAuth callback card (#34082) 2026-06-26 10:16:51 -05:00
Aiden Cline f254476043 fix(llm): omit stateless response item ids (#34027) 2026-06-26 10:14:43 -05:00
Aiden Cline 639c8e6475 tweak: update OAuth callback branding (#34081) 2026-06-26 10:08:29 -05:00
Aiden Cline e3bfd4cce6 fix(mcp): refresh credentials on reauthentication (#33717) 2026-06-26 10:03:39 -05:00
Shoubhit Dash c2882268d6 fix(acp): skip resume transcript replay (#34070) 2026-06-26 14:12:05 +00:00
Aarav Sareen 9c38b91e91 feat(app): updates to design system (#34066) 2026-06-26 13:22:35 +00:00
Shoubhit Dash 6e8e772582 fix(acp): surface prompt errors (#34061) 2026-06-26 12:20:38 +00:00
Shoubhit Dash 971518c6d9 fix(core): refresh cached remote skills (#34059) 2026-06-26 12:12:50 +00:00
Shoubhit Dash ae853561cd fix(opencode): preserve skill resource paths (#34052) 2026-06-26 16:48:30 +05:30
Luke Parker 753d312c28 fix(tui): preserve renderer initialization errors (#33996)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-26 15:14:28 +05:30
vimtor eeb5b1d8bc core: prevent UI imports compiling TSX source 2026-06-26 10:25:48 +02:00
opencode-agent[bot] f8ceb30b43 chore: generate 2026-06-26 08:06:37 +00:00
usrnk1 78a5a030ce feat(app): refine session UI styling (#33860)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-06-26 08:04:56 +00:00
opencode-agent[bot] 219ba24d90 chore: update nix node_modules hashes 2026-06-26 05:43:54 +00:00
Aiden Cline e8fea9e63a feat(oauth): unify OAuth callback browser pages (#34025) 2026-06-26 00:36:40 -05:00
opencode-agent[bot] 11537260aa chore: generate 2026-06-26 05:31:26 +00:00
Brendan Allan 929c4aaf29 refactor(app): replace tab drag handling with dndkit (#33880)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
2026-06-26 05:29:58 +00:00
opencode-agent[bot] 19e510f5d2 refactor(app): use dropdown for project selector (#33984) 2026-06-26 13:18:23 +08:00
opencode-agent[bot] 8a1608ed1d chore: update nix node_modules hashes 2026-06-26 05:13:59 +00:00
opencode-agent[bot] 077deb9d82 fix(app): bump ghostty-web to prevent terminal resize hangs (#34020)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-06-26 14:59:18 +10:00
98 changed files with 3476 additions and 1660 deletions
+22 -2
View File
@@ -31,6 +31,10 @@
"name": "@opencode-ai/app",
"version": "1.17.11",
"dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -59,7 +63,7 @@
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
@@ -1451,6 +1455,20 @@
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="],
"@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="],
"@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="],
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="],
"@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="],
"@dnd-kit/solid": ["@dnd-kit/solid@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-IKDqVZICS0jEeUzpJMIIF61w0WA4zisyx9U7K7Skbmkb/kQSDa3lB0cOc0947RwSO+ALoxytRNOuoNfyOIm3lQ=="],
"@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="],
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
@@ -2287,6 +2305,8 @@
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
"@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="],
"@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="],
"@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="],
@@ -3781,7 +3801,7 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=",
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=",
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=",
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE="
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
}
}
+5 -1
View File
@@ -44,6 +44,10 @@
"vite-plugin-solid": "catalog:"
},
"dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -72,7 +76,7 @@
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
+3 -1
View File
@@ -16,6 +16,7 @@ import {
type Component,
createEffect,
createMemo,
createRenderEffect,
createResource,
createSignal,
ErrorBoundary,
@@ -278,10 +279,11 @@ function QueryProvider(props: ParentProps) {
function BodyDesignClass() {
const settings = useSettings()
createEffect(() => {
createRenderEffect(() => {
if (typeof document === "undefined") return
const enabled = settings.general.newLayoutDesigns()
document.body.toggleAttribute("data-new-layout", enabled)
document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled)
@@ -1,6 +1,6 @@
import "@pierre/trees/web-components"
import { FileTree } from "@pierre/trees"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -27,6 +27,7 @@ import {
pickerRoot,
} from "./directory-picker-domain"
import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
interface DialogSelectDirectoryV2Props {
title?: string
@@ -266,8 +267,12 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
onCleanup(() => tree?.cleanUp())
return (
<Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
<div class="directory-picker-v2-body">
<Dialog size="large" class="directory-picker-v2">
<DialogHeader>
<DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="directory-picker-v2-body pt-4!">
<div class="directory-picker-v2-path" ref={pathArea}>
<TextInputV2
value={input()}
@@ -349,7 +354,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
</Show>
</div>
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
</div>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
@@ -1,7 +1,8 @@
import { Popover } from "@kobalte/core/popover"
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { getProjectAvatarVariant } from "@/context/layout"
import { useLanguage } from "@/context/language"
@@ -25,12 +26,23 @@ export type PromptProjectControls = {
add: (title: string, server?: string) => void
}
const actionPrefix = "action:"
const projectPrefix = "project:"
function projectKey(project: PromptProject) {
return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}`
}
function actionKey(server?: string) {
return `${actionPrefix}${encodeURIComponent(server ?? "")}`
}
export function createPromptProjectController(input: {
controls: Accessor<PromptProjectControls>
onDone: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ open: false, search: "" })
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
const selected = () => {
@@ -53,8 +65,27 @@ export function createPromptProjectController(input: {
.controls()
.available.map((project) => project.server)
.filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index)
const keys = () => {
if (servers().length <= 1) {
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
}
return [
...servers().flatMap((server) =>
projects()
.filter((project) => project.server?.key === server!.key)
.map(projectKey),
),
actionKey(),
]
}
const initialActive = () => {
const selectedKey = selected() ? projectKey(selected()!) : undefined
const options = keys()
if (selectedKey && options.includes(selectedKey)) return selectedKey
return options[0] ?? ""
}
const close = () => {
setStore({ open: false, search: "" })
setStore({ open: false, search: "", active: "" })
input.onDone()
}
const select = (project: PromptProject) => {
@@ -67,7 +98,7 @@ export function createPromptProjectController(input: {
close()
}
const add = (server?: string) => {
setStore("open", false)
setStore({ open: false, search: "", active: "" })
input.controls().add(language.t("command.project.open"), server)
}
@@ -75,8 +106,11 @@ export function createPromptProjectController(input: {
selected,
projects,
servers,
projectKey,
actionKey,
open: () => store.open,
search: () => store.search,
active: () => store.active,
labels: {
add: () => language.t("session.new.project.add"),
clear: () => language.t("common.clear"),
@@ -86,50 +120,202 @@ export function createPromptProjectController(input: {
add,
select,
setOpen(open: boolean) {
setStore("open", open)
if (open) requestAnimationFrame(() => searchRef?.focus())
if (open) {
setStore({ open: true, active: initialActive() })
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
return
}
setStore({ open: false, search: "", active: "" })
},
setSearch(value: string) {
setStore("search", value)
const search = value.trim().toLowerCase()
const first = input
.controls()
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
setStore({
search: value,
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
})
},
clearSearch() {
setStore({ search: "", active: initialActive() })
setTimeout(() => searchRef?.focus())
},
setActive(key: string) {
setStore("active", key)
},
moveActive(delta: number) {
const options = keys()
if (options.length === 0) return
const index = options.indexOf(store.active)
const start = index === -1 ? 0 : index
setStore("active", options[(start + delta + options.length) % options.length])
},
activeProject() {
return store.active.startsWith(projectPrefix)
? projects().find((project) => projectKey(project) === store.active)
: undefined
},
activeServer() {
return store.active.startsWith(actionPrefix)
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
: undefined
},
activeAction() {
return store.active.startsWith(actionPrefix)
},
setSearchRef(el: HTMLInputElement) {
searchRef = el
},
focusSearch() {
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
},
}
}
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
export function PromptProjectSelector(props: { controller: PromptProjectController }) {
export function PromptProjectSelector(props: {
controller: PromptProjectController
placement?: "bottom" | "bottom-start"
}) {
let contentRef: HTMLDivElement | undefined
let restoreTrigger = true
const activeItem = () =>
props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
: undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const selectProject = (project: PromptProject) => {
restoreTrigger = false
props.controller.setOpen(false)
afterClose(() => props.controller.select(project))
}
const selectAction = (server?: string) => {
restoreTrigger = false
props.controller.setOpen(false)
afterClose(() => props.controller.add(server))
}
const selectActive = () => {
const project = props.controller.activeProject()
if (project) {
selectProject(project)
return
}
if (props.controller.activeAction() && props.controller.servers().length > 1) {
const item = activeItem()
item?.focus()
item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))
return
}
selectAction(props.controller.activeServer())
}
const moveActive = (delta: number) => {
props.controller.moveActive(delta)
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
}
const focusPreviousControl = () => {
const target = Array.from(
document.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
)
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
.findLast((element) => element.offsetParent !== null)
restoreTrigger = false
target?.focus()
queueMicrotask(() => {
if (props.controller.open()) props.controller.setOpen(false)
})
}
const selectedValue = () => {
const project = props.controller.selected()
return project ? props.controller.projectKey(project) : undefined
}
return (
<Popover
<DropdownMenu
open={props.controller.open()}
placement="bottom-start"
placement={props.placement ?? "bottom"}
gutter={4}
modal={false}
onOpenChange={(open) => props.controller.setOpen(open)}
>
<Popover.Trigger as={ProjectTrigger} controller={props.controller} />
<Popover.Portal>
<Popover.Content
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
<DropdownMenu.Portal>
<DropdownMenu.Content
ref={contentRef}
id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
>
<div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(el) => props.controller.setSearchRef(el)}
value={props.controller.search()}
placeholder={props.controller.labels.search()}
aria-autocomplete="list"
aria-controls="prompt-project-menu"
aria-activedescendant={props.controller.active() || undefined}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => props.controller.setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Tab") {
event.preventDefault()
event.stopPropagation()
if (event.shiftKey) {
focusPreviousControl()
return
}
activeItem()?.focus()
return
}
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
props.controller.setOpen(false)
return
}
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
/>
<Show when={props.controller.search().trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onClick={() => props.controller.setSearch("")}
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onPointerDown={(event) => event.preventDefault()}
onClick={() => props.controller.clearSearch()}
aria-label={props.controller.labels.clear()}
>
<Icon name="close-small" size="small" />
@@ -139,53 +325,80 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
<Show
when={props.controller.servers().length > 1}
fallback={
<For each={props.controller.projects()}>
{(project) => (
<ProjectItem
project={project}
selected={props.controller.selected()}
onSelect={props.controller.select}
/>
)}
</For>
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects()}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
}
>
<For each={props.controller.servers()}>
<For
each={props.controller
.servers()
.filter((server) =>
props.controller.projects().some((project) => project.server?.key === server!.key),
)}
>
{(server) => (
<div>
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{server!.name}
</div>
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
{(project) => (
<ProjectItem
project={project}
selected={props.controller.selected()}
onSelect={props.controller.select}
/>
)}
</For>
<ProjectAction
label={props.controller.labels.add()}
onSelect={() => props.controller.add(server!.key)}
/>
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
</div>
)}
</For>
</Show>
</div>
<Show when={props.controller.servers().length <= 1}>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<ProjectAction
label={props.controller.labels.add()}
onSelect={() => props.controller.add(props.controller.servers()[0]?.key)}
/>
</div>
</Show>
</Popover.Content>
</Popover.Portal>
</Popover>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<Show
when={props.controller.servers().length > 1}
fallback={
<ProjectAction
server={props.controller.servers()[0]?.key}
controller={props.controller}
onSelect={selectAction}
/>
}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
id={props.controller.actionKey()}
data-option-key={props.controller.actionKey()}
class={projectActionClass}
classList={{
"!bg-v2-overlay-simple-overlay-hover": props.controller.active() === props.controller.actionKey(),
}}
onMouseEnter={() => props.controller.setActive(props.controller.actionKey())}
>
<Icon name="plus" size="small" />
<span data-slot="dropdown-menu-item-label" class="min-w-0 flex-1 truncate leading-5">
{props.controller.labels.add()}
</span>
<Icon name="chevron-right" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<For each={props.controller.servers()}>
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
</For>
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
</Show>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)
}
@@ -205,15 +418,29 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
}
function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
const [local, rest] = splitProps(props, ["controller", "class", "onClick"])
const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"])
const project = () => local.controller.selected()
return (
<button
{...rest}
data-action="prompt-project"
type="button"
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => local.controller.setOpen(true)}
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{
...local.classList,
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
"text-v2-text-text-muted": local.controller.open(),
}}
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
onKeyDown={(event) => {
if (!local.controller.open() && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault()
event.stopPropagation()
return
}
if (typeof local.onKeyDown === "function") local.onKeyDown(event)
}}
>
<Show
when={project()}
@@ -237,42 +464,88 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
function ProjectItem(props: {
project: PromptProject
selected?: PromptProject
controller: PromptProjectController
onSelect: (project: PromptProject) => void
}) {
const key = () => props.controller.projectKey(props.project)
return (
<button
type="button"
class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => props.onSelect(props.project)}
<DropdownMenu.RadioItem
id={key()}
value={key()}
data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
style={{
"font-family": "var(--v2-font-family-sans)",
"font-size": "13px",
"font-weight": 440,
"line-height": "20px",
"letter-spacing": "-0.04px",
color: "var(--v2-text-text-base)",
padding: "0 12px",
}}
closeOnSelect
onMouseEnter={() => {
props.controller.setActive(key())
props.controller.focusSearch()
}}
onSelect={() => props.onSelect(props.project)}
>
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
<span class="min-w-0 flex-1 truncate leading-5">{displayName(props.project)}</span>
<Show
when={
props.selected?.worktree === props.project.worktree &&
props.selected?.server?.key === props.project.server?.key
}
>
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">{displayName(props.project)}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator style={{ width: "14px", height: "14px", right: "12px" }}>
<IconV2 name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)
}
function ProjectAction(props: { label: string; onSelect: () => void }) {
const projectActionClass =
"h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
function ProjectAction(props: {
server?: string
controller: PromptProjectController
onSelect: (server?: string) => void
}) {
const key = () => props.controller.actionKey(props.server)
return (
<button
type="button"
class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={props.onSelect}
<DropdownMenu.Item
id={key()}
data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
style={{
"font-family": "var(--v2-font-family-sans)",
"font-size": "13px",
"font-weight": 440,
"line-height": "20px",
"letter-spacing": "-0.04px",
color: "var(--v2-text-text-base)",
padding: "0 12px",
}}
onMouseEnter={() => {
props.controller.setActive(key())
props.controller.focusSearch()
}}
onSelect={() => props.onSelect(props.server)}
>
<Icon name="plus" size="small" />
<span class="min-w-0 flex-1 truncate leading-5">{props.label}</span>
</button>
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">
{props.controller.labels.add()}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
)
}
function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) {
return (
<DropdownMenu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}>
<DropdownMenu.ItemLabel class="min-w-0 flex-1 truncate leading-5">{props.server.name}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
)
}
@@ -0,0 +1,102 @@
import { For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language"
export function PromptWorkspaceSelector(props: {
value: string
projectRoot: string
workspaces: string[]
branch?: string
onChange: (value: string) => void
onDone: () => void
}) {
const language = useLanguage()
let pending: string | undefined
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
const icon = () => {
if (selected() === "main") return "monitor"
if (selected() === "create") return "workspace-new"
return "workspace"
}
const select = (value: string) => {
pending = value
}
const onOpenChange = (open: boolean) => {
if (open) return
const value = pending
pending = undefined
if (value) props.onChange(value)
props.onDone()
}
const label = () => {
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
if (props.value === "create") return language.t("workspace.new")
return getFilename(props.value)
}
return (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{label()}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="w-[180px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<IconV2 name="monitor" />
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
<Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
<MenuV2.Item onSelect={() => select("create")}>
<IconV2 name="workspace-new" />
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</MenuV2.Group>
<Show when={props.workspaces.length > 0}>
<MenuV2.Separator />
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<IconV2 name="workspace" />
{language.t("session.new.workspace.existing")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-w-[200px]">
<For each={props.workspaces}>
{(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}>
<IconV2 name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{props.branch || "main"}</span>
</div>
</>
)
}
@@ -1,5 +1,6 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
@@ -52,8 +53,12 @@ export const DialogServerV2: Component<{
}
return (
<Dialog title={title()} fit class="settings-v2-server-dialog">
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
<Dialog fit class="settings-v2-server-dialog">
<DialogHeader hideClose={true}>
<DialogTitle>{title()}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
@@ -115,7 +120,7 @@ export const DialogServerV2: Component<{
</div>
</div>
</div>
</div>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
{language.t("common.cancel")}
@@ -633,7 +633,7 @@
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
align-items: center;
padding: 24px 24px 0;
padding: 24px 24px 16px;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
@@ -1,141 +0,0 @@
import { describe, expect, test } from "bun:test"
import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag"
import {
canOpenTabRename,
captureTabPointerDown,
canStartTabDrag,
createTabDragPreview,
forwardTabRef,
isPrimaryPointerPressed,
isTabCloseTarget,
} from "./titlebar-tab-gesture"
describe("titlebar tab drag", () => {
const layout = {
listLeft: 100,
dividerWidth: 13,
tabWidthById: new Map([
["a", 40],
["b", 40],
["c", 40],
["d", 40],
]),
}
test("moves across multiple tabs from one pointer update", () => {
expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3)
expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0)
})
test("keeps the current index inside the left hysteresis deadband", () => {
expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1)
})
test("includes slot margins in captured divider width", () => {
const list = document.createElement("div")
const first = document.createElement("div")
const second = document.createElement("div")
const firstTab = document.createElement("div")
const secondTab = document.createElement("div")
first.dataset.titlebarTabSlot = ""
first.dataset.tabKey = "a"
second.dataset.titlebarTabSlot = ""
second.dataset.tabKey = "b"
second.style.marginLeft = "6px"
firstTab.dataset.titlebarTab = ""
secondTab.dataset.titlebarTab = ""
first.append(firstTab)
second.append(secondTab)
list.append(first, second)
document.body.append(list)
firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect
list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
list.remove()
})
test("uses the list gap as the divider width", () => {
const list = document.createElement("div")
const first = document.createElement("div")
const second = document.createElement("div")
const firstTab = document.createElement("div")
const secondTab = document.createElement("div")
first.dataset.titlebarTabSlot = ""
first.dataset.tabKey = "a"
second.dataset.titlebarTabSlot = ""
second.dataset.tabKey = "b"
firstTab.dataset.titlebarTab = ""
secondTab.dataset.titlebarTab = ""
first.append(firstTab)
second.append(secondTab)
list.append(first, second)
list.style.columnGap = "13.5px"
document.body.append(list)
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13.5)
list.remove()
})
})
describe("titlebar tab gestures", () => {
test("excludes close controls from tab gestures", () => {
const close = document.createElement("div")
const button = document.createElement("button")
const link = document.createElement("a")
close.dataset.slot = "tab-close"
close.append(button)
expect(isTabCloseTarget(close)).toBe(true)
expect(isTabCloseTarget(button)).toBe(true)
expect(isTabCloseTarget(link)).toBe(false)
})
test("forwards component refs", () => {
const element = document.createElement("div")
let received: HTMLDivElement | undefined
forwardTabRef((value) => (received = value), element)
expect(received).toBe(element)
})
test("does not reopen rename while a save is pending", () => {
expect(canOpenTabRename(false, false, false)).toBe(true)
expect(canOpenTabRename(false, false, true)).toBe(false)
})
test("keeps the rendered tab content in the drag preview", () => {
const tab = document.createElement("div")
tab.innerHTML = '<span data-slot="project-avatar-slot"></span><span data-slot="tab-title">Session</span>'
const preview = createTabDragPreview(tab)
expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull()
expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session")
})
test("captures the grab offset before navigation scrolls the tab", () => {
const tab = document.createElement("div")
tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect
expect(captureTabPointerDown(tab, 100, 20)).toEqual({
startX: 100,
startY: 20,
grabOffsetX: 20,
grabOffsetY: 10,
width: 120,
element: tab,
})
})
test("detects when the primary pointer button was released outside the window", () => {
expect(isPrimaryPointerPressed(1)).toBe(true)
expect(isPrimaryPointerPressed(3)).toBe(true)
expect(isPrimaryPointerPressed(0)).toBe(false)
expect(isPrimaryPointerPressed(2)).toBe(false)
})
test("preserves native panning for touch pointers", () => {
expect(canStartTabDrag("mouse")).toBe(true)
expect(canStartTabDrag("pen")).toBe(true)
expect(canStartTabDrag("touch")).toBe(false)
})
})
@@ -1,137 +0,0 @@
export type TabDragLayout = {
tabWidthById: Map<string, number>
dividerWidth: number
listLeft: number
}
export const ACTIVATION_DISTANCE = 4
export const HYSTERESIS_DEADBAND = 8
export const AUTOSCROLL_EDGE = 24
export const AUTOSCROLL_MAX_SPEED = 8
export const FLOATER_OVERSHOOT_MAX = 8
export function pointerDistance(x1: number, y1: number, x2: number, y2: number) {
const dx = x2 - x1
const dy = y2 - y1
return Math.sqrt(dx * dx + dy * dy)
}
export function captureTabDragLayout(list: HTMLElement, order: string[]) {
const tabWidthById = new Map<string, number>()
const slots = list.querySelectorAll<HTMLElement>("[data-titlebar-tab-slot]")
for (const slot of slots) {
const id = slot.dataset.tabKey
if (!id) continue
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) continue
tabWidthById.set(id, tab.getBoundingClientRect().width)
}
let dividerWidth = 0
if (order.length >= 2) {
const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0
const secondId = order[1]
for (const slot of slots) {
if (slot.dataset.tabKey !== secondId) continue
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) break
const style = getComputedStyle(slot)
dividerWidth =
gap ||
slot.getBoundingClientRect().width -
tab.getBoundingClientRect().width +
(Number.parseFloat(style.marginLeft) || 0) +
(Number.parseFloat(style.marginRight) || 0)
break
}
}
return {
tabWidthById,
dividerWidth,
listLeft: list.getBoundingClientRect().left,
}
}
export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) {
layout.listLeft = list.getBoundingClientRect().left
}
function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) {
const id = order[index]
if (!id) return 0
const tabWidth = layout.tabWidthById.get(id) ?? 0
return index === 0 ? tabWidth : layout.dividerWidth + tabWidth
}
function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) {
let left = layout.listLeft
for (let i = 0; i < index; i++) {
left += slotWidthAt(order, i, layout)
}
return left
}
export function insertIndexFromVirtualLayout(
pointerX: number,
order: readonly string[],
draggedId: string,
currentIndex: number,
layout: TabDragLayout,
deadband = HYSTERESIS_DEADBAND,
) {
if (order.length === 0) return 0
const others = order.filter((id) => id !== draggedId)
let target = currentIndex
while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target--
while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++
return target
}
export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) {
const fromIndex = order.indexOf(draggedId)
if (fromIndex === -1 || fromIndex === toIndex) return [...order]
const next = [...order]
next.splice(toIndex, 0, ...next.splice(fromIndex, 1))
return next
}
export function draftOrderChanged(initial: readonly string[], final: readonly string[]) {
if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false
return final.some((key, index) => key !== initial[index])
}
function easeOvershoot(overshoot: number) {
return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX)
}
export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) {
const stripWidth = stripRight - stripLeft
if (width >= stripWidth) return stripLeft
const maxLeft = stripRight - width
if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft)
if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left)
return left
}
export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) {
const leftEdge = containerLeft + AUTOSCROLL_EDGE
const rightEdge = containerRight - AUTOSCROLL_EDGE
if (pointerX < leftEdge) {
const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE
return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
}
if (pointerX > rightEdge) {
const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE
return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
}
return 0
}
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./titlebar-tab-gesture"
describe("titlebar tab gestures", () => {
test("excludes close controls from tab gestures", () => {
const close = document.createElement("div")
const button = document.createElement("button")
const link = document.createElement("a")
close.dataset.slot = "tab-close"
close.append(button)
expect(isTabCloseTarget(close)).toBe(true)
expect(isTabCloseTarget(button)).toBe(true)
expect(isTabCloseTarget(link)).toBe(false)
})
test("forwards component refs", () => {
const element = document.createElement("div")
let received: HTMLDivElement | undefined
forwardTabRef((value) => (received = value), element)
expect(received).toBe(element)
})
test("does not reopen rename while a save is pending", () => {
expect(canOpenTabRename(false, false, false)).toBe(true)
expect(canOpenTabRename(false, false, true)).toBe(false)
})
test("preserves native panning for touch pointers", () => {
expect(canStartTabDrag("mouse")).toBe(true)
expect(canStartTabDrag("pen")).toBe(true)
expect(canStartTabDrag("touch")).toBe(false)
})
})
@@ -8,22 +8,6 @@ export function canStartTabDrag(pointerType: string) {
return pointerType !== "touch"
}
export function isPrimaryPointerPressed(buttons: number) {
return (buttons & 1) !== 0
}
export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) {
const rect = element.getBoundingClientRect()
return {
startX: clientX,
startY: clientY,
grabOffsetX: clientX - rect.left,
grabOffsetY: clientY - rect.top,
width: rect.width,
element,
}
}
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
if (typeof ref === "function") ref(element)
}
@@ -31,7 +15,3 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
return !dragging && !editing && !committing
}
export function createTabDragPreview(element: HTMLDivElement) {
return element.cloneNode(true) as HTMLDivElement
}
@@ -9,6 +9,10 @@
justify-content: center;
}
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none;
}
[data-titlebar-tab-list] {
gap: 13.5px;
}
+139 -159
View File
@@ -29,9 +29,6 @@ export function TabNavItem(props: {
dragging?: boolean
pressed?: boolean
hidden?: boolean
tabKey: string
dragActive: boolean
onPointerDown: (event: PointerEvent) => void
}) {
const language = useLanguage()
const [editing, setEditing] = createSignal(false)
@@ -173,112 +170,105 @@ export function TabNavItem(props: {
return (
<div
data-titlebar-tab-slot
data-tab-key={props.tabKey}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
onPointerDown={props.onPointerDown}
ref={(el) => {
tabRoot = el
forwardTabRef(props.ref, el)
}}
data-titlebar-tab
data-slot="titlebar-tab-item"
data-title-overflow={titleOverflowing()}
data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<div
ref={(el) => {
tabRoot = el
forwardTabRef(props.ref, el)
}}
data-titlebar-tab
data-slot="titlebar-tab-item"
data-title-overflow={titleOverflowing()}
data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<Show when={props.session()}>
{(session) => {
return (
<a
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
draggable={false}
onDragStart={(event) => {
<Show when={props.session()}>
{(session) => {
return (
<a
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
draggable={false}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span data-slot="project-avatar-slot">
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
activeServer={props.activeServer}
/>
</span>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = session().title
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = session().title
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation()
}}
onClick={(event) => {
if (!editing()) return
event.preventDefault()
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span data-slot="project-avatar-slot">
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
activeServer={props.activeServer}
/>
</span>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = session().title
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = session().title
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation()
}}
onClick={(event) => {
if (!editing()) return
event.preventDefault()
}}
/>
</a>
)
}}
</Show>
/>
</a>
)
}}
</Show>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2
size="small"
variant="ghost-muted"
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
/>
</div>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2
size="small"
variant="ghost-muted"
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
)
@@ -295,9 +285,6 @@ export function DraftTabItem(props: {
dragging?: boolean
pressed?: boolean
hidden?: boolean
tabKey: string
dragActive: boolean
onPointerDown: (event: PointerEvent) => void
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
@@ -306,69 +293,62 @@ export function DraftTabItem(props: {
}
return (
<div
data-titlebar-tab-slot
data-tab-key={props.tabKey}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
onPointerDown={props.onPointerDown}
ref={(el) => forwardTabRef(props.ref, el)}
data-titlebar-tab
data-slot="titlebar-tab-item"
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<div
ref={(el) => forwardTabRef(props.ref, el)}
data-titlebar-tab
data-slot="titlebar-tab-item"
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
<a
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
draggable={false}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<a
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
draggable={false}
onDragStart={(event) => {
<span class="flex size-4 shrink-0 items-center justify-center">
<IconV2 name="edit" />
</span>
<span
data-titlebar-tab-title
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
>
{props.title}
</span>
</a>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2
size="small"
variant="ghost-muted"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
onMouseDown={(event) => {
event.preventDefault()
if (props.suppressNavigation?.()) return
props.onNavigate()
event.stopPropagation()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span class="flex size-4 shrink-0 items-center justify-center">
<IconV2 name="edit" />
</span>
<span
data-titlebar-tab-title
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
>
{props.title}
</span>
</a>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2
size="small"
variant="ghost-muted"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div>
)
+159 -392
View File
@@ -1,18 +1,11 @@
import {
createEffect,
createMemo,
createResource,
createRoot,
createSignal,
For,
onCleanup,
onMount,
Show,
} from "solid-js"
import { Portal } from "solid-js/web"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
import { ServerConnection } from "@/context/server"
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
@@ -22,42 +15,30 @@ import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode"
import {
captureTabPointerDown,
canStartTabDrag,
createTabDragPreview,
isPrimaryPointerPressed,
isTabCloseTarget,
} from "./titlebar-tab-gesture"
import {
ACTIVATION_DISTANCE,
autoscrollSpeed,
captureTabDragLayout,
clampFloaterLeft,
draftOrderChanged,
insertIndexFromVirtualLayout,
movePlaceholder,
pointerDistance,
syncLayoutScroll,
type TabDragLayout,
} from "@/components/titlebar-tab-drag"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
const sortableTransition = { duration: 0 }
function SessionTabSlot(props: {
tab: SessionTab
id: string
index: () => number
active: () => boolean
activeServerKey: ServerConnection.Key
forceTruncate: boolean
dragActive: boolean
dragged: () => boolean
pressed: () => boolean
serverCtx: () => ServerCtx | undefined
suppressNavigation: () => boolean
onPointerDown: (event: PointerEvent) => void
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const tabs = useTabs()
const sortable = useSortable({
get id() {
return props.id
},
get index() {
return props.index()
},
})
let ref!: HTMLDivElement
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
@@ -100,33 +81,79 @@ function SessionTabSlot(props: {
})
return (
<TabNavItem
tabKey={props.id}
dragActive={props.dragActive}
onPointerDown={props.onPointerDown}
ref={ref}
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onTitleChangeFailed={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
activeServer={props.tab.server === props.activeServerKey}
forceTruncate={props.forceTruncate}
suppressNavigation={props.suppressNavigation}
pressed={props.pressed()}
hidden={props.dragged() || !session()}
/>
<div
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() }}
>
<TabNavItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onTitleChangeFailed={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
activeServer={props.tab.server === props.activeServerKey}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/>
</div>
)
}
function DraftTabSlot(props: {
tab: Extract<Tab, { type: "draft" }>
id: string
index: () => number
active: () => boolean
title: string
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const sortable = useSortable({
get id() {
return props.id
},
get index() {
return props.index()
},
})
let ref!: HTMLDivElement
return (
<div
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<DraftTabItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
title={props.title}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
dragging={sortable.isDragSource()}
/>
</div>
)
}
@@ -142,52 +169,12 @@ export function TitlebarTabStrip(props: {
}) {
const global = useGlobal()
const language = useLanguage()
const [drag, setDrag] = createStore({
active: false,
draggedId: undefined as string | undefined,
placeholderIndex: 0,
draftOrder: [] as string[],
initialOrder: [] as string[],
draggedWidth: 0,
pointerX: 0,
grabOffsetX: 0,
floaterTop: 0,
})
const [gesture, setGesture] = createStore({
pending: undefined as
| {
id: string
startX: number
startY: number
grabOffsetX: number
grabOffsetY: number
pointerId: number
width: number
element: HTMLDivElement
}
| undefined,
})
const [suppressNavigation, setSuppressNavigation] = createSignal(false)
const [pressedId, setPressedId] = createSignal<string | undefined>()
const [stripScrollLeft, setStripScrollLeft] = createSignal(0)
let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement
let dragLayout: TabDragLayout | undefined
let dragPointerId: number | undefined
let autoscrollFrame: number | undefined
let resizeFrame: number | undefined
let dragPreview: HTMLDivElement | undefined
const tabIds = () => props.tabs.map(tabKey)
const displayTabs = createMemo(() => {
if (!drag.active || drag.draftOrder.length === 0) return props.tabs
const byKey = new Map(props.tabs.map((tab) => [tabKey(tab), tab]))
return drag.draftOrder.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
})
function refreshOverflow() {
if (!scrollRef) return
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
@@ -200,220 +187,14 @@ export function TitlebarTabStrip(props: {
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
refreshOverflow()
if (!drag.active || !listRef) return
dragLayout = captureTabDragLayout(listRef, drag.draftOrder)
updateInsertIndex()
})
},
)
function syncScroll() {
if (!scrollRef || !listRef || !dragLayout) return
syncLayoutScroll(listRef, dragLayout)
setStripScrollLeft(scrollRef.scrollLeft)
updateInsertIndex()
}
function stopAutoscroll() {
if (autoscrollFrame === undefined) return
cancelAnimationFrame(autoscrollFrame)
autoscrollFrame = undefined
}
function tickAutoscroll() {
if (!drag.active || !scrollRef) return
const strip = scrollRef.getBoundingClientRect()
const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right)
if (speed !== 0) {
scrollRef.scrollLeft += speed
syncScroll()
}
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
}
function startAutoscroll() {
stopAutoscroll()
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
}
function applyPlaceholderIndex(nextIndex: number) {
const id = drag.draggedId
if (!id) return
const next = movePlaceholder(drag.draftOrder, id, nextIndex)
setDrag({
draftOrder: next,
placeholderIndex: nextIndex,
})
}
function updateInsertIndex() {
if (!drag.active || !dragLayout) return
const draggedId = drag.draggedId
if (!draggedId) return
const nextIndex = insertIndexFromVirtualLayout(
drag.pointerX,
drag.draftOrder,
draggedId,
drag.placeholderIndex,
dragLayout,
)
if (nextIndex === drag.placeholderIndex) return
applyPlaceholderIndex(nextIndex)
}
function startDrag(id: string) {
const order = tabIds()
const index = order.indexOf(id)
const pending = gesture.pending
if (index === -1 || !pending || !listRef || !scrollRef) return
dragLayout = captureTabDragLayout(listRef, order)
dragPreview = createTabDragPreview(pending.element)
dragPointerId = pending.pointerId
setGesture("pending", undefined)
setDrag({
active: true,
draggedId: id,
placeholderIndex: index,
draftOrder: order,
initialOrder: order,
draggedWidth: pending.width,
pointerX: pending.startX,
grabOffsetX: pending.grabOffsetX,
floaterTop: pending.startY - pending.grabOffsetY,
})
setPressedId(undefined)
setStripScrollLeft(scrollRef.scrollLeft)
startAutoscroll()
}
function endDrag(commit: boolean) {
const initial = drag.initialOrder
const final = drag.draftOrder
const moved = drag.active
if (commit && moved && draftOrderChanged(initial, final)) {
props.onReorder(final)
}
if (moved) setSuppressNavigation(true)
setDrag({
active: false,
draggedId: undefined,
placeholderIndex: 0,
draftOrder: [],
initialOrder: [],
draggedWidth: 0,
pointerX: 0,
grabOffsetX: 0,
floaterTop: 0,
})
dragLayout = undefined
dragPreview = undefined
dragPointerId = undefined
setGesture("pending", undefined)
setPressedId(undefined)
stopAutoscroll()
refreshOverflow()
requestAnimationFrame(() => setSuppressNavigation(false))
}
function onPointerDown(id: string, event: PointerEvent) {
if (event.button !== 0 || drag.active) return
if (!canStartTabDrag(event.pointerType)) return
if (isTabCloseTarget(event.target)) return
const target = event.currentTarget as HTMLDivElement
const tabEl = target.matches("[data-titlebar-tab]")
? target
: target.querySelector<HTMLDivElement>("[data-titlebar-tab]")
if (!tabEl) return
if (!tabEl.querySelector('[data-slot="tab-link"]')) return
const tab = props.tabs.find((item) => tabKey(item) === id)
if (!tab) return
const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY)
setSuppressNavigation(true)
props.onNavigate(tab, tabEl)
setPressedId(id)
setGesture("pending", {
id,
pointerId: event.pointerId,
...pointer,
})
}
function onPointerMove(event: PointerEvent) {
const pending = gesture.pending
if (pending && event.pointerId !== pending.pointerId) return
if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return
if (!isPrimaryPointerPressed(event.buttons)) {
if (drag.active) endDrag(true)
if (pending) {
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
return
}
if (pending && !drag.active) {
if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return
startDrag(pending.id)
}
if (!drag.active) return
setDrag("pointerX", event.clientX)
syncScroll()
}
function onPointerUp(event: PointerEvent) {
if (drag.active) {
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
setDrag("pointerX", event.clientX)
syncScroll()
endDrag(true)
return
}
const pending = gesture.pending
if (pending && event.pointerId !== pending.pointerId) return
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
function onPointerCancel(event: PointerEvent) {
if (drag.active) {
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
endDrag(false)
return
}
if (!gesture.pending) return
if (gesture.pending.pointerId !== event.pointerId) return
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
onMount(() => {
const cleanups = [
makeEventListener(window, "pointermove", onPointerMove),
makeEventListener(window, "pointerup", onPointerUp),
makeEventListener(window, "pointercancel", onPointerCancel),
]
refreshOverflow()
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
})
onCleanup(stopAutoscroll)
onCleanup(() => {
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
})
@@ -424,50 +205,54 @@ export function TitlebarTabStrip(props: {
refreshOverflow()
})
createEffect(() => {
if (!drag.active || !scrollRef) return
onCleanup(makeEventListener(scrollRef, "scroll", syncScroll))
})
const floaterStyle = () => {
stripScrollLeft()
const strip = scrollRef?.getBoundingClientRect()
const left = strip
? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right)
: drag.pointerX - drag.grabOffsetX
return {
position: "fixed" as const,
top: `${drag.floaterTop}px`,
left: `${left}px`,
width: `${drag.draggedWidth}px`,
"z-index": "10000",
"pointer-events": "none" as const,
}
}
const draggedTab = createMemo(() => {
const id = drag.draggedId
if (!id) return
return props.tabs.find((tab) => tabKey(tab) === id)
})
return (
<>
<div data-slot="titlebar-tabs" class="relative min-w-0">
<div
data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={scrollRef}
<div data-slot="titlebar-tabs" class="relative min-w-0">
<div
data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={scrollRef}
>
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
!canStartTabDrag(event.pointerType) ||
isTabCloseTarget(event.target) ||
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
}),
]}
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragStart={(event) => {
const source = event.operation.source
if (!source) return
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
if (!tab) return
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
props.onNavigate(tab, tabEl ?? undefined)
}}
onDragEnd={(event) => {
const current = tabIds()
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
const { initialIndex, index } = source
if (initialIndex !== index) {
props.onReorder(arrayMove(current, source.initialIndex, source.index))
}
}}
>
<div data-titlebar-tab-list class="flex min-w-0 flex-row items-center" ref={listRef}>
<For each={displayTabs()}>
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
<For each={props.tabs}>
{(tab, index) => {
const id = tabKey(tab)
let ref!: HTMLDivElement
useTabShortcut(index, () => props.onNavigate(tab, ref))
const dragged = () => drag.active && drag.draggedId === id
const serverCtx = createMemo(() => {
if (tab.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
@@ -479,68 +264,50 @@ export function TitlebarTabStrip(props: {
<SessionTabSlot
tab={tab}
id={id}
index={index}
active={() => props.currentTab() === tab}
activeServerKey={props.activeServerKey}
forceTruncate={props.forceTruncate}
dragActive={drag.active}
dragged={dragged}
pressed={() => pressedId() === id}
serverCtx={serverCtx}
suppressNavigation={() => suppressNavigation()}
onPointerDown={(event) => {
if (dragged()) return
onPointerDown(id, event)
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
}}
onNavigate={(element) => props.onNavigate(tab, element)}
onClose={() => props.onClose(tab)}
/>
)
}
return (
<DraftTabItem
tabKey={id}
dragActive={drag.active}
onPointerDown={(event) => {
if (dragged()) return
onPointerDown(id, event)
}}
ref={ref}
href={tabHref(tab)}
<DraftTabSlot
tab={tab}
id={id}
index={index}
active={() => props.currentTab() === tab}
title={language.t("command.session.new")}
onNavigate={() => props.onNavigate(tab, ref)}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
}}
onClose={() => props.onClose(tab)}
suppressNavigation={() => suppressNavigation()}
active={props.currentTab() === tab}
pressed={pressedId() === id}
hidden={dragged()}
/>
)
}}
</For>
</div>
</div>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
</DragDropProvider>
</div>
<Show when={drag.active && draggedTab() && dragPreview}>
{(_) => (
<Portal>
<div data-titlebar-tab-preview style={floaterStyle()}>
{dragPreview}
</div>
</Portal>
)}
</Show>
</>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
</div>
)
}
+4
View File
@@ -656,6 +656,10 @@ export const dict = {
"session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
"session.new.worktree.create": "Create new worktree",
"session.new.workspace.runIn": "Run session in",
"session.new.workspace.triggerLocal": "Local",
"session.new.workspace.local": "Local repository",
"session.new.workspace.existing": "Workspace…",
"session.new.lastModified": "Last modified",
"session.header.search.placeholder": "Search {{project}}",
+10 -3
View File
@@ -2,6 +2,7 @@ import type { Session } from "@opencode-ai/sdk/v2/client"
import {
createEffect,
createMemo,
createResource,
createRoot,
For,
Match,
@@ -525,10 +526,16 @@ function HomeProjectColumn(props: {
const global = useGlobal()
const dialog = useDialog()
const controller = useServerManagementController({ navigateOnAdd: false })
const [state, setState] = persisted(
const [_state, setState, _, ready] = persisted(
Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }),
)
const [state] = createResource(
() => ready.promise ?? Promise.resolve(),
(p) => p.then(() => _state),
{ initialValue: _state },
)
return (
<aside
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
@@ -560,7 +567,7 @@ function HomeProjectColumn(props: {
const key = ServerConnection.key(item)
const healthy = () => !!global.servers.health[key]?.healthy
const serverCtx = global.ensureServerCtx(item)
const collapsed = () => !!state.collapsed[key]
const collapsed = () => !!state().collapsed[key]
return (
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<HomeServerRow
@@ -573,7 +580,7 @@ function HomeProjectColumn(props: {
focusServer={props.focusServer}
chooseProject={props.chooseProject}
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
toggleCollapsed={() => setState("collapsed", key, !state.collapsed[key])}
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
language={props.language}
/>
<Show when={healthy() && !collapsed()}>
+42 -8
View File
@@ -19,6 +19,9 @@ import { createPromptInputController, createPromptProjectControls } from "@/page
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
/**
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
@@ -51,16 +54,21 @@ export default function NewSessionPage() {
onDone: () => inputRef?.focus(),
})
const [store, setStore] = createStore({
worktree: "main",
})
const [store, setStore] = createStore<{ worktree?: string }>({})
const newSessionWorktree = createMemo(() => {
if (store.worktree === "create") return "create"
if (store.worktree) return store.worktree
const project = sync().project
if (project && sdk().directory !== project.worktree) return sdk().directory
return "main"
})
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
const selectedBranch = createMemo(() => {
const worktree = newSessionWorktree()
if (worktree === "main" || worktree === "create") return localBranch()
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
})
createEffect(() => {
if (!prompt.ready()) return
@@ -97,7 +105,7 @@ export default function NewSessionPage() {
</div>
}
>
<div class="flex flex-col gap-3">
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}>
<PromptInput
controls={inputController()}
variant="new-session"
@@ -105,7 +113,7 @@ export default function NewSessionPage() {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
onSubmit={() => comments.clear()}
toolbar={
<Show when={!projectController.selected()}>
@@ -114,8 +122,34 @@ export default function NewSessionPage() {
}
/>
<Show when={projectController.selected()}>
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
<PromptProjectSelector controller={projectController} />
<div
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
classList={{
"flex-col justify-center sm:flex-row": showWorkspaceBar,
"justify-start": !showWorkspaceBar,
}}
>
<PromptProjectSelector
controller={projectController}
placement={showWorkspaceBar ? "bottom" : "bottom-start"}
/>
<Show when={showWorkspaceBar}>
<PromptWorkspaceSelector
value={newSessionWorktree()}
projectRoot={projectRoot()}
workspaces={sync().project?.sandboxes ?? []}
branch={selectedBranch()}
onChange={(value) =>
setStore(
"worktree",
value === "main" && sync().project?.worktree !== sdk().directory
? sync().project?.worktree
: value,
)
}
onDone={() => inputRef?.focus()}
/>
</Show>
</div>
</Show>
</div>
+10 -2
View File
@@ -908,7 +908,13 @@ export default function Page() {
)
const reviewPanel = () => (
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
<div
classList={{
"flex flex-col h-full overflow-hidden contain-strict": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
{reviewContent({
diffStyle: layout.review.diffStyle(),
@@ -1713,7 +1719,9 @@ export default function Page() {
>
<div
classList={{
"flex-1 min-h-0 flex flex-col bg-background-stronger": true,
"flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}}
@@ -1,5 +1,6 @@
import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
@@ -13,6 +14,7 @@ export function SessionComposerRegion(props: {
}) {
const language = useLanguage()
const controller = props.controller
const settings = useSettings()
const rolled = () => {
const revert = controller.revert()
return revert?.items.length ? revert : undefined
@@ -22,7 +24,11 @@ export function SessionComposerRegion(props: {
<div
ref={controller.setDockRef}
data-component="session-prompt-dock"
class="w-full shrink-0 flex flex-col justify-center items-center pb-3 bg-background-stronger pointer-events-none"
classList={{
"w-full shrink-0 flex flex-col justify-center items-center pb-3 pointer-events-none": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<div
classList={{
@@ -1019,7 +1019,13 @@ export function MessageTimeline(props: {
<div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}>
{(comment) => (
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
<div
classList={{
"shrink-0 max-w-[260px] rounded-[6px] border-border-weak-base bg-background-stronger px-2.5 py-2": true,
"border-[0.5px]": settings.general.newLayoutDesigns(),
border: !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
<FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" />
<span class="truncate">{getFilename(comment().path)}</span>
@@ -1288,7 +1294,11 @@ export function MessageTimeline(props: {
<div
data-session-title
classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"sticky top-0 z-30": true,
"bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)]":
settings.general.newLayoutDesigns(),
"bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]":
!settings.general.newLayoutDesigns(),
"w-full": true,
"pb-4": true,
"pr-3": true,
@@ -1509,7 +1519,11 @@ export function MessageTimeline(props: {
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
class={
settings.general.newLayoutDesigns()
? "w-full shadow-none border-[0.5px] border-border-weak-base"
: "w-full shadow-none border border-border-weak-base"
}
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
+10 -3
View File
@@ -1,7 +1,8 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
@@ -30,8 +31,14 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
const language = useLanguage()
const openAddWsl = () => {
dialog.push(() => (
<Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
<DialogAddWslServer />
<Dialog size="large" fit class="settings-v2-wsl-dialog">
<DialogHeader hideClose={true}>
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody>
<DialogAddWslServer />
</DialogBody>
</Dialog>
))
}
+68 -1
View File
@@ -1,10 +1,15 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { LOCALE_HEADER, cookie, localeFromRequest, route, tag } from "~/lib/language"
const dataPath = "/data"
export async function statsProxy(evt: APIEvent) {
const req = evt.request.clone()
const locale = localeFromRequest(req)
const redirect = redirectToLocalizedData(req, new URL(req.url), locale)
if (redirect) return redirect
const targetUrl = new URL(req.url)
targetUrl.protocol = "https:"
targetUrl.hostname = Resource.App.stage === "production" ? "stats.opencode.ai" : "stats.dev.opencode.ai"
@@ -18,9 +23,13 @@ export async function statsProxy(evt: APIEvent) {
targetUrl.pathname = targetUrl.pathname.slice(dataPath.length)
}
const requestHeaders = new Headers(req.headers)
requestHeaders.set(LOCALE_HEADER, locale)
requestHeaders.set("accept-language", tag(locale))
const response = await fetch(targetUrl, {
method: req.method,
headers: req.headers,
headers: requestHeaders,
body: req.body,
})
@@ -30,6 +39,7 @@ export async function statsProxy(evt: APIEvent) {
headers.delete("content-encoding")
headers.delete("content-length")
headers.delete("etag")
appendVary(headers, "Accept-Language", "Cookie")
return new Response(rewriteStatsHtml(await response.text()), {
status: response.status,
@@ -52,3 +62,60 @@ export function statsRedirect(evt: APIEvent) {
function rewriteStatsHtml(html: string) {
return html.replaceAll('"/_build/', `"${dataPath}/_build/`).replaceAll("'/_build/", `'${dataPath}/_build/`)
}
function redirectToLocalizedData(request: Request, url: URL, locale: ReturnType<typeof localeFromRequest>) {
if (locale === "en") return null
if (request.headers.get(LOCALE_HEADER)) return null
if (request.method !== "GET" && request.method !== "HEAD") return null
if (!acceptsHtml(request)) return null
if (!url.pathname.startsWith(`${dataPath}/`) && url.pathname !== dataPath) return null
if (isDataBypassPath(url.pathname)) return null
const next = new URL(url)
next.pathname = route(locale, url.pathname)
const headers = new Headers({
Location: next.toString(),
})
headers.append("set-cookie", cookie(locale))
appendVary(headers, "Accept-Language", "Cookie")
return new Response(null, {
status: 308,
headers,
})
}
function acceptsHtml(request: Request) {
const accept = request.headers.get("accept")
return !accept || accept.includes("text/html") || accept.includes("*/*")
}
function isDataBypassPath(pathname: string) {
return (
pathname.startsWith(`${dataPath}/_build/`) ||
pathname.startsWith(`${dataPath}/api/`) ||
pathname.startsWith(`${dataPath}/_server`) ||
pathname === `${dataPath}/banner.jpg` ||
pathname === `${dataPath}/banner.png`
)
}
function appendVary(headers: Headers, ...values: string[]) {
const existing = headers
.get("vary")
?.split(",")
.map((value) => value.trim())
.filter(Boolean)
headers.set(
"vary",
values
.reduce(
(result, value) =>
result.some((item) => item.toLowerCase() === value.toLowerCase()) ? result : [...result, value],
existing ?? [],
)
.join(", "),
)
}
+1
View File
@@ -48,6 +48,7 @@ export const Flag = {
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
OPENCODE_EXPERIMENTAL_TAB_SCROLL: enabledByExperimental("OPENCODE_EXPERIMENTAL_TAB_SCROLL"),
// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
+276
View File
@@ -0,0 +1,276 @@
// Branded HTML pages for local OAuth callback servers.
//
// These are served by the loopback HTTP servers that finish an OAuth exchange
// (MCP, Codex/ChatGPT, xAI, Snowflake, DigitalOcean, ...). The functions return
// a fully self-contained HTML string with no external assets, so they work
// offline and drop into any transport (`res.end(...)`, Effect `response.end`,
// etc.).
//
// The visual language mirrors the OpenCode app: the design tokens are a curated
// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and
// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`.
// Keep this file in sync with those sources when the brand changes.
export interface CallbackPageOptions {
/** Friendly integration name shown as a subtitle, e.g. "xAI", "Snowflake", "MCP". */
provider?: string
/** Attempt to close the window shortly after success. Defaults to true. */
autoClose?: boolean
}
export function success(options?: CallbackPageOptions) {
const provider = options?.provider
return renderDocument({
title: "Authorization successful",
body: renderCard({
status: "success",
headline: "Authorization successful",
message: provider ? `OpenCode is now connected to ${escapeHtml(provider)}.` : "OpenCode is now authorized.",
footnote: "You can close this window.",
}),
script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT,
})
}
export function error(detail: string, options?: CallbackPageOptions) {
const provider = options?.provider
return renderDocument({
title: "Authorization failed",
body: renderCard({
status: "error",
headline: "Authorization failed",
message: provider
? `OpenCode couldn't finish connecting to ${escapeHtml(provider)}.`
: "OpenCode couldn't complete authorization.",
detail,
footnote: "Close this window and try again from OpenCode.",
}),
})
}
export interface BootstrapOptions {
/** Same-origin path the in-browser script POSTs the parsed callback to. */
tokenPath: string
provider?: string
}
// For flows where the credential arrives in the URL fragment (implicit grant),
// the browser must relay it back to the loopback server. This renders a pending
// page whose script reads the fragment, POSTs it to `tokenPath`, then resolves
// to the success or error state in place.
export function bootstrap(options: BootstrapOptions) {
return renderDocument({
title: "Finishing sign-in",
body: renderCard({
status: "pending",
headline: "Finishing sign-in",
message: options.provider
? `Completing your ${escapeHtml(options.provider)} authorization.`
: "Completing authorization.",
footnote: "You can close this window once sign-in finishes.",
}),
script: bootstrapScript(options),
})
}
export * as OauthCallbackPage from "./page"
type Status = "pending" | "success" | "error"
function renderCard(input: { status: Status; headline: string; message: string; detail?: string; footnote: string }) {
const detail = input.detail?.trim()
return `<main class="card" id="oc-card" data-status="${input.status}" role="status" aria-live="polite">
<div class="brand">${WORDMARK}</div>
<div class="status" aria-hidden="true">
<span class="icon icon-pending">${ICON_SPINNER}</span>
<span class="icon icon-success">${ICON_CHECK}</span>
<span class="icon icon-error">${ICON_CROSS}</span>
</div>
<h1 class="headline" id="oc-headline">${escapeHtml(input.headline)}</h1>
<p class="message" id="oc-message">${input.message}</p>
<pre class="detail" id="oc-detail"${detail ? "" : " hidden"}>${detail ? escapeHtml(detail) : ""}</pre>
<p class="footnote" id="oc-footnote">${escapeHtml(input.footnote)}</p>
</main>`
}
function renderDocument(input: { title: string; body: string; script?: string }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>${escapeHtml(input.title)} · OpenCode</title>
<style>${STYLES}</style>
</head>
<body>
${input.body}${input.script ? `\n <script>${input.script}</script>` : ""}
</body>
</html>`
}
const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)`
function bootstrapScript(options: BootstrapOptions) {
return `var PROVIDER=${scriptString(options.provider ?? "")};
var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href;
(function(){
var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote");
function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("OpenCode couldn't finish connecting to "+PROVIDER+"."):"OpenCode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from OpenCode."}
function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("OpenCode is now connected to "+PROVIDER+"."):"OpenCode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window.";setTimeout(function(){try{window.close()}catch(e){}},2500)}
try{
var hash=new URLSearchParams((window.location.hash||"").slice(1));
var search=new URLSearchParams(window.location.search||"");
var err=hash.get("error")||search.get("error");
var errDescription=hash.get("error_description")||search.get("error_description");
var body=err?{error:err,error_description:errDescription||""}:{access_token:hash.get("access_token")||"",expires_in:hash.get("expires_in")||"0",state:hash.get("state")||""};
fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){
if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))});
if(err){fail(errDescription||err);return}
ok();
}).catch(function(e){fail(String(e&&e.message?e.message:e))});
}catch(e){fail(String(e&&e.message?e.message:e))}
})()`
}
function scriptString(value: string) {
return JSON.stringify(value).replaceAll("<", "\\u003c")
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
// Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is
// light; dark applies via prefers-color-scheme. The [data-theme] selectors let a
// host force a scheme without changing the default.
const LIGHT_VARS = `
--oc-bg: #f8f8f8;
--oc-card: #fcfcfc;
--oc-text-strong: #171717;
--oc-text-base: #6f6f6f;
--oc-text-weak: #8f8f8f;
--oc-border-weak: #e5e5e5;
--oc-icon-strong: #171717;
--oc-icon-base: #8f8f8f;
--oc-icon-weak: #dbdbdb;
--oc-success: #2dba26;
--oc-error: #ed4831;
--oc-detail-bg: #fff8f6;
--oc-detail-border: #fdc3b7;
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.10), 0 6px 12px -2px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.06);`
const DARK_VARS = `
--oc-bg: #101010;
--oc-card: #161616;
--oc-text-strong: rgba(255,255,255,.936);
--oc-text-base: rgba(255,255,255,.618);
--oc-text-weak: rgba(255,255,255,.422);
--oc-border-weak: #282828;
--oc-icon-strong: #ededed;
--oc-icon-base: #7e7e7e;
--oc-icon-weak: #343434;
--oc-success: #12c905;
--oc-error: #fc533a;
--oc-detail-bg: #28110c;
--oc-detail-border: #6a1206;
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.55), 0 6px 12px -2px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.4);`
const STYLES = `
:root { color-scheme: light dark;${LIGHT_VARS}
--oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } }
:root[data-theme="dark"] {${DARK_VARS} }
:root[data-theme="light"] {${LIGHT_VARS} }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: var(--oc-bg);
color: var(--oc-text-base);
font-family: var(--oc-font-sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.card {
width: min(100%, 28rem);
padding: 2.25rem 2rem 1.75rem;
background: var(--oc-card);
border: 1px solid var(--oc-border-weak);
border-radius: 14px;
box-shadow: var(--oc-shadow);
text-align: center;
}
.brand { display: flex; justify-content: center; margin-bottom: 1.75rem; }
.brand svg { height: 19px; width: auto; }
.status { display: flex; justify-content: center; margin-bottom: 1.125rem; }
.icon { display: none; line-height: 0; }
.icon svg { display: block; }
.card[data-status="pending"] .icon-pending,
.card[data-status="success"] .icon-success,
.card[data-status="error"] .icon-error { display: block; }
.icon-success { color: var(--oc-success); }
.icon-error { color: var(--oc-error); }
.icon-pending { color: var(--oc-text-weak); }
.headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); }
.message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); }
.detail {
margin: 1.25rem 0 0;
padding: 0.75rem 0.875rem;
text-align: left;
font-family: var(--oc-font-mono);
font-size: 0.8125rem;
line-height: 1.55;
color: var(--oc-text-strong);
background: var(--oc-detail-bg);
border: 1px solid var(--oc-detail-border);
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
max-height: 9.5rem;
overflow: auto;
}
.detail[hidden] { display: none; }
.footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); }
.spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; }
@keyframes oc-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
`
// OpenCode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo).
const WORDMARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="OpenCode" role="img">
<path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" />
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" />
<path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" />
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="var(--oc-icon-base)" />
<path d="M84 24V30H66V24H84Z" fill="var(--oc-icon-weak)" />
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="var(--oc-icon-base)" />
<path d="M108 36H96V18H108V36Z" fill="var(--oc-icon-weak)" />
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="var(--oc-icon-base)" />
<path d="M144 30H126V18H144V30Z" fill="var(--oc-icon-weak)" />
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="var(--oc-icon-strong)" />
<path d="M168 30H156V18H168V30Z" fill="var(--oc-icon-weak)" />
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="var(--oc-icon-strong)" />
<path d="M198 30H186V18H198V30Z" fill="var(--oc-icon-weak)" />
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="var(--oc-icon-strong)" />
<path d="M234 24V30H216V24H234Z" fill="var(--oc-icon-weak)" />
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="var(--oc-icon-strong)" />
</svg>`
const ICON_CHECK = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m8.5 12.5 2.4 2.4 4.6-5.4" /></svg>`
const ICON_CROSS = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m9 9 6 6m0-6-6 6" /></svg>`
const ICON_SPINNER = `<svg class="spinner" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9" opacity="0.2" /><path d="M21 12a9 9 0 0 0-9-9" /></svg>`
+8 -8
View File
@@ -7,6 +7,7 @@ import { Credential } from "../../credential"
import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider"
import type { PluginInternal } from "../internal"
@@ -58,17 +59,21 @@ const browser = {
const value = url.searchParams.get("code")
if (error) {
Effect.runFork(Deferred.fail(code, new Error(error)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(error, { provider: "ChatGPT" }))
return
}
if (!value || url.searchParams.get("state") !== state) {
const message = value ? "Invalid OAuth state" : "Missing authorization code"
Effect.runFork(Deferred.fail(code, new Error(message)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(message, { provider: "ChatGPT" }))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "ChatGPT" }))
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
@@ -285,8 +290,3 @@ function claim(token: string) {
return
}
}
const successPage =
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
const errorPage = (message: string) =>
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
+60 -12
View File
@@ -52,6 +52,7 @@ function isSafeRelativePath(value: string) {
class IndexSkill extends Schema.Class<IndexSkill>("SkillDiscovery.IndexSkill")({
name: Schema.String,
version: Schema.optional(Schema.String),
files: Schema.Array(Schema.String),
}) {}
@@ -80,12 +81,15 @@ export const layer = Layer.effect(
)
const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) {
if (yield* fs.exists(destination).pipe(Effect.orDie)) return
yield* HttpClientRequest.get(url).pipe(
if (yield* fs.exists(destination).pipe(Effect.orDie)) return true
return yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })),
Effect.as(true),
Effect.catch((error) =>
Effect.logError("failed to download skill file", { url, error }).pipe(Effect.as(false)),
),
)
})
@@ -120,6 +124,7 @@ export const layer = Layer.effect(
}
const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source)
const versionFile = path.join(root, ".opencode-version")
const files = skill.files.map((file) => {
if (!isSafeRelativePath(file)) return undefined
let resource: URL
@@ -135,23 +140,66 @@ export const layer = Layer.effect(
return {
url: resource.href,
destination,
file,
}
})
if (files.some((file) => file === undefined)) {
return []
}
return [{ skill, root, files: files as { url: string; destination: string }[] }]
return [{ skill, root, versionFile, files: files as { url: string; destination: string; file: string }[] }]
}),
({ skill, root, files }) =>
({ skill, root, versionFile, files }) =>
Effect.gen(function* () {
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
concurrency: fileConcurrency,
discard: true,
})
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
const version = skill.version
const current =
version === undefined
? undefined
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (version === undefined || current === version) {
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
concurrency: fileConcurrency,
discard: true,
})
} else {
const token = crypto.randomUUID()
const staging = `${root}.tmp-${token}`
const backup = `${root}.old-${token}`
yield* Effect.gen(function* () {
const downloaded = yield* Effect.forEach(
files,
(file) => download(file.url, path.resolve(staging, file.file)),
{ concurrency: fileConcurrency },
)
if (!downloaded.every(Boolean)) return
const exists =
(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie)) ||
(yield* fs.exists(path.join(staging, `${skill.name}.md`)).pipe(Effect.orDie))
if (!exists) return
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
yield* Effect.uninterruptible(
Effect.gen(function* () {
const cached = yield* fs.exists(root).pipe(Effect.orDie)
if (cached) yield* fs.rename(root, backup)
yield* fs.rename(staging, root).pipe(
Effect.catch((error) =>
Effect.gen(function* () {
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
),
)
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
}),
)
}).pipe(
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
)
}
const exists =
(yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
? [AbsolutePath.make(root)]
: []
return exists ? [AbsolutePath.make(root)] : []
}),
{ concurrency: skillConcurrency },
).pipe(Effect.map((directories) => directories.flat()))
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test"
import { OauthCallbackPage } from "../src/oauth/page"
describe("OauthCallbackPage", () => {
test("escapes bootstrap options embedded in the inline script", () => {
const html = OauthCallbackPage.bootstrap({
provider: `xAI</script><script>alert("provider")</script>`,
tokenPath: `/token</script><script>alert("path")</script>`,
})
expect(html.match(/<\/script>/g)).toHaveLength(1)
expect(html).toContain(`xAI\\u003c/script>\\u003cscript>alert(\\\"provider\\\")\\u003c/script>`)
expect(html).toContain(`/token\\u003c/script>\\u003cscript>alert(\\\"path\\\")\\u003c/script>`)
})
})
+62 -2
View File
@@ -10,8 +10,8 @@ import { tmpdir } from "./fixture/tmpdir"
const base = "https://skills.example.test/catalog/"
async function pull(skills: unknown[], files: Record<string, string> = {}) {
const tmp = await tmpdir()
async function pull(skills: unknown[], files: Record<string, string> = {}, cache?: Awaited<ReturnType<typeof tmpdir>>) {
const tmp = cache ?? (await tmpdir())
const requests: string[] = []
const http = Layer.succeed(
HttpClient.HttpClient,
@@ -101,4 +101,64 @@ describe("SkillDiscovery.pull", () => {
await result.tmp[Symbol.asyncDispose]()
}
})
test("refreshes cached files when the version changes", async () => {
const tmp = await tmpdir()
try {
const first = await pull(
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
{
[`${base}deploy/SKILL.md`]: "# Old",
},
tmp,
)
const second = await pull(
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
{
[`${base}deploy/SKILL.md`]: "# New",
},
tmp,
)
expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New")
expect(second.requests).toContain(`${base}deploy/SKILL.md`)
const third = await pull(
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
{ [`${base}deploy/SKILL.md`]: "# Ignored" },
tmp,
)
expect(third.requests).toEqual([`${base}index.json`])
} finally {
await tmp[Symbol.asyncDispose]()
}
})
test("publishes complete updates and removes stale files", async () => {
const tmp = await tmpdir()
try {
const first = await pull(
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
{
[`${base}deploy/SKILL.md`]: "# Old",
[`${base}deploy/old.md`]: "old reference",
},
tmp,
)
const root = first.directories[0]
await pull(
[{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }],
{ [`${base}deploy/SKILL.md`]: "# Partial" },
tmp,
)
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old")
expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference")
await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp)
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New")
expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false)
} finally {
await tmp[Symbol.asyncDispose]()
}
})
})
+13 -5
View File
@@ -53,7 +53,7 @@ const OpenAIResponsesReasoningSummaryText = Schema.Struct({
const OpenAIResponsesReasoningItem = Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.String,
id: Schema.optionalKey(Schema.String),
summary: Schema.Array(OpenAIResponsesReasoningSummaryText),
encrypted_content: optionalNull(Schema.String),
})
@@ -101,6 +101,7 @@ type OpenAIResponsesReasoningInput = {
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
type OpenAIResponsesReasoningReplay = Omit<OpenAIResponsesReasoningInput, "id">
const OpenAIResponsesTool = Schema.Struct({
type: Schema.tag("function"),
@@ -366,7 +367,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
@@ -383,7 +384,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
flushText()
const reasoning = lowerReasoning(part)
if (!reasoning) continue
if (store !== false && reasoning.id) {
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
@@ -395,8 +396,13 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
existing.encrypted_content = reasoning.encrypted_content
continue
}
reasoningItems[reasoning.id] = reasoning
input.push(reasoning)
const replay = {
type: reasoning.type,
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
reasoningItems[reasoning.id] = replay
input.push(replay)
continue
}
if (part.type === "tool-call") {
@@ -974,6 +980,7 @@ export const route = Route.make({
endpoint,
auth,
transport: httpTransport,
defaults: { providerOptions: { openai: { store: false } } },
})
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
@@ -1001,6 +1008,7 @@ export const webSocketRoute = Route.make({
endpoint,
auth,
transport: webSocketTransport,
defaults: { providerOptions: { openai: { store: false } } },
})
export * as OpenAIResponses from "./openai-responses"
@@ -44,7 +44,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
},
"response": {
"status": 200,
@@ -50,6 +50,7 @@ describe("OpenAI Responses route", () => {
{ role: "system", content: "You are concise." },
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
],
store: false,
stream: true,
max_output_tokens: 20,
temperature: 0,
@@ -161,16 +162,16 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.updateRequest(request, {
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
"gpt-4.1-mini",
),
model: OpenAIResponses.webSocketRoute
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" }),
}),
)
expect(prepared.route).toBe("openai-responses-websocket")
expect(prepared.protocol).toBe("openai-responses")
expect(prepared.metadata).toEqual({ transport: "websocket-json" })
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", stream: true })
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
}),
)
@@ -356,7 +357,13 @@ describe("OpenAI Responses route", () => {
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
],
store: false,
stream: true,
max_output_tokens: undefined,
temperature: undefined,
tool_choice: undefined,
tools: undefined,
top_p: undefined,
})
}),
)
@@ -519,7 +526,6 @@ describe("OpenAI Responses route", () => {
},
{
type: "reasoning",
id: "rs_continuation_1",
encrypted_content: "encrypted-continuation-state",
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
},
@@ -864,7 +870,9 @@ describe("OpenAI Responses route", () => {
it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const response = yield* LLMClient.generate(
LLM.updateRequest(request, { providerOptions: { openai: { store: true } } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
@@ -925,12 +933,12 @@ describe("OpenAI Responses route", () => {
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(yield* Effect.promise(() => web.json())).toMatchObject({
const body = yield* Effect.promise(() => web.json())
expect(body).toMatchObject({
input: [
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
},
@@ -938,6 +946,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
})
expect(body.input[1]).not.toHaveProperty("id")
return input.respond(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -984,7 +993,6 @@ describe("OpenAI Responses route", () => {
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
@@ -1078,7 +1086,6 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
+1 -1
View File
@@ -598,7 +598,7 @@ describe("LLMClient tools", () => {
include: ["reasoning.encrypted_content"],
input: [
{ role: "user" },
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" },
{ type: "reasoning", summary: [], encrypted_content: "encrypted-state" },
{ type: "function_call", call_id: "call_1", name: "get_weather" },
{ type: "function_call_output", call_id: "call_1" },
],
+8 -1
View File
@@ -46,6 +46,7 @@ export class UnsupportedOperationError extends Schema.TaggedErrorClass<Unsupport
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
safeMessage: Schema.String,
service: Schema.optional(Schema.String),
errorName: Schema.optional(Schema.String),
}) {}
export type Error =
@@ -81,7 +82,13 @@ export function toRequestError(error: Error) {
case "ACPUnsupportedOperationError":
return RequestError.methodNotFound(error.method)
case "ACPServiceFailureError":
return RequestError.internalError({ service: error.service }, error.safeMessage)
return RequestError.internalError(
{
...(error.service ? { service: error.service } : {}),
...(error.errorName ? { errorName: error.errorName } : {}),
},
error.safeMessage,
)
}
}
+139 -9
View File
@@ -1,9 +1,16 @@
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import type {
AgentSideConnection,
PermissionOption,
RequestPermissionResponse,
ToolCallContent,
ToolCallLocation,
ToolCallUpdate,
} from "@agentclientprotocol/sdk"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { exists, readText } from "@/util/filesystem"
import type { ACPSession } from "./session"
import { toLocations, toToolKind, type ToolInput } from "./tool"
import { pendingToolCall, toLocations, type ToolInput } from "./tool"
import { Effect } from "effect"
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
@@ -54,14 +61,11 @@ export class Handler {
const result = await this.input.connection
.requestPermission({
sessionId: permission.sessionID,
toolCall: {
toolCall: await permissionToolCall({
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
toolName: permission.permission,
input: permission.metadata,
}),
options: permissionOptions,
})
.catch(async () => {
@@ -111,6 +115,107 @@ export class Handler {
}
}
async function permissionToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
}): Promise<ToolCallUpdate> {
const toolCall = pendingToolCall({
toolCallId: input.toolCallId,
toolName: input.toolName,
state: {
input: input.input,
title: permissionTitle(input.toolName, input.input),
},
})
const content = await permissionContent(input.toolName, input.input)
return {
...toolCall,
locations: permissionLocations(input.toolName, input.input),
...(content.length ? { content } : {}),
}
}
function permissionTitle(toolName: string, input: ToolInput) {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "external_directory":
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
case "webfetch":
return stringValue(input.url)
case "websearch":
return stringValue(input.query)
case "grep":
case "glob":
return stringValue(input.pattern)
case "read":
case "edit":
case "write":
return editTitle(input)
default:
return undefined
}
}
function editTitle(input: ToolInput) {
const files = fileMetadata(input)
if (files.length === 1) return files[0]?.relativePath ?? files[0]?.filePath
if (files.length > 1) return `${files.length} files`
return stringValue(input.filePath) ?? stringValue(input.filepath) ?? stringValue(input.path)
}
function permissionLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
const files = fileMetadata(input)
if (files.length) {
return Array.from(
new Set(files.flatMap((file) => [file.filePath, file.movePath].filter((path): path is string => !!path))),
(path) => ({ path }),
)
}
return toLocations(toolName, input)
}
async function permissionContent(toolName: string, input: ToolInput): Promise<ToolCallContent[]> {
if (toolName.toLocaleLowerCase() !== "edit") return []
const files = fileMetadata(input)
if (files.length) return diffContentForFiles(files)
const filepath = stringValue(input.filepath) ?? stringValue(input.filePath)
const diff = stringValue(input.diff)
if (!filepath || !diff) return []
const content = await diffContentForPatch(filepath, diff)
return content ? [content] : []
}
async function diffContentForFiles(files: PermissionFileMetadata[]) {
const content = await Promise.all(
files.map(async (file) => {
if (!file.patch) return []
const content = await diffContentForPatch(file.filePath, file.patch, file.movePath)
return content ? [content] : []
}),
)
return content.flat()
}
async function diffContentForPatch(filepath: string, diff: string, displayPath = filepath) {
const content = (await exists(filepath)) ? await readText(filepath) : ""
const next = applyPatch(content, diff)
if (next === false) return undefined
return {
type: "diff" as const,
path: displayPath,
oldText: content,
newText: next,
}
}
function selectedReply(result: RequestPermissionResponse): Reply {
if (result.outcome.outcome !== "selected") return "reject"
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
@@ -121,4 +226,29 @@ function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
type PermissionFileMetadata = {
readonly filePath: string
readonly relativePath?: string
readonly movePath?: string
readonly patch?: string
}
function fileMetadata(input: ToolInput): PermissionFileMetadata[] {
if (!Array.isArray(input.files)) return []
return input.files.flatMap((file): PermissionFileMetadata[] => {
if (!file || typeof file !== "object") return []
const info = file as Record<string, unknown>
const filePath = stringValue(info.filePath)
if (!filePath) return []
return [
{
filePath,
relativePath: stringValue(info.relativePath),
movePath: stringValue(info.movePath),
patch: stringValue(info.patch),
},
]
})
}
export * as ACPPermission from "./permission"
+57 -10
View File
@@ -30,7 +30,7 @@ import {
type SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import type { AssistantMessage, Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option"
@@ -314,7 +314,6 @@ export function make(input: {
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
yield* replayMessages(events, messages)
return {
configOptions: configOptions(snapshot, {
@@ -521,7 +520,7 @@ export function make(input: {
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId)
return yield* promptResponse(response.info, params.messageId)
}
const known = snapshot.availableCommands.find((item) => item.name === command.name)
@@ -543,7 +542,7 @@ export function make(input: {
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId)
return yield* promptResponse(response.info, params.messageId)
}
if (command.name === "compact") {
@@ -563,7 +562,7 @@ export function make(input: {
}
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(undefined, params.messageId)
return yield* promptResponse(undefined, params.messageId)
}),
cancel,
}
@@ -695,7 +694,8 @@ type MessageInfo = {
readonly agent?: Message["agent"]
}
type AssistantInfo = UsageService.AssistantTokenCost | undefined
type AssistantError = NonNullable<AssistantMessage["error"]>
type AssistantInfo = (UsageService.AssistantTokenCost & Pick<AssistantMessage, "error">) | undefined
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return Effect.tryPromise({
@@ -811,13 +811,60 @@ function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
return { name, args: rest.join(" ").trim() }
}
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
return {
stopReason: "end_turn",
...(info ? { usage: UsageService.buildUsage(info) } : {}),
const promptResponse = Effect.fn("ACP.promptResponse")(function* (
info: AssistantInfo,
messageId: string | null | undefined,
) {
if (!info?.error) {
return {
stopReason: "end_turn" as const,
...(info ? { usage: UsageService.buildUsage(info) } : {}),
...(messageId ? { userMessageId: messageId } : {}),
_meta: {},
}
}
const base = {
usage: UsageService.buildUsage(info),
...(messageId ? { userMessageId: messageId } : {}),
_meta: {},
}
if (info.error.name === "MessageAbortedError") {
return {
stopReason: "cancelled" as const,
...base,
}
}
if (info.error.name === "MessageOutputLengthError") {
return {
stopReason: "max_tokens" as const,
...base,
}
}
if (info.error.name === "ContentFilterError") {
return {
stopReason: "refusal" as const,
...base,
}
}
if (info.error.name === "ProviderAuthError") {
return yield* new ACPError.AuthRequiredError({ providerId: info.error.data.providerID })
}
return yield* new ACPError.ServiceFailureError({
service: "session",
safeMessage: promptErrorMessage(info.error),
errorName: info.error.name,
})
})
function promptErrorMessage(error: AssistantError) {
if ("message" in error.data && typeof error.data.message === "string") return error.data.message
return "OpenCode prompt failed"
}
function sendUsageUpdate(
+1 -4
View File
@@ -192,11 +192,8 @@ export function completedToolUpdate(input: {
return {
toolCallId: input.toolCallId,
status: "completed",
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.state.input, input.state.title),
locations: toLocations(input.toolName, input.state.input, input.cwd),
...(input.state.title ? { title: input.state.title } : {}),
content: completedToolContent(input.toolName, input.state),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
rawOutput: completedToolRawOutput(input.state),
}
}
+9 -1
View File
@@ -1,4 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import path from "path"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import type { InstanceContext } from "@/project/instance-context"
@@ -132,12 +133,19 @@ export const layer = Layer.effect(
for (const item of yield* skill.all()) {
if (commands[item.name]) continue
const dir = item.location === "<built-in>" ? undefined : path.dirname(item.location)
commands[item.name] = {
name: item.name,
description: item.description,
source: "skill",
get template() {
return item.content
if (!dir) return item.content
return [
item.content,
"",
`Base directory for this skill: ${dir}`,
"Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.",
].join("\n")
},
hints: [],
}
+13 -11
View File
@@ -22,7 +22,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { withTimeout } from "@/util/timeout"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
import { McpOAuthPendingProvider, McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
import { McpOAuthCallback } from "./oauth-callback"
import { McpAuth } from "./auth"
import { EventV2Bridge } from "@/event-v2-bridge"
@@ -109,7 +109,7 @@ export type Status = Schema.Schema.Type<typeof Status>
// Store transports for OAuth servers to allow finishing auth
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
const pendingOAuthTransports = new Map<string, { transport: TransportWithAuth; provider?: McpOAuthPendingProvider }>()
// Prompt cache types
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
@@ -301,7 +301,7 @@ export const layer = Layer.effect(
})
.pipe(Effect.ignore, Effect.as(undefined))
} else {
pendingOAuthTransports.set(key, transport)
pendingOAuthTransports.set(key, { transport })
lastStatus = { status: "needs_auth" as const }
return events
.publish(TuiEvent.ToastShow, {
@@ -819,7 +819,7 @@ export const layer = Layer.effect(
.join("")
yield* auth.updateOAuthState(mcpName, oauthState)
let capturedUrl: URL | undefined
const authProvider = new McpOAuthProvider(
const authProvider = new McpOAuthPendingProvider(
mcpName,
mcpConfig.url,
{
@@ -845,15 +845,16 @@ export const layer = Layer.effect(
return yield* Effect.tryPromise({
try: () => {
const client = createClient(directory)
return client
.connect(transport)
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
return client.connect(transport).then(async () => {
await authProvider.commit()
return { authorizationUrl: "", oauthState, client } satisfies AuthResult
})
},
catch: (error) => error,
}).pipe(
Effect.catch((error) => {
if (error instanceof UnauthorizedError && capturedUrl) {
pendingOAuthTransports.set(mcpName, transport)
pendingOAuthTransports.set(mcpName, { transport, provider: authProvider })
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult)
}
return Effect.die(error)
@@ -924,11 +925,11 @@ export const layer = Layer.effect(
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
yield* requireMcpConfig(mcpName)
const transport = pendingOAuthTransports.get(mcpName)
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
const pending = pendingOAuthTransports.get(mcpName)
if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
const result = yield* Effect.tryPromise({
try: () => transport.finishAuth(authorizationCode).then(() => true as const),
try: () => pending.transport.finishAuth(authorizationCode).then(() => true as const),
catch: (error) => {
return error
},
@@ -938,6 +939,7 @@ export const layer = Layer.effect(
return { status: "failed", error: "OAuth completion failed" } satisfies Status
}
yield* Effect.promise(() => pending.provider?.commit() ?? Promise.resolve())
yield* auth.clearCodeVerifier(mcpName)
pendingOAuthTransports.delete(mcpName)
+6 -47
View File
@@ -1,6 +1,6 @@
import { createConnection } from "net"
import { createServer } from "http"
import { escapeHtml } from "@/util/html"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
const OAUTH_CALLBACK_HOST = "127.0.0.1"
@@ -9,47 +9,6 @@ const OAUTH_CALLBACK_HOST = "127.0.0.1"
let currentPort = OAUTH_CALLBACK_PORT
let currentPath = OAUTH_CALLBACK_PATH
const HTML_SUCCESS = `<!DOCTYPE html>
<html>
<head>
<title>OpenCode - Authorization Successful</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
.container { text-align: center; padding: 2rem; }
h1 { color: #4ade80; margin-bottom: 1rem; }
p { color: #aaa; }
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>`
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
<html>
<head>
<title>OpenCode - Authorization Failed</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
.container { text-align: center; padding: 2rem; }
h1 { color: #f87171; margin-bottom: 1rem; }
p { color: #aaa; }
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
interface PendingAuth {
resolve: (code: string) => void
reject: (error: Error) => void
@@ -98,7 +57,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg))
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
return
}
@@ -112,14 +71,14 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg))
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
stopIfIdle()
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR("No authorization code provided"))
res.end(OauthCallbackPage.error("No authorization code provided", { provider: "MCP" }))
return
}
@@ -127,7 +86,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg))
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
return
}
@@ -139,7 +98,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_SUCCESS)
res.end(OauthCallbackPage.success({ provider: "MCP" }))
stopIfIdle()
}
+62 -9
View File
@@ -25,11 +25,11 @@ export interface McpOAuthCallbacks {
export class McpOAuthProvider implements OAuthClientProvider {
constructor(
private mcpName: string,
private serverUrl: string,
private config: McpOAuthConfig,
protected mcpName: string,
protected serverUrl: string,
protected config: McpOAuthConfig,
private callbacks: McpOAuthCallbacks,
private auth: McpAuth.Interface,
protected auth: McpAuth.Interface,
) {}
get redirectUrl(): string {
@@ -53,7 +53,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
async clientInformation(): Promise<OAuthClientInformation | undefined> {
// Check config first (pre-registered client)
if (this.config.clientId) {
return {
client_id: this.config.clientId,
@@ -164,10 +163,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
if (!entry) {
return
}
if (!entry) return
switch (type) {
case "all":
await Effect.runPromise(this.auth.remove(this.mcpName))
@@ -184,6 +180,63 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
}
export class McpOAuthPendingProvider extends McpOAuthProvider {
private pendingClientInfo?: OAuthClientInformationFull
private pendingTokens?: OAuthTokens
override async clientInformation(): Promise<OAuthClientInformation | undefined> {
if (!this.config.clientId) return this.pendingClientInfo
return {
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}
}
override async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
this.pendingClientInfo = info
}
override async tokens(): Promise<OAuthTokens | undefined> {
return this.pendingTokens
}
override async saveTokens(tokens: OAuthTokens): Promise<void> {
this.pendingTokens = tokens
}
override async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
if (type === "all" || type === "client") this.pendingClientInfo = undefined
if (type === "all" || type === "tokens") this.pendingTokens = undefined
}
async commit(): Promise<void> {
if (!this.pendingTokens) return
await Effect.runPromise(
this.auth.set(
this.mcpName,
{
tokens: {
accessToken: this.pendingTokens.access_token,
refreshToken: this.pendingTokens.refresh_token,
expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined,
scope: this.pendingTokens.scope,
},
clientInfo:
this.pendingClientInfo && !this.config.clientId
? {
clientId: this.pendingClientInfo.client_id,
clientSecret: this.pendingClientInfo.client_secret,
clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at,
clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at,
}
: undefined,
},
this.serverUrl,
),
)
}
}
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
/**
+2 -60
View File
@@ -1,6 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { createServer } from "http"
import open from "open"
@@ -58,65 +59,6 @@ function buildAuthorizeUrl(state: string): string {
return `${DO_AUTHORIZE_URL}?${params.toString()}`
}
const HTML_CALLBACK = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>OpenCode - DigitalOcean Authorization</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0b1220; color: #e8eef9; }
.container { text-align: center; padding: 2rem; max-width: 32rem; }
h1 { color: #e8eef9; margin-bottom: 1rem; }
p { color: #9aa9c0; }
.error { color: #ff917b; font-family: monospace; margin-top: 1rem; padding: 1rem; background: #3c140d; border-radius: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<h1 id="title">Finishing sign-in...</h1>
<p id="msg">You can close this window once it says you're signed in.</p>
</div>
<script>
(async function() {
const params = new URLSearchParams((window.location.hash || "").slice(1))
const search = new URLSearchParams(window.location.search)
const error = params.get("error") || search.get("error")
const errorDescription = params.get("error_description") || search.get("error_description")
const titleEl = document.getElementById("title")
const msgEl = document.getElementById("msg")
const tokenUrl = new URL(${JSON.stringify(OAUTH_TOKEN_PATH)}, window.location.origin).href
try {
const body = error
? { error, error_description: errorDescription || "" }
: { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const detail = await res.text().catch(function () { return "" })
throw new Error(detail || ("callback failed (" + res.status + ")"))
}
if (error) {
titleEl.textContent = "Authorization Failed"
msgEl.textContent = errorDescription || error
msgEl.className = "error"
return
}
titleEl.textContent = "Authorization Successful"
msgEl.textContent = "You can close this window and return to OpenCode."
setTimeout(function () { window.close() }, 2000)
} catch (e) {
titleEl.textContent = "Authorization Failed"
msgEl.textContent = String(e && e.message ? e.message : e)
msgEl.className = "error"
}
})()
</script>
</body>
</html>`
async function startOAuthServer(): Promise<void> {
if (oauthServer) return
oauthServer = createServer((req, res) => {
@@ -124,7 +66,7 @@ async function startOAuthServer(): Promise<void> {
if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) {
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_CALLBACK)
res.end(OauthCallbackPage.bootstrap({ tokenPath: OAUTH_TOKEN_PATH, provider: "DigitalOcean" }))
return
}
+4 -91
View File
@@ -5,7 +5,7 @@ import os from "os"
import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
import { OpenAIWebSocketPool } from "./ws-pool"
import { escapeHtml } from "@/util/html"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const ISSUER = "https://auth.openai.com"
@@ -138,95 +138,8 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis
return response.json()
}
const HTML_SUCCESS = `<!doctype html>
<html>
<head>
<title>OpenCode - Codex Authorization Successful</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #f1ecec;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>
setTimeout(() => window.close(), 2000)
</script>
</body>
</html>`
export const renderOAuthError = (error: string) => `<!doctype html>
<html>
<head>
<title>OpenCode - Codex Authorization Failed</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #fc533a;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
.error {
color: #ff917b;
font-family: monospace;
margin-top: 1rem;
padding: 1rem;
background: #3c140d;
border-radius: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
// Kept as a named export for plugin.codex tests; delegates to the shared branded page.
export const renderOAuthError = (error: string) => OauthCallbackPage.error(error, { provider: "ChatGPT" })
interface PendingOAuth {
pkce: PkceCodes
@@ -287,7 +200,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
.catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_SUCCESS)
res.end(OauthCallbackPage.success({ provider: "ChatGPT" }))
return
}
@@ -1,6 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { OAUTH_DUMMY_KEY } from "../auth"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { createServer } from "http"
import open from "open"
@@ -156,29 +157,6 @@ async function refreshAccessToken(account: string, refreshToken: string) {
return token
}
const HTML_SUCCESS = `<!doctype html>
<html>
<head><title>OpenCode - Snowflake Authorization Successful</title></head>
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
<div style="text-align:center; max-width:36rem; padding:2rem;">
<h1 style="color:#7ee787;">Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>setTimeout(() => window.close(), 1500)</script>
</body>
</html>`
const htmlError = (message: string) => `<!doctype html>
<html>
<head><title>OpenCode - Snowflake Authorization Failed</title></head>
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
<div style="text-align:center; max-width:48rem; padding:2rem;">
<h1 style="color:#ff7b72;">Authorization Failed</h1>
<pre style="white-space:pre-wrap; color:#ffb3ad; background:#2a1210; padding:1rem; border-radius:.5rem;">${message}</pre>
</div>
</body>
</html>`
async function startOAuthServer() {
if (oauthServer) return
@@ -203,7 +181,7 @@ async function startOAuthServer() {
pendingOAuth?.reject(new Error(message))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(htmlError(message))
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
return
}
@@ -214,7 +192,7 @@ async function startOAuthServer() {
const message = errorDescription || error
current.reject(new Error(message))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(htmlError(message))
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
return
}
@@ -222,7 +200,7 @@ async function startOAuthServer() {
const message = "Missing authorization code"
current.reject(new Error(message))
res.writeHead(400, { "Content-Type": "text/html" })
res.end(htmlError(message))
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
return
}
@@ -231,7 +209,7 @@ async function startOAuthServer() {
.catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
res.end(OauthCallbackPage.success({ provider: "Snowflake" }))
})
await new Promise<void>((resolve, reject) => {
+5 -95
View File
@@ -2,7 +2,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { OAUTH_DUMMY_KEY } from "../auth"
import { createServer } from "http"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { escapeHtml } from "@/util/html"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
@@ -285,96 +285,6 @@ export async function pollDeviceCodeToken(
throw new Error("xAI device authorization timed out")
}
const HTML_SUCCESS = `<!doctype html>
<html>
<head>
<title>OpenCode - xAI Authorization Successful</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #f1ecec;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>
setTimeout(() => window.close(), 2000)
</script>
</body>
</html>`
const HTML_ERROR = (error: string) => `<!doctype html>
<html>
<head>
<title>OpenCode - xAI Authorization Failed</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #fc533a;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
.error {
color: #ff917b;
font-family: monospace;
margin-top: 1rem;
padding: 1rem;
background: #3c140d;
border-radius: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
// CORS allowlist for the loopback callback. The redirect_uri itself is
// already bound to 127.0.0.1 and gated by PKCE+state, so we only accept
// xAI's own auth origins for additional defense-in-depth on the OPTIONS
@@ -425,7 +335,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
return
}
@@ -434,7 +344,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
return
}
@@ -443,7 +353,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
return
}
@@ -455,7 +365,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
.catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
res.end(OauthCallbackPage.success({ provider: "xAI" }))
return
}
+47 -10
View File
@@ -13,6 +13,7 @@ const fileConcurrency = 8
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
name: Schema.String,
files: Schema.Array(Schema.String),
version: Schema.optional(Schema.String),
}) {}
class Index extends Schema.Class<Index>("Index")({
@@ -76,17 +77,53 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | Htt
(skill) =>
Effect.gen(function* () {
const root = path.join(cache, skill.name)
const versionFile = path.join(root, ".opencode-version")
const version = skill.version
const current =
version === undefined
? undefined
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{
concurrency: fileConcurrency,
},
)
const md = path.join(root, "SKILL.md")
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
if (version === undefined || current === version) {
yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{ concurrency: fileConcurrency, discard: true },
)
} else {
const token = crypto.randomUUID()
const staging = `${root}.tmp-${token}`
const backup = `${root}.old-${token}`
yield* Effect.gen(function* () {
const downloaded = yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(staging, file)),
{ concurrency: fileConcurrency },
)
if (!downloaded.every(Boolean)) return
if (!(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie))) return
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
yield* Effect.uninterruptible(
Effect.gen(function* () {
const cached = yield* fs.exists(root).pipe(Effect.orDie)
if (cached) yield* fs.rename(root, backup)
yield* fs.rename(staging, root).pipe(
Effect.catch((error) =>
Effect.gen(function* () {
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
),
)
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
}),
)
}).pipe(
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
)
}
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ? root : null
}),
{ concurrency: skillConcurrency },
)
+2 -2
View File
@@ -1,6 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, Context, Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import type { Agent } from "@/agent/agent"
@@ -17,6 +16,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { Glob } from "@opencode-ai/core/util/glob"
import { Discovery } from "./discovery"
import { isRecord } from "@/util/record"
import { escapeHtml } from "@/util/html"
const CLAUDE_EXTERNAL_DIR = ".claude"
const AGENTS_EXTERNAL_DIR = ".agents"
@@ -339,7 +339,7 @@ export function fmt(list: Info[], opts: { verbose: boolean }) {
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
` <location>${escapeHtml(skill.location)}</location>`,
" </skill>",
]),
"</available_skills>",
+130 -3
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "bun:test"
import { afterEach, describe, expect, it } from "bun:test"
import type {
AgentSideConnection,
RequestPermissionRequest,
@@ -6,13 +6,22 @@ import type {
SessionUpdate,
} from "@agentclientprotocol/sdk"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { createTwoFilesPatch } from "diff"
import { Effect, ManagedRuntime } from "effect"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { ACPEvent } from "@/acp/event"
import { ACPSession } from "@/acp/session"
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
const cleanupDirs: string[] = []
afterEach(async () => {
await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
})
const pollUntil = async (
check: () => boolean | Promise<boolean>,
@@ -137,6 +146,14 @@ function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
.join("")
}
async function tempFile(name: string, content: string) {
const dir = await mkdtemp(path.join(tmpdir(), "opencode-acp-permission-"))
cleanupDirs.push(dir)
const file = path.join(dir, name)
await Bun.write(file, content)
return file
}
describe("acp permissions", () => {
it("sends requestPermission and replies with the selected outcome", async () => {
const harness = createHarness()
@@ -151,7 +168,7 @@ describe("acp permissions", () => {
toolCall: {
toolCallId: "call_1",
status: "pending",
title: "bash",
title: "printf hello",
rawInput: { command: "printf hello" },
kind: "execute",
locations: [],
@@ -165,6 +182,116 @@ describe("acp permissions", () => {
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
})
it("uses permission metadata for non-shell titles", async () => {
const harness = createHarness()
await createSession(harness.session, "ses_a")
harness.subscription.handle(
permissionAsked("ses_a", "perm_fetch", {
permission: "webfetch",
metadata: {
url: "https://example.com/docs",
format: "markdown",
},
tool: { messageID: "msg_1", callID: "call_1" },
}),
)
await pollUntil(() => harness.replies.length === 1, "webfetch permission was never replied")
expect(harness.requests[0]?.toolCall).toMatchObject({
toolCallId: "call_1",
title: "https://example.com/docs",
kind: "fetch",
rawInput: { url: "https://example.com/docs", format: "markdown" },
})
})
it("includes a diff content block for edit permission metadata", async () => {
const filepath = await tempFile("file.ts", "before\n")
const harness = createHarness()
await createSession(harness.session, "ses_a")
harness.subscription.handle(
permissionAsked("ses_a", "perm_edit", {
permission: "edit",
metadata: {
filepath,
diff: createTwoFilesPatch(filepath, filepath, "before\n", "after\n"),
},
tool: { messageID: "msg_1", callID: "call_1" },
}),
)
await pollUntil(() => harness.replies.length === 1, "edit permission was never replied")
expect(harness.requests[0]?.toolCall).toMatchObject({
toolCallId: "call_1",
title: filepath,
kind: "edit",
locations: [{ path: filepath }],
content: [
{
type: "diff",
path: filepath,
oldText: "before\n",
newText: "after\n",
},
],
})
})
it("includes per-file diff blocks and locations for apply_patch permission metadata", async () => {
const first = await tempFile("first.ts", "one\n")
const second = await tempFile("second.ts", "alpha\n")
const harness = createHarness()
await createSession(harness.session, "ses_a")
harness.subscription.handle(
permissionAsked("ses_a", "perm_patch", {
permission: "edit",
metadata: {
filepath: "first.ts, second.ts",
files: [
{
filePath: first,
relativePath: "first.ts",
patch: createTwoFilesPatch(first, first, "one\n", "two\n"),
},
{
filePath: second,
relativePath: "second.ts",
patch: createTwoFilesPatch(second, second, "alpha\n", "beta\n"),
},
],
},
tool: { messageID: "msg_1", callID: "call_1" },
}),
)
await pollUntil(() => harness.replies.length === 1, "apply_patch permission was never replied")
expect(harness.requests[0]?.toolCall).toMatchObject({
toolCallId: "call_1",
title: "2 files",
locations: [{ path: first }, { path: second }],
content: [
{
type: "diff",
path: first,
oldText: "one\n",
newText: "two\n",
},
{
type: "diff",
path: second,
oldText: "alpha\n",
newText: "beta\n",
},
],
})
})
it("forwards external_directory metadata and locations to requestPermission", async () => {
const harness = createHarness()
await createSession(harness.session, "ses_a")
@@ -189,7 +316,7 @@ describe("acp permissions", () => {
toolCall: {
toolCallId: "call_1",
status: "pending",
title: "external_directory",
title: "Create external directory",
rawInput: {
command: "mkdir -p /tmp/outside",
description: "Create external directory",
@@ -10,7 +10,7 @@ import type {
SessionConfigSelectOption,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import type { AssistantMessage, OpencodeClient } from "@opencode-ai/sdk/v2"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Effect } from "effect"
@@ -144,7 +144,10 @@ const provider: Provider.Info = {
describe("ACP service sessions", () => {
const makeService = (
messages: readonly { info: unknown; parts: readonly unknown[] }[] = [],
options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> },
options?: {
abort?: (input: { sessionID: string }) => Promise<{ data: boolean }>
prompt?: (input: unknown) => Promise<{ data: { info: ReturnType<typeof assistantInfo> } }>
},
) => {
const updates: SessionNotification[] = []
const mcpAdds: string[] = []
@@ -193,19 +196,21 @@ describe("ACP service sessions", () => {
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
}),
messages: () => Promise.resolve({ data: messages }),
prompt: (input: unknown) => {
prompts.push(input)
return Promise.resolve({
data: {
info: assistantInfo({
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
}),
},
})
},
prompt:
options?.prompt ??
((input: unknown) => {
prompts.push(input)
return Promise.resolve({
data: {
info: assistantInfo({
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
}),
},
})
}),
command: (input: unknown) => {
commands.push(input)
return Promise.resolve({
@@ -389,15 +394,29 @@ describe("ACP service sessions", () => {
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_2", "ses_1"])
})
it("resumes a session and stores restored state", async () => {
const { service } = makeService([
it("resumes a session and stores restored state without replaying transcript chunks", async () => {
const { service, updates } = makeService([
{
info: {
id: "msg_user",
sessionID: "ses_resume",
role: "user",
model: { providerID: "test", modelID: "test-model", variant: "high" },
agent: "plan",
},
parts: [],
parts: [{ id: "part_user", sessionID: "ses_resume", messageID: "msg_user", type: "text", text: "hello" }],
},
{
info: { id: "msg_assistant", sessionID: "ses_resume", role: "assistant" },
parts: [
{
id: "part_assistant",
sessionID: "ses_resume",
messageID: "msg_assistant",
type: "text",
text: "hi there",
},
],
},
])
const resumed = await Effect.runPromise(
@@ -409,6 +428,11 @@ describe("ACP service sessions", () => {
expect(select(resumed, "effort")?.currentValue).toBe("high")
expect(select(updated, "effort")?.currentValue).toBe("default")
expect(
updates
.map((item) => item.update)
.filter((item) => item.sessionUpdate === "user_message_chunk" || item.sessionUpdate === "agent_message_chunk"),
).toEqual([])
})
it("closes local ACP state and aborts the backing session best-effort", async () => {
@@ -994,6 +1018,52 @@ describe("ACP service sessions", () => {
expect(usageUpdates).toEqual([session.sessionId])
})
it("maps assistant prompt errors to request errors instead of end turn", async () => {
const { service } = makeService([], {
prompt: () =>
Promise.resolve({
data: {
info: assistantInfo(
{ input: 8, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
{ name: "APIError", data: { message: "Provider request failed", isRetryable: false } },
),
},
}),
})
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const error = await Effect.runPromise(
service
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
.pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
)
expect(error.code).toBe(-32603)
expect(error.message).toBe("Internal error: Provider request failed")
expect(error.data).toEqual({ service: "session", errorName: "APIError" })
})
it("maps aborted assistant prompt errors to cancelled", async () => {
const { service } = makeService([], {
prompt: () =>
Promise.resolve({
data: {
info: assistantInfo(
{ input: 8, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
{ name: "MessageAbortedError", data: { message: "Aborted" } },
),
},
}),
})
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const result = await Effect.runPromise(
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }),
)
expect(result.stopReason).toBe("cancelled")
})
it("prompt maps assistant and user audience annotations", async () => {
const { service, prompts } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@@ -1145,13 +1215,17 @@ describe("ACP service sessions", () => {
})
})
function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage {
function assistantInfo(
tokens: UsageService.AssistantTokenCost["tokens"],
error?: AssistantMessage["error"],
): UsageService.AssistantMessage & Pick<AssistantMessage, "error"> {
return {
role: "assistant",
providerID: "test",
modelID: "test-model",
cost: 0,
tokens,
...(error ? { error } : {}),
}
}
+81
View File
@@ -2,9 +2,11 @@ import { resolve } from "path"
import { describe, expect, test } from "bun:test"
import {
completedToolContent,
completedToolUpdate,
completedToolRawOutput,
extractImageAttachments,
imageContents,
pendingToolCall,
shellOutputSnapshot,
toLocations,
toToolKind,
@@ -111,6 +113,85 @@ describe("acp tool conversion", () => {
])
})
test("sends completed tool calls as partial updates", () => {
expect(
pendingToolCall({
toolCallId: "tool-1",
toolName: "edit",
state: {
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
},
}),
).toMatchObject({
kind: "edit",
locations: [{ path: "/tmp/file.ts" }],
rawInput: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
})
expect(
completedToolUpdate({
toolCallId: "tool-1",
toolName: "edit",
state: {
status: "completed",
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
output: "Edit applied successfully.",
},
}),
).toEqual({
toolCallId: "tool-1",
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: "Edit applied successfully." },
},
{
type: "diff",
path: "/tmp/file.ts",
oldText: "before",
newText: "after",
},
],
rawOutput: {
output: "Edit applied successfully.",
},
})
expect(
completedToolUpdate({
toolCallId: "tool-1",
toolName: "edit",
state: {
status: "completed",
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
title: "file.ts",
output: "Edit applied successfully.",
},
}),
).toMatchObject({
toolCallId: "tool-1",
status: "completed",
title: "file.ts",
})
})
test("uses clean read display text for completed content", () => {
const output = [
"<path>/tmp/file.ts</path>",
@@ -33,7 +33,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0812d6cbe7a2b19b016a1214d32f6881998bcd9ff2e739d7f2\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}"
},
"response": {
"status": 200,
@@ -35,7 +35,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0fdce240b46054ad016a1214d326848196b269feebe1844759\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
},
"response": {
"status": 200,
@@ -23,6 +23,8 @@ let simulateAuthFlow = true
let connectSucceedsImmediately = false
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
let listToolsCalls = 0
let finishAuthFails = false
let finishAuthStoresCredentials = false
// Mock the transport constructors to simulate OAuth auto-auth on 401
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
@@ -32,6 +34,10 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
state?: () => Promise<string>
redirectToAuthorization?: (url: URL) => Promise<void>
saveCodeVerifier?: (v: string) => Promise<void>
tokens?: () => Promise<{ access_token: string } | undefined>
clientInformation?: () => Promise<{ client_id: string } | undefined>
saveClientInformation?: (info: { client_id: string; client_secret?: string }) => Promise<void>
saveTokens?: (tokens: { access_token: string; token_type: string }) => Promise<void>
}
| undefined
constructor(url: URL, options?: { authProvider?: unknown }) {
@@ -49,6 +55,8 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
// It calls auth() which eventually calls provider.state(), then
// provider.redirectToAuthorization(), then throws UnauthorizedError.
if (simulateAuthFlow && this.authProvider) {
if (await this.authProvider.tokens?.()) throw new MockUnauthorizedError()
if (await this.authProvider.clientInformation?.()) throw new MockUnauthorizedError()
// The SDK calls provider.state() to get the OAuth state parameter
if (this.authProvider.state) {
await this.authProvider.state()
@@ -65,7 +73,14 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
}
throw new MockUnauthorizedError()
}
async finishAuth(_code: string) {}
async finishAuth(_code: string) {
if (finishAuthFails) throw new Error("Token exchange failed")
if (finishAuthStoresCredentials) {
await this.authProvider?.saveClientInformation?.({ client_id: "replacement-client" })
await this.authProvider?.saveTokens?.({ access_token: "replacement-token", token_type: "Bearer" })
}
}
async close() {}
},
}))
@@ -125,6 +140,8 @@ beforeEach(() => {
connectSucceedsImmediately = false
serverCapabilities = { tools: {} }
listToolsCalls = 0
finishAuthFails = false
finishAuthStoresCredentials = false
})
// Import modules after mocking
@@ -133,6 +150,7 @@ const { EventV2Bridge } = await import("../../src/event-v2-bridge")
const { Config } = await import("../../src/config/config")
const { McpAuth } = await import("../../src/mcp/auth")
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
const { FSUtil } = await import("@opencode-ai/core/fs-util")
const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
@@ -227,6 +245,59 @@ mcpTest.instance("state() returns existing state when one is saved", () =>
}),
)
mcpTest.instance(
"failed reauthentication preserves existing credentials",
() =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
const name = "test-reauth-failure"
const url = "https://example.com/mcp"
const clientInfo = { clientId: "dynamic-client", clientSecret: "dynamic-secret" }
yield* auth.updateClientInfo(name, clientInfo, url)
yield* auth.updateTokens(name, { accessToken: "working-token" }, url)
expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
finishAuthFails = true
expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({
status: "failed",
error: "OAuth completion failed",
})
const entry = yield* auth.get(name)
expect(entry?.tokens?.accessToken).toBe("working-token")
expect(entry?.clientInfo).toEqual(clientInfo)
}),
{ config: config("test-reauth-failure") },
)
mcpTest.instance(
"successful reauthentication commits replacement credentials",
() =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
const name = "test-reauth-success"
const url = "https://example.com/mcp"
yield* auth.updateClientInfo(name, { clientId: "old-client" }, url)
yield* auth.updateTokens(name, { accessToken: "old-token" }, url)
expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token")
finishAuthStoresCredentials = true
connectSucceedsImmediately = true
expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected")
const entry = yield* auth.get(name)
expect(entry?.tokens?.accessToken).toBe("replacement-token")
expect(entry?.clientInfo?.clientId).toBe("replacement-client")
expect(entry?.serverUrl).toBe(url)
}),
{ config: config("test-reauth-success") },
)
mcpTest.instance(
"auth status only reports credentials stored for the configured server URL",
() =>
@@ -101,7 +101,7 @@ describe("McpOAuthCallback.ensureRunning", () => {
`${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`,
)
expect(await response.text()).toContain('<div class="error">The user denied access</div>')
expect(await response.text()).toContain('<pre class="detail" id="oc-detail">The user denied access</pre>')
})
test("binds the callback server to IPv4 loopback", async () => {
@@ -115,10 +115,9 @@ const storedSession = {
const openAIResponses = {
user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
openaiReasoning: (text: string, options: { readonly itemId: string; readonly encryptedContent: string }) => ({
openaiReasoning: (text: string, encryptedContent: string) => ({
type: "reasoning",
id: options.itemId,
encrypted_content: options.encryptedContent,
encrypted_content: encryptedContent,
summary: [{ type: "summary_text", text }],
}),
}
@@ -657,10 +656,7 @@ describe("session.llm-native.request", () => {
expectedBody: {
input: [
openAIResponses.user("What changed?"),
openAIResponses.openaiReasoning("Checked the previous diff.", {
itemId: "rs_1",
encryptedContent: "encrypted-state",
}),
openAIResponses.openaiReasoning("Checked the previous diff.", "encrypted-state"),
openAIResponses.assistant("The parser changed."),
openAIResponses.user("Summarize it."),
],
@@ -683,7 +679,7 @@ describe("session.llm-native.request", () => {
],
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
expectedBody: {
input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }],
input: [{ type: "reasoning", summary: [], encrypted_content: "encrypted-state" }],
include: ["reasoning.encrypted_content"],
store: false,
},
@@ -11,6 +11,10 @@ import { testEffect } from "../lib/effect"
let CLOUDFLARE_SKILLS_URL: string
let server: ReturnType<typeof Bun.serve>
let downloadCount = 0
let mutableVersion = "1"
let mutableContent = "# Old"
let mutableDownloadCount = 0
let mutableFiles = ["SKILL.md"]
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
const cacheDir = path.join(Global.Path.cache, "skills")
@@ -24,6 +28,15 @@ beforeAll(async () => {
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === "/mutable/index.json") {
return Response.json({ skills: [{ name: "mutable", version: mutableVersion, files: mutableFiles }] })
}
if (url.pathname === "/mutable/mutable/SKILL.md") {
mutableDownloadCount++
return new Response(mutableContent)
}
if (url.pathname === "/mutable/mutable/old.md") return new Response("old reference")
// route /.well-known/skills/* to the fixture directory
if (url.pathname.startsWith("/.well-known/skills/")) {
const filePath = url.pathname.replace("/.well-known/skills/", "")
@@ -136,4 +149,37 @@ describe("Discovery.pull", () => {
expect(downloadCount).toBe(firstCount)
}),
)
it.live("refreshes a remote skill when its version changes", () =>
Effect.gen(function* () {
yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
mutableVersion = "1"
mutableContent = "# Old"
mutableDownloadCount = 0
mutableFiles = ["SKILL.md", "old.md"]
const discovery = yield* Discovery.Service
const url = `http://localhost:${server.port}/mutable/`
const first = yield* discovery.pull(url)
expect(yield* Effect.promise(() => Bun.file(path.join(first[0], "SKILL.md")).text())).toBe("# Old")
mutableVersion = "2"
mutableContent = "# Partial"
mutableFiles = ["SKILL.md", "missing.md"]
const second = yield* discovery.pull(url)
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# Old")
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).text())).toBe("old reference")
mutableVersion = "3"
mutableContent = "# New"
mutableFiles = ["SKILL.md"]
yield* discovery.pull(url)
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# New")
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).exists())).toBe(false)
expect(mutableDownloadCount).toBe(3)
yield* discovery.pull(url)
expect(mutableDownloadCount).toBe(3)
}),
)
})
@@ -77,6 +77,33 @@ const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
)
describe("skill", () => {
it.effect("formats verbose locations as XML-safe filesystem paths", () =>
Effect.sync(() => {
const output = Skill.fmt(
[
{
name: "tagged-skill",
description: "A tagged skill.",
location: "/tmp/plugin.git#v1.3.0/SKILL.md",
content: "",
},
{
name: "built-in-skill",
description: "A built-in skill.",
location: "<built-in>",
content: "",
},
],
{ verbose: true },
)
expect(output).toContain("<location>/tmp/plugin.git#v1.3.0/SKILL.md</location>")
expect(output).toContain("<location>&lt;built-in&gt;</location>")
expect(output).not.toContain("file://")
expect(output).not.toContain("%23")
}),
)
it.live("discovers skills from .opencode/skill/ directory", () =>
provideTmpdirInstance(
(dir) =>
@@ -43,7 +43,7 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-weak);
color: var(--v2-text-text-faint);
[data-component="spinner"] {
width: 16px;
@@ -89,14 +89,14 @@
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
color: var(--v2-text-text-base);
&.capitalize {
text-transform: capitalize;
}
&.agent-title {
color: var(--text-strong);
color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium);
}
}
@@ -114,7 +114,7 @@
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-base);
color: var(--v2-text-text-muted);
&.clickable {
cursor: pointer;
@@ -122,7 +122,7 @@
transition: color 0.15s ease;
&:hover {
color: var(--text-base);
color: var(--v2-text-text-muted);
}
}
@@ -160,7 +160,7 @@
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-base);
color: var(--v2-text-text-muted);
}
[data-slot="basic-tool-tool-action"] {
@@ -178,7 +178,7 @@
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
border: 0.5px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
background: color-mix(in srgb, var(--background-base) 92%, transparent);
transition:
border-color 0.15s ease,
@@ -213,7 +213,7 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--icon-weak);
color: var(--v2-text-text-faint);
margin-left: auto;
opacity: 0;
transition:
@@ -233,7 +233,7 @@
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--text-strong);
color: var(--v2-text-text-base);
}
&:hover,
@@ -246,3 +246,34 @@
}
}
}
body:not([data-new-layout]) {
[data-component="tool-trigger"] {
[data-slot="basic-tool-tool-spinner"] {
color: var(--text-weak);
}
[data-slot="basic-tool-tool-title"],
[data-slot="basic-tool-tool-title"].agent-title {
color: var(--text-strong);
}
[data-slot="basic-tool-tool-subtitle"],
[data-slot="basic-tool-tool-subtitle"].clickable:hover,
[data-slot="basic-tool-tool-arg"] {
color: var(--text-base);
}
}
[data-component="task-tool-card"] {
border-width: 1px;
[data-component="task-tool-action"] {
color: var(--icon-weak);
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--text-strong);
}
}
}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test"
import { inlineCodeKind } from "./markdown-inline-code-kind"
describe("inlineCodeKind", () => {
test("leaves code expressions as normal inline code", () => {
expect(
inlineCodeKind(
`case "question.asked": ... input.setStore("question", question.sessionID, [question]) / splice/insert`,
),
).toBeUndefined()
expect(inlineCodeKind(`<SessionQuestionDock request={request} ... />`)).toBeUndefined()
expect(inlineCodeKind(`from sync.data.question + sync.data.session.`)).toBeUndefined()
expect(inlineCodeKind(`@opencode-ai/app <StatusPopover />)`)).toBeUndefined()
expect(inlineCodeKind(`sync.data.session`)).toBeUndefined()
expect(inlineCodeKind(`window.api`)).toBeUndefined()
})
test("detects file and directory paths", () => {
expect(inlineCodeKind(`app.tsx`)).toBe("path")
expect(inlineCodeKind(`packages/desktop-electron`)).toBe("path")
expect(inlineCodeKind(`~/.config/opencode`)).toBe("path")
expect(inlineCodeKind(`@opencode-ai/app`)).toBe("path")
expect(inlineCodeKind(`session/status`)).toBe("path")
})
test("detects urls", () => {
expect(inlineCodeKind(`https://opencode.ai/docs`)).toBe("url")
expect(inlineCodeKind(`http://localhost:4444`)).toBe("url")
expect(inlineCodeKind(`file:///tmp/opencode`)).toBeUndefined()
expect(inlineCodeKind(`ftp://opencode.ai/docs`)).toBeUndefined()
})
})
@@ -0,0 +1,15 @@
export function inlineCodeKind(text: string): "path" | "url" | undefined {
if (/^https?:\/\//i.test(text)) return "url"
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return
if (/^\/[a-z][a-z0-9-]*$/i.test(text)) return
if (/\s/.test(text)) return
if (/[()\[\]{}*+=<>|&^"';]/.test(text)) return
if (
/[/\\]/.test(text) ||
/^\.\.?[/\\]/.test(text) ||
/\.(tsx?|jsx?|json|py|go|rs|rb|php|css|html|md|sh|ya?ml|toml|sql|c|cpp|h|java|kt|swift|scala|xml|png|jpe?g|gif|svg|ico)$/i.test(
text,
)
)
return "path"
}
+137 -24
View File
@@ -1,9 +1,17 @@
[data-component="markdown"] {
--markdown-inline-code-path-color: var(--v2-text-text-code-accent);
--markdown-inline-code-color: var(--v2-text-text-code-accent);
--markdown-inline-code-bg-mix: 8%;
--markdown-shell-code-background: var(--v2-background-bg-layer-02);
--markdown-shell-code-border: var(--v2-border-border-muted);
--markdown-shell-code-color: var(--v2-text-text-base);
--markdown-shell-code-token-color: currentColor;
/* Reset & Base Typography */
min-width: 0;
max-width: 100%;
overflow-wrap: break-word;
color: var(--text-strong);
color: var(--v2-text-text-base);
font-family: var(--font-family-sans);
font-size: var(--font-size-base); /* 14px */
line-height: 160%;
@@ -22,15 +30,31 @@
margin-bottom: 0;
}
/* Headings: Same size, distinguished by color and spacing */
h1,
h2,
/* Headings: Sized by level, distinguished by color and spacing */
h1 {
font-size: 17px;
color: var(--v2-text-text-base);
font-weight: 600;
margin-top: 0px;
margin-bottom: 24px;
line-height: var(--line-height-large);
}
h2 {
font-size: 15px;
color: var(--v2-text-text-base);
font-weight: 600;
margin-top: 0px;
margin-bottom: 24px;
line-height: var(--line-height-large);
}
h3,
h4,
h5,
h6 {
font-size: 14px;
color: var(--text-strong);
font-size: 13px;
color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium);
margin-top: 0px;
margin-bottom: 24px;
@@ -40,7 +64,7 @@
/* Emphasis & Strong: Neutral strong color */
strong,
b {
color: var(--text-strong);
color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium);
}
@@ -95,7 +119,7 @@
}
li::marker {
color: var(--text-weak);
color: var(--v2-text-text-muted);
}
/* Nested lists spacing */
@@ -112,10 +136,10 @@
/* Blockquotes */
blockquote {
border-left: 2px solid var(--border-weak-base);
border-left: 0.5px solid var(--v2-border-border-base);
margin: 1.5rem 0;
padding-left: 0.5rem;
color: var(--text-weak);
color: var(--v2-text-text-muted);
font-style: normal;
}
@@ -132,13 +156,27 @@
font-size: 13px;
padding: 12px;
border-radius: 6px;
border: 0.5px solid var(--border-weak-base);
border: 0.5px solid var(--v2-border-border-base);
}
[data-component="markdown-code"] {
position: relative;
}
[data-component="markdown-code"][data-code-kind="shell"] .shiki {
background: var(--markdown-shell-code-background);
border-color: var(--markdown-shell-code-border);
color: var(--markdown-shell-code-color);
}
[data-component="markdown-code"][data-code-kind="shell"] code {
color: var(--markdown-shell-code-color);
}
[data-component="markdown-code"][data-code-kind="shell"] code span {
color: var(--markdown-shell-code-token-color) !important;
}
[data-slot="markdown-copy-button"] {
position: absolute;
top: 4px;
@@ -160,7 +198,7 @@
background: var(--surface-float-base);
color: var(--text-invert-strong);
padding: 2px 8px;
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
border: 0.5px solid var(--v2-border-border-base, rgba(0, 0, 0, 0.07));
box-shadow: var(--shadow-md);
pointer-events: none;
@@ -185,11 +223,11 @@
[data-slot="markdown-copy-button"][data-variant="secondary"] {
box-shadow: none;
border: 1px solid var(--border-weak-base);
border: 0.5px solid var(--v2-border-border-base);
}
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--icon-base);
color: var(--v2-icon-icon-base);
}
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {
@@ -222,15 +260,28 @@
:not(pre) > code {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
color: var(--syntax-string);
color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium);
/* font-size: 13px; */
padding: 2px 4px;
border-radius: 4px;
background: color-mix(in oklch, var(--v2-text-text-base) var(--markdown-inline-code-bg-mix), transparent);
}
/* padding: 2px 2px; */
/* margin: 0 1.5px; */
/* border-radius: 2px; */
/* background: var(--surface-base); */
/* box-shadow: 0 0 0 0.5px var(--border-weak-base); */
:not(pre) > code[data-inline-code-kind="path"] {
color: var(--markdown-inline-code-path-color);
background: color-mix(
in oklch,
var(--markdown-inline-code-path-color) var(--markdown-inline-code-bg-mix),
transparent
);
}
a.external-link > code[data-inline-code-kind="url"] {
color: var(--markdown-inline-code-color);
background: none;
text-decoration: underline;
text-decoration-color: color-mix(in oklch, var(--markdown-inline-code-color) 25%, transparent);
text-underline-offset: 2px;
}
/* Tables */
@@ -246,16 +297,16 @@
th,
td {
/* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */
border-bottom: 1px solid var(--border-weaker-base);
border-bottom: 0.5px solid var(--v2-border-border-muted);
padding: 12px;
text-align: left;
vertical-align: top;
}
th {
color: var(--text-strong);
color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium);
border-bottom: 1px solid var(--border-weak-base);
border-bottom: 0.5px solid var(--v2-border-border-base);
}
/* Images */
@@ -268,6 +319,68 @@
}
}
body:not([data-new-layout]) [data-component="markdown"] {
color: var(--text-strong);
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 14px;
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
strong,
b {
color: var(--text-strong);
}
li::marker {
color: var(--text-weak);
}
blockquote {
border-left: 2px solid var(--border-weak-base);
color: var(--text-weak);
}
.shiki {
border-color: var(--border-weak-base);
}
:not(pre) > code {
color: var(--syntax-string);
padding: 0;
border-radius: 0;
background: none;
}
[data-slot="markdown-copy-button"]::after {
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
}
[data-slot="markdown-copy-button"][data-variant="secondary"] {
border: 1px solid var(--border-weak-base);
}
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--icon-base);
}
th,
td {
border-bottom: 1px solid var(--border-weaker-base);
}
th {
color: var(--text-strong);
border-bottom-color: var(--border-weak-base);
}
}
[data-component="markdown"] a.external-link:hover > code {
text-decoration: underline;
text-underline-offset: 2px;
@@ -25,6 +25,7 @@ import {
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
import { inlineCodeKind } from "./markdown-inline-code-kind"
type RenderedBlock =
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
@@ -140,6 +141,35 @@ function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boo
button.setAttribute("data-tooltip", labels.copy)
}
const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"])
function codeKind(language: string | undefined) {
const value = language?.toLowerCase()
if (!value) return
if (shellLanguages.has(value)) return "shell"
}
function codeLanguage(block: HTMLPreElement) {
const code = block.querySelector("code")
if (!(code instanceof HTMLElement)) return
return code.className.match(/(?:^|\s)language-([^\s]+)/)?.[1]
}
function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) {
if (!document.body.hasAttribute("data-new-layout")) {
delete wrapper.dataset.language
delete wrapper.dataset.codeKind
return
}
if (language) wrapper.dataset.language = language
else delete wrapper.dataset.language
const kind = codeKind(language)
if (kind) wrapper.dataset.codeKind = kind
else delete wrapper.dataset.codeKind
}
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
const parent = block.parentElement
if (!parent) return
@@ -147,12 +177,15 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
if (!wrapped) {
const wrapper = document.createElement("div")
wrapper.setAttribute("data-component", "markdown-code")
applyCodeMetadata(wrapper, codeLanguage(block))
parent.replaceChild(wrapper, block)
wrapper.appendChild(block)
wrapper.appendChild(createCopyButton(labels))
return
}
applyCodeMetadata(parent, codeLanguage(block))
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
)
@@ -196,11 +229,23 @@ function markCodeLinks(root: HTMLDivElement) {
}
}
function markInlineCode(root: HTMLDivElement) {
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
for (const code of codeNodes) {
if (!(code instanceof HTMLElement)) continue
delete code.dataset.inlineCodeKind
const kind = inlineCodeKind(code.textContent ?? "")
if (kind) code.dataset.inlineCodeKind = kind
}
}
function decorate(root: HTMLDivElement, labels: CopyLabels) {
const blocks = Array.from(root.querySelectorAll("pre"))
for (const block of blocks) {
ensureCodeWrapper(block, labels)
}
if (!document.body.hasAttribute("data-new-layout")) return
markInlineCode(root)
markCodeLinks(root)
}
@@ -522,6 +567,8 @@ function updateCodeBlock(
const code = existing?.querySelector("code")
if (code instanceof HTMLElement) {
const wrapper = code.closest('[data-component="markdown-code"]')
if (wrapper instanceof HTMLElement) applyCodeMetadata(wrapper, block.language)
code.className = `language-${block.language}`
const previous = renderedCodeTokens.get(next)
const reset = shouldResetCodeTokens(previous, {
@@ -552,6 +599,7 @@ function updateCodeBlock(
const wrapper = document.createElement("div")
wrapper.setAttribute("data-component", "markdown-code")
applyCodeMetadata(wrapper, block.language)
const pre = document.createElement("pre")
pre.className = "shiki OpenCode"
const codeElement = document.createElement("code")
@@ -14,7 +14,7 @@
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
color: var(--v2-text-text-base);
display: flex;
flex-direction: column;
align-items: flex-end;
@@ -41,15 +41,15 @@
min-width: 0;
border-radius: 6px;
overflow: hidden;
background: var(--surface-weak);
border: 1px solid var(--border-weak-base);
background: var(--v2-background-bg-layer-02);
border: 0.5px solid var(--v2-border-border-base);
cursor: default;
transition:
border-color 0.15s ease,
opacity 0.3s ease;
&:hover {
border-color: var(--border-strong-base);
border-color: var(--v2-border-border-strong);
}
&[data-clickable] {
@@ -107,7 +107,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-base);
color: var(--v2-text-text-muted);
font-size: var(--font-size-small);
line-height: var(--line-height-large);
}
@@ -130,10 +130,10 @@
white-space: pre-wrap;
word-break: break-word;
overflow: hidden;
background: var(--surface-base);
border: 1px solid var(--border-weak-base);
background: var(--v2-background-bg-layer-02);
border: none;
padding: 8px 12px;
border-radius: 6px;
border-radius: 10px;
[data-highlight="file"] {
color: var(--syntax-property);
@@ -199,7 +199,7 @@
}
.text-text-strong {
color: var(--text-strong);
color: var(--v2-text-text-base);
}
.font-medium {
@@ -267,7 +267,7 @@
[data-slot="compaction-part-line"] {
flex: 1 1 auto;
height: 1px;
background: var(--border-weak-base);
background: var(--v2-border-border-base);
}
[data-slot="compaction-part-label"] {
@@ -279,18 +279,18 @@
[data-component="reasoning-part"] {
width: 100%;
color: var(--text-base);
color: var(--v2-text-text-muted);
line-height: var(--line-height-normal);
[data-component="markdown"] {
margin-top: 16px;
font-style: normal;
font-size: 13px;
color: var(--text-weak);
color: var(--v2-text-text-muted);
strong,
b {
color: var(--text-weak);
color: var(--v2-text-text-muted);
}
p:has(strong) {
@@ -357,7 +357,7 @@
[data-component="bash-output"] {
width: 100%;
border: 1px solid var(--border-weak-base);
border: 0.5px solid var(--v2-border-border-base);
border-radius: 6px;
background: transparent;
position: relative;
@@ -380,7 +380,7 @@
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
box-shadow: none;
border: 1px solid var(--border-weak-base);
border: 0.5px solid var(--v2-border-border-base);
}
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] {
@@ -442,7 +442,7 @@
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-base);
color: var(--v2-text-text-muted);
}
[data-slot="message-part-title-spinner"] {
@@ -453,7 +453,7 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-weak);
color: var(--v2-text-text-muted);
[data-component="spinner"] {
width: 16px;
@@ -464,7 +464,7 @@
[data-slot="message-part-title-text"] {
flex-shrink: 0;
text-transform: capitalize;
color: var(--text-strong);
color: var(--v2-text-text-base);
}
[data-slot="message-part-title-filename"] {
@@ -484,7 +484,7 @@
}
[data-slot="message-part-directory"] {
color: var(--text-weak);
color: var(--v2-text-text-muted);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
@@ -493,7 +493,7 @@
}
[data-slot="message-part-filename"] {
color: var(--text-strong);
color: var(--v2-text-text-base);
flex-shrink: 0;
}
@@ -508,7 +508,7 @@
[data-component="edit-content"] {
border-radius: inherit;
border-top: 1px solid var(--border-weaker-base);
border-top: 0.5px solid var(--v2-border-border-muted);
overflow-x: hidden;
overflow-y: visible;
@@ -527,7 +527,7 @@
[data-component="write-content"] {
border-radius: inherit;
border-top: 1px solid var(--border-weaker-base);
border-top: 0.5px solid var(--v2-border-border-muted);
overflow-x: hidden;
overflow-y: visible;
@@ -561,7 +561,7 @@
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
line-height: var(--line-height-large);
color: var(--text-base);
color: var(--v2-text-text-muted);
}
[data-slot="basic-tool-tool-subtitle"].exa-tool-query {
@@ -583,7 +583,7 @@
max-width: 100%;
font: inherit;
line-height: inherit;
color: var(--text-interactive-base);
color: var(--v2-text-text-accent);
text-decoration: underline;
text-underline-offset: 2px;
overflow: hidden;
@@ -591,11 +591,11 @@
white-space: nowrap;
&:hover {
color: var(--text-interactive-base);
color: var(--v2-text-text-accent);
}
&:visited {
color: var(--text-interactive-base);
color: var(--v2-text-text-accent);
}
}
@@ -615,7 +615,7 @@
&[data-completed="completed"] {
text-decoration: line-through;
color: var(--text-weaker);
color: var(--v2-text-text-faint);
}
}
}
@@ -660,8 +660,8 @@
flex-direction: column;
gap: 4px;
padding: 8px 12px;
background-color: var(--surface-critical-weak);
border-top: 1px solid var(--border-critical-base);
background-color: var(--v2-state-bg-danger);
border-top: 0.5px solid var(--v2-state-border-danger);
[data-slot="diagnostic"] {
display: flex;
@@ -673,7 +673,7 @@
}
[data-slot="diagnostic-label"] {
color: var(--text-on-critical-base);
color: var(--v2-state-fg-danger);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
letter-spacing: -0.5px;
@@ -681,12 +681,12 @@
}
[data-slot="diagnostic-location"] {
color: var(--text-on-critical-weak);
color: var(--v2-state-fg-danger);
flex-shrink: 0;
}
[data-slot="diagnostic-message"] {
color: var(--text-on-critical-base);
color: var(--v2-state-fg-danger);
word-break: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
@@ -761,7 +761,7 @@
font-size: 14px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-strong);
color: var(--v2-text-text-base);
min-width: 0;
}
@@ -778,7 +778,7 @@
font-size: 14px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--text-weak);
color: var(--v2-text-text-muted);
padding: 0;
}
@@ -856,7 +856,7 @@
font-size: 14px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-strong);
color: var(--v2-text-text-base);
min-width: 0;
}
@@ -916,7 +916,7 @@
font-size: 14px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-strong);
color: var(--v2-text-text-base);
padding: 0 10px;
-webkit-user-select: text;
user-select: text;
@@ -927,7 +927,7 @@
font-size: 13px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--text-weak);
color: var(--v2-text-text-muted);
padding: 0 10px;
}
@@ -953,8 +953,8 @@
align-items: flex-start;
gap: 12px;
padding: 8px 8px 8px 10px;
background-color: var(--surface-raised-stronger-non-alpha);
border: 1px solid var(--border-weak-base);
background-color: var(--v2-background-bg-layer-01);
border: 0.5px solid var(--v2-border-border-base);
border-radius: 6px;
box-shadow: none;
text-align: left;
@@ -966,11 +966,11 @@
box-shadow 0.15s ease;
&:hover:not([data-picked="true"]) {
background-color: var(--background-base);
background-color: var(--v2-background-bg-base);
}
&[data-picked="true"] {
background-color: var(--surface-interactive-weak);
background-color: var(--v2-state-bg-info);
border-color: transparent;
box-shadow: var(--shadow-xs-border-hover);
}
@@ -991,7 +991,7 @@
height: 16px;
padding: 2px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-weak-base);
border: 0.5px solid var(--v2-border-border-base);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1014,7 +1014,7 @@
width: 6px;
height: 6px;
border-radius: 999px;
background-color: var(--background-stronger);
background-color: var(--v2-background-bg-layer-01);
opacity: 0;
}
@@ -1052,7 +1052,7 @@
font-size: 14px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-strong);
color: var(--v2-text-text-base);
}
[data-slot="option-description"] {
@@ -1060,7 +1060,7 @@
font-size: 14px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--text-base);
color: var(--v2-text-text-muted);
min-width: 0;
overflow-wrap: anywhere;
white-space: normal;
@@ -1088,7 +1088,7 @@
font-size: 14px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--text-base);
color: var(--v2-text-text-muted);
min-width: 0;
cursor: text;
resize: none;
@@ -1096,11 +1096,11 @@
overflow-wrap: anywhere;
&::placeholder {
color: var(--text-weak);
color: var(--v2-text-text-muted);
}
&:focus-visible {
outline: 1px solid var(--border-interactive-base);
outline: 0.5px solid var(--v2-border-border-focus);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
@@ -1139,11 +1139,11 @@
font-size: 13px;
[data-slot="question-text"] {
color: var(--text-weak);
color: var(--v2-text-text-muted);
}
[data-slot="answer-text"] {
color: var(--text-strong);
color: var(--v2-text-text-base);
}
}
}
@@ -1166,13 +1166,13 @@
z-index: 20;
height: calc(32px + var(--tool-content-gap));
padding-bottom: var(--tool-content-gap);
background-color: var(--background-stronger);
background-color: var(--v2-background-bg-base);
}
}
[data-component="accordion"][data-scope="apply-patch"] {
[data-slot="accordion-trigger"] {
background-color: var(--background-stronger) !important;
background-color: var(--v2-background-bg-base) !important;
}
[data-slot="apply-patch-trigger-content"] {
@@ -1199,7 +1199,7 @@
}
[data-slot="apply-patch-directory"] {
color: var(--text-base);
color: var(--v2-text-text-muted);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
@@ -1208,7 +1208,7 @@
}
[data-slot="apply-patch-filename"] {
color: var(--text-strong);
color: var(--v2-text-text-base);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -1268,10 +1268,199 @@
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--text-weak);
color: var(--v2-text-text-muted);
[data-component="icon"] {
flex-shrink: 0;
color: var(--icon-weak);
}
}
body:not([data-new-layout]) {
[data-component="user-message"] {
color: var(--text-strong);
[data-slot="user-message-attachment"] {
background: var(--surface-weak);
border: 1px solid var(--border-weak-base);
&:hover {
border-color: var(--border-strong-base);
}
}
[data-slot="user-message-attachment-name"] {
color: var(--text-base);
}
[data-slot="user-message-text"] {
background: var(--surface-base);
border: 1px solid var(--border-weak-base);
border-radius: 6px;
}
.text-text-strong {
color: var(--text-strong);
}
}
[data-slot="compaction-part-line"] {
background: var(--border-weak-base);
}
[data-component="reasoning-part"] {
color: var(--text-base);
[data-component="markdown"],
[data-component="markdown"] :is(strong, b) {
color: var(--text-weak);
}
}
[data-component="bash-output"] {
border: 1px solid var(--border-weak-base);
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
border: 1px solid var(--border-weak-base);
}
}
[data-component="edit-trigger"],
[data-component="write-trigger"] {
[data-slot="message-part-title"] {
color: var(--text-base);
}
[data-slot="message-part-title-spinner"],
[data-slot="message-part-directory"] {
color: var(--text-weak);
}
[data-slot="message-part-title-text"],
[data-slot="message-part-filename"] {
color: var(--text-strong);
}
}
[data-component="edit-content"],
[data-component="write-content"] {
border-top: 1px solid var(--border-weaker-base);
}
[data-component="exa-tool-output"] {
color: var(--text-base);
}
[data-slot="exa-tool-link"],
[data-slot="exa-tool-link"]:hover,
[data-slot="exa-tool-link"]:visited {
color: var(--text-interactive-base);
}
[data-slot="message-part-todo-content"][data-completed="completed"] {
color: var(--text-weaker);
}
[data-component="diagnostics"] {
background-color: var(--surface-critical-weak);
border-top: 1px solid var(--border-critical-base);
[data-slot="diagnostic-label"],
[data-slot="diagnostic-message"] {
color: var(--text-on-critical-base);
}
[data-slot="diagnostic-location"] {
color: var(--text-on-critical-weak);
}
}
[data-component="dock-prompt"][data-kind="permission"] {
[data-slot="permission-header-title"] {
color: var(--text-strong);
}
[data-slot="permission-hint"] {
color: var(--text-weak);
}
}
[data-component="dock-prompt"][data-kind="question"] {
[data-slot="question-header-title"],
[data-slot="question-text"],
[data-slot="option-label"] {
color: var(--text-strong);
}
[data-slot="question-hint"] {
color: var(--text-weak);
}
[data-slot="question-option"] {
background-color: var(--surface-raised-stronger-non-alpha);
border: 1px solid var(--border-weak-base);
&:hover:not([data-picked="true"]) {
background-color: var(--background-base);
}
&[data-picked="true"] {
background-color: var(--surface-interactive-weak);
}
}
[data-slot="question-option-box"] {
border: 1px solid var(--border-weak-base);
[data-slot="question-option-radio-dot"] {
background-color: var(--background-stronger);
}
}
[data-slot="option-description"],
[data-slot="question-custom-input"] {
color: var(--text-base);
}
[data-slot="question-custom-input"] {
&::placeholder {
color: var(--text-weak);
}
&:focus-visible {
outline: 1px solid var(--border-interactive-base);
}
}
}
[data-component="question-answers"] {
[data-slot="question-text"] {
color: var(--text-weak);
}
[data-slot="answer-text"] {
color: var(--text-strong);
}
}
:is([data-component="edit-tool"], [data-component="write-tool"], [data-component="apply-patch-tool"])
> [data-component="collapsible"]
> [data-slot="collapsible-trigger"][aria-expanded="true"],
[data-component="accordion"][data-scope="apply-patch"] [data-slot="accordion-trigger"] {
background-color: var(--background-stronger) !important;
}
[data-component="accordion"][data-scope="apply-patch"] {
[data-slot="apply-patch-directory"] {
color: var(--text-base);
}
[data-slot="apply-patch-filename"] {
color: var(--text-strong);
}
}
[data-component="tool-loaded-file"] {
color: var(--text-weak);
}
}
@@ -21,7 +21,7 @@
[data-slot="session-review-header"] {
z-index: 120;
background-color: var(--background-stronger);
background-color: var(--v2-background-bg-base);
height: 40px;
padding-bottom: 8px;
flex-shrink: 0;
@@ -211,7 +211,7 @@
[data-slot="session-review-large-diff"] {
padding: 12px;
background: var(--background-stronger);
background: var(--v2-background-bg-base);
}
[data-slot="session-review-large-diff-title"] {
@@ -235,3 +235,13 @@
margin-top: 10px;
}
}
body:not([data-new-layout]) [data-component="session-review"] {
[data-slot="session-review-header"] {
background-color: var(--background-stronger);
}
[data-slot="session-review-large-diff"] {
background: var(--background-stronger);
}
}
@@ -71,7 +71,7 @@
}
.error-card {
color: var(--text-on-critical-base);
color: var(--v2-text-text-base);
max-height: 240px;
white-space: pre-wrap;
overflow-wrap: anywhere;
@@ -102,7 +102,7 @@
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
background-color: var(--background-stronger);
background-color: var(--v2-background-bg-base);
height: 44px;
}
@@ -226,6 +226,16 @@
}
}
body:not([data-new-layout]) [data-component="session-turn"] {
.error-card {
color: var(--text-on-critical-base);
}
[data-slot="session-turn-diffs-header"] {
background-color: var(--background-stronger);
}
}
[data-slot="session-turn-list"] {
gap: 24px;
}
@@ -2,6 +2,19 @@
--card-pad-y: 8px;
--card-line-pad: 12px;
[data-slot="basic-tool-tool-title"] {
color: var(--v2-text-text-base);
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--v2-text-text-muted);
}
[data-slot="collapsible-arrow"],
[data-slot="collapsible-arrow-icon"] {
color: var(--v2-text-text-faint);
}
> [data-component="collapsible"].tool-collapsible {
gap: 0px;
}
@@ -43,10 +56,34 @@
}
[data-slot="tool-error-card-content"] :where(*)::selection {
background: var(--surface-critical-base);
color: var(--text-on-critical-base);
background: var(--v2-state-bg-danger);
color: var(--v2-text-text-base);
}
[data-slot="tool-error-card-content"] :where(*)::-moz-selection {
background: var(--v2-state-bg-danger);
color: var(--v2-text-text-base);
}
}
body:not([data-new-layout]) [data-component="card"][data-kind="tool-error-card"] {
[data-slot="basic-tool-tool-title"] {
color: var(--text-strong);
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--text-base);
}
[data-slot="collapsible-arrow"] {
color: var(--text-base);
}
[data-slot="collapsible-arrow-icon"] {
color: var(--icon-weaker);
}
[data-slot="tool-error-card-content"] :where(*)::selection,
[data-slot="tool-error-card-content"] :where(*)::-moz-selection {
background: var(--surface-critical-base);
color: var(--text-on-critical-base);
@@ -31,6 +31,7 @@ import {
type ModelCatalogEntry,
} from "../model-catalog"
import { runStatsEffect } from "../../stats-runtime"
import { setStatsPageCacheHeaders } from "../stats-cache"
import {
applyThemePreference,
Footer,
@@ -86,7 +87,7 @@ export default function StatsModel() {
const i18n = useI18n()
const language = useLanguage()
const event = getRequestEvent()
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
setStatsPageCacheHeaders(event?.response.headers)
const params = useParams()
const labParam = createMemo(() => params.lab ?? "")
const modelParam = createMemo(() => params.model ?? "")
@@ -21,6 +21,7 @@ import {
type ModelCatalogLab,
} from "../model-catalog"
import { runStatsEffect } from "../../stats-runtime"
import { setStatsPageCacheHeaders } from "../stats-cache"
import {
applyThemePreference,
Footer,
@@ -43,7 +44,7 @@ export default function StatsLab() {
const i18n = useI18n()
const language = useLanguage()
const event = getRequestEvent()
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
setStatsPageCacheHeaders(event?.response.headers)
const params = useParams()
const labParam = createMemo(() => params.lab ?? "")
const catalog = createAsync(() => getModelCatalog())
+2 -1
View File
@@ -32,6 +32,7 @@ import { useI18n } from "../context/i18n"
import { useLanguage } from "../context/language"
import { localizedUrl } from "../lib/language"
import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog"
import { setStatsPageCacheHeaders } from "./stats-cache"
import {
applyThemePreference,
Footer,
@@ -121,7 +122,7 @@ export default function StatsHome() {
const i18n = useI18n()
const language = useLanguage()
const event = getRequestEvent()
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
setStatsPageCacheHeaders(event?.response.headers)
const statsHomeUrl = localizedUrl(language.locale(), "/data/")
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
const data = createAsync(() => getData())
@@ -0,0 +1,27 @@
const statsPageCacheControl = "public, max-age=60, s-maxage=300, stale-while-revalidate=86400"
export function setStatsPageCacheHeaders(headers: Headers | undefined) {
if (!headers) return
headers.set("Cache-Control", statsPageCacheControl)
appendVary(headers, "Accept-Language", "Cookie")
}
function appendVary(headers: Headers, ...values: string[]) {
const existing = headers
.get("vary")
?.split(",")
.map((value) => value.trim())
.filter(Boolean)
headers.set(
"vary",
values
.reduce(
(result, value) =>
result.some((item) => item.toLowerCase() === value.toLowerCase()) ? result : [...result, value],
existing ?? [],
)
.join(", "),
)
}
+18 -16
View File
@@ -8,7 +8,7 @@ import { ClipboardProvider, useClipboard } from "./context/clipboard"
import { ExitProvider, useExit } from "./context/exit"
import { EpilogueProvider } from "./context/epilogue"
import * as Selection from "./util/selection"
import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core"
import { createCliRenderer, MouseButton } from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
import {
Switch,
@@ -183,21 +183,23 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const result = yield* Effect.scoped(
Effect.gen(function* () {
const renderer = yield* Effect.acquireRelease(
Effect.tryPromise(() =>
createCliRenderer({
externalOutputMode: "passthrough",
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
}),
),
Effect.tryPromise({
try: () =>
createCliRenderer({
externalOutputMode: "passthrough",
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
}),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}),
(renderer) =>
Effect.sync(() => {
destroyRenderer(renderer)
+14
View File
@@ -417,6 +417,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const filePath = path.join(paths.state, "session.json")
const state = {
pending: false,
scroll: new Map<string, number>(),
}
function save() {
@@ -455,6 +456,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
function prune(sessionID: string) {
batch(() => {
state.scroll.delete(sessionID)
if (sessionStore.pinned.includes(sessionID)) {
setSessionStore(
"pinned",
@@ -487,6 +489,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
? sessionStore.pinned.filter((x) => x !== sessionID)
: [...sessionStore.pinned, sessionID]
setSessionStore("pinned", next)
if (exists) state.scroll.delete(sessionID)
save()
})
},
@@ -496,6 +499,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (route.data.type === "session" && route.data.sessionID === target) return
route.navigate({ type: "session", sessionID: target })
},
scrollPosition(sessionID: string) {
if (!slots().includes(sessionID)) return
return state.scroll.get(sessionID)
},
setScrollPosition(sessionID: string, position: number | undefined) {
if (position === undefined || !slots().includes(sessionID)) {
state.scroll.delete(sessionID)
return
}
state.scroll.set(sessionID, position)
},
}
}
+78 -8
View File
@@ -6,7 +6,6 @@ import {
createSignal,
For,
Match,
on,
onCleanup,
onMount,
Show,
@@ -82,6 +81,7 @@ import { getRevertDiffFiles } from "../../util/revert-diff"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
import { LocationProvider } from "../../context/location"
import { Flag } from "@opencode-ai/core/flag/flag"
addDefaultParsers(parsers.parsers)
@@ -259,6 +259,11 @@ export function Session() {
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true)
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
const [jumpBottomPosition, setJumpBottomPosition] = kv.signal<"center" | "right">(
"experimental_jump_bottom_position",
"center",
)
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => {
@@ -275,6 +280,7 @@ export function Session() {
const toast = useToast()
const sdk = useSDK()
const editor = useEditorContext()
const local = useLocal()
createEffect(() => {
const sessionID = route.sessionID
@@ -304,7 +310,15 @@ export function Session() {
}
editor.reconnect(result.data.directory)
await sync.session.sync(sessionID)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
setTimeout(() => {
if (route.sessionID !== sessionID || !scroll || scroll.isDestroyed) return
scroll.scrollTo(
Flag.OPENCODE_EXPERIMENTAL_TAB_SCROLL
? (local.session.scrollPosition(sessionID) ?? scroll.scrollHeight)
: scroll.scrollHeight,
)
updateAwayFromBottom()
}, 50)
})().catch((error) => {
if (route.sessionID !== sessionID) return
toast.show({
@@ -335,6 +349,13 @@ export function Session() {
let seeded = false
let scroll: ScrollBoxRenderable
onCleanup(() => {
if (!scroll || scroll.isDestroyed) return
local.session.setScrollPosition(
route.sessionID,
Flag.OPENCODE_EXPERIMENTAL_TAB_SCROLL && isAwayFromBottom() ? scroll.scrollTop : undefined,
)
})
let prompt: PromptRef | undefined
const bind = (r: PromptRef | undefined) => {
prompt = r
@@ -404,24 +425,40 @@ export function Session() {
if (!targetID) {
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
updateAwayFromBottom()
dialog.clear()
return
}
const child = scroll.getChildren().find((c) => c.id === targetID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
updateAwayFromBottom()
dialog.clear()
}
function isAwayFromBottom() {
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1
}
function updateAwayFromBottom() {
if (!Flag.OPENCODE_EXPERIMENTAL_TAB_SCROLL) return
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
const away = isAwayFromBottom()
setAwayFromBottom(away)
if (!away) local.session.setScrollPosition(route.sessionID, undefined)
})
}
function toBottom() {
setAwayFromBottom(false)
local.session.setScrollPosition(route.sessionID, undefined)
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
scroll.scrollTo(scroll.scrollHeight)
}, 50)
}
const local = useLocal()
function enterChild(sessionID: string) {
navigate({
type: "session",
@@ -522,6 +559,7 @@ export function Session() {
return child.id === messageID
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
updateAwayFromBottom()
}}
sessionID={route.sessionID}
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
@@ -545,6 +583,7 @@ export function Session() {
return child.id === messageID
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
updateAwayFromBottom()
}}
sessionID={route.sessionID}
/>
@@ -742,6 +781,19 @@ export function Session() {
dialog.clear()
},
},
{
title: `Move jump-to-bottom button ${jumpBottomPosition() === "center" ? "right" : "to center"}`,
value: "session.jump_bottom.position",
category: "Session",
hidden: !Flag.OPENCODE_EXPERIMENTAL_TAB_SCROLL,
slash: {
name: "jump-bottom-position",
},
run: () => {
setJumpBottomPosition((position) => (position === "center" ? "right" : "center"))
dialog.clear()
},
},
{
title: "Page up",
value: "session.page.up",
@@ -749,6 +801,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(-scroll.height / 2)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -759,6 +812,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(scroll.height / 2)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -769,6 +823,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(-1)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -779,6 +834,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(1)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -789,6 +845,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(-scroll.height / 4)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -799,6 +856,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(scroll.height / 4)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -809,6 +867,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollTo(0)
updateAwayFromBottom()
dialog.clear()
},
},
@@ -818,7 +877,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
scroll.scrollTo(scroll.scrollHeight)
toBottom()
dialog.clear()
},
},
@@ -848,6 +907,7 @@ export function Session() {
return child.id === message.id
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
updateAwayFromBottom()
break
}
}
@@ -1139,9 +1199,6 @@ export function Session() {
}
})
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
return (
<LocationProvider location={location()}>
<context.Provider
@@ -1182,6 +1239,7 @@ export function Session() {
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
onMouseScroll={updateAwayFromBottom}
>
<box height={1} />
<For each={messages()}>
@@ -1280,6 +1338,18 @@ export function Session() {
</For>
</scrollbox>
<box flexShrink={0}>
<Show when={Flag.OPENCODE_EXPERIMENTAL_TAB_SCROLL && awayFromBottom()}>
<box
height={1}
flexDirection="row"
justifyContent={jumpBottomPosition() === "center" ? "center" : "flex-end"}
paddingRight={jumpBottomPosition() === "right" ? 1 : 0}
>
<text fg={theme.textMuted} onMouseUp={toBottom}>
Bottom
</text>
</box>
</Show>
<Show when={permissions().length > 0}>
<PermissionPrompt
request={permissions()[0]}
+2
View File
@@ -15,6 +15,7 @@
"**/*.css"
],
"files": [
"dist/**/*.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
"!src/**/*.test.ts",
@@ -55,6 +56,7 @@
"./v2/styles/*": "./src/v2/styles/*"
},
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"typecheck": "tsgo --noEmit",
"test": "bun test src --only-failures",
"dev": "vite",
+1
View File
@@ -246,6 +246,7 @@
--v2-text-text-contrast: var(--v2-grey-50);
--v2-text-text-accent: var(--v2-blue-600);
--v2-text-text-accent-hover: var(--v2-blue-700);
--v2-text-text-code-accent: var(--v2-blue-900);
--v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600);
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "node:fs/promises"
import path from "node:path"
export async function pack() {
const original = await Bun.file("package.json").text()
const pkg = JSON.parse(original) as {
name: string
version: string
exports: Record<string, string | { types: string; import: string }>
}
const tarball = path.resolve(`${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`)
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string" || (!value.endsWith(".ts") && !value.endsWith(".tsx"))) return [key, value]
return [
key,
{
types: value.replace("./src/", "./dist/").replace(/\.tsx?$/, ".d.ts"),
import: value,
},
]
}),
)
await rm(tarball, { force: true })
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
try {
await $`bun pm pack`
return tarball
} finally {
await Bun.write("package.json", original)
}
}
if (import.meta.main) console.log(await pack())
+2 -2
View File
@@ -4,6 +4,7 @@ import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import { pack } from "./pack"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
@@ -18,8 +19,7 @@ if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode ==
try {
await $`bun run typecheck`
await $`bun run test`
await rm(tarball, { force: true })
await $`bun pm pack`
await pack()
await $`npm publish ${tarball} --access public --tag ${Script.channel}`
} finally {
await rm(tarball, { force: true })
+31 -7
View File
@@ -35,12 +35,12 @@
cursor: default;
user-select: none;
background-color: var(--background-stronger);
border: 1px solid var(--border-weak-base);
background-color: var(--v2-background-bg-base);
border: 0.5px solid var(--v2-border-border-base);
border-radius: 0;
box-shadow: none;
overflow: clip;
color: var(--text-strong);
color: var(--v2-text-text-base);
transition: background-color 0.15s ease;
/* text-12-regular */
@@ -52,10 +52,10 @@
letter-spacing: var(--letter-spacing-normal);
&:hover:not([data-disabled]) {
background-color: var(--surface-base-hover);
background-color: var(--v2-overlay-simple-overlay-hover);
}
&:active:not([data-disabled]) {
background-color: var(--surface-base-active);
background-color: var(--v2-overlay-simple-overlay-pressed);
}
&:focus-visible {
outline: none;
@@ -82,9 +82,9 @@
&[data-expanded] {
[data-slot="accordion-content"] {
border: 1px solid var(--border-weak-base);
border: 0.5px solid var(--v2-border-border-base);
border-top: 0;
background-color: var(--background-stronger);
background-color: var(--v2-background-bg-layer-01);
}
}
@@ -104,6 +104,30 @@
}
}
body:not([data-new-layout]) [data-component="accordion"] {
[data-slot="accordion-item"] [data-slot="accordion-header"] [data-slot="accordion-trigger"] {
background-color: var(--background-stronger);
border-width: 1px;
border-color: var(--border-weak-base);
color: var(--text-strong);
&:hover:not([data-disabled]) {
background-color: var(--surface-base-hover);
}
&:active:not([data-disabled]) {
background-color: var(--surface-base-active);
}
}
[data-slot="accordion-item"][data-expanded] [data-slot="accordion-content"] {
border-width: 1px;
border-color: var(--border-weak-base);
border-top: 0;
background-color: var(--background-stronger);
}
}
@keyframes slideDown {
from {
height: 0;
+26 -5
View File
@@ -19,14 +19,14 @@
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
color: var(--v2-text-text-base);
--card-gap: 8px;
--card-icon: 16px;
--card-indent: 0px;
--card-line-pad: 8px;
--card-accent: var(--icon-active);
--card-accent: var(--v2-icon-icon-base);
&:has([data-slot="card-title"]) {
gap: 8px;
@@ -48,7 +48,7 @@
}
:where([data-card="title"], [data-slot="card-title"]) {
color: var(--text-strong);
color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium);
}
@@ -72,7 +72,7 @@
}
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
color: var(--text-weak);
color: var(--v2-text-text-faint);
}
:where([data-slot="card-title-icon"])
@@ -82,7 +82,7 @@
}
:where([data-card="description"], [data-slot="card-description"]) {
color: var(--text-base);
color: var(--v2-text-text-muted);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
@@ -92,3 +92,24 @@
padding-left: var(--card-indent);
}
}
body:not([data-new-layout]) [data-component="card"] {
color: var(--text-strong);
--card-accent: var(--icon-active);
:where([data-card="title"], [data-slot="card-title"]) {
color: var(--text-strong);
}
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
color: var(--text-weak);
}
:where([data-card="description"], [data-slot="card-description"]) {
color: var(--text-base);
}
&[data-variant="error"] {
--card-accent: var(--icon-critical-base) !important;
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function Card(props: CardProps) {
const variant = () => split.variant ?? "normal"
const accent = () => {
const v = variant()
if (v === "error") return "var(--icon-critical-base)"
if (v === "error") return "var(--v2-state-fg-danger)"
if (v === "warning") return "var(--icon-warning-active)"
if (v === "success") return "var(--icon-success-active)"
if (v === "info") return "var(--icon-info-active)"
@@ -249,6 +249,7 @@
--color-v2-text-text-contrast: var(--v2-text-text-contrast);
--color-v2-text-text-accent: var(--v2-text-text-accent);
--color-v2-text-text-accent-hover: var(--v2-text-text-accent-hover);
--color-v2-text-text-code-accent: var(--v2-text-text-code-accent);
--color-v2-icon-icon-base: var(--v2-icon-icon-base);
--color-v2-icon-icon-muted: var(--v2-icon-icon-muted);
--color-v2-icon-icon-inverse: var(--v2-icon-icon-inverse);
+20 -14
View File
@@ -20,6 +20,8 @@
"text-base": "#6F6F6F",
"text-weak": "#8F8F8F",
"text-weaker": "#C7C7C7",
"text-diff-add-base": "var(--v2-state-fg-success)",
"text-diff-delete-base": "var(--v2-state-fg-danger)",
"border-weak-base": "#DBDBDB",
"border-weaker-base": "#E8E8E8",
"icon-base": "#8F8F8F",
@@ -31,14 +33,14 @@
"surface-interactive-weak": "#F5FAFF",
"icon-success-base": "#0ABE00",
"surface-success-base": "#E6FFE5",
"syntax-comment": "#7a7a7a",
"syntax-keyword": "#a753ae",
"syntax-string": "#00ceb9",
"syntax-primitive": "#034cff",
"syntax-property": "#a753ae",
"syntax-type": "#8a6f00",
"syntax-comment": "var(--v2-text-text-muted)",
"syntax-keyword": "var(--v2-pink-800)",
"syntax-string": "var(--v2-green-800)",
"syntax-primitive": "var(--v2-pink-800)",
"syntax-property": "var(--v2-orange-800)",
"syntax-type": "var(--v2-purple-800)",
"syntax-constant": "#007b80",
"syntax-critical": "#ff8c00",
"syntax-critical": "var(--v2-red-800)",
"syntax-diff-delete": "#ff8c00",
"syntax-diff-unknown": "#a753ae",
"surface-critical-base": "#FFF2F0"
@@ -170,6 +172,7 @@
"v2-text-text-contrast": "var(--v2-grey-50)",
"v2-text-text-accent": "var(--v2-blue-600)",
"v2-text-text-accent-hover": "var(--v2-blue-700)",
"v2-text-text-code-accent": "var(--v2-blue-900)",
"v2-icon-icon-base": "var(--v2-grey-800)",
"v2-icon-icon-muted": "var(--v2-grey-600)",
"v2-icon-icon-inverse": "var(--v2-grey-50)",
@@ -252,6 +255,8 @@
"text-base": "#A0A0A0",
"text-weak": "#707070",
"text-weaker": "#505050",
"text-diff-add-base": "var(--v2-state-fg-success)",
"text-diff-delete-base": "var(--v2-state-fg-danger)",
"border-weak-base": "#282828",
"border-weaker-base": "#232323",
"icon-base": "#7E7E7E",
@@ -262,14 +267,14 @@
"surface-base-hover": "#FFFFFF0D",
"surface-interactive-weak": "#0D172B",
"surface-success-base": "#022B00",
"syntax-comment": "#8f8f8f",
"syntax-keyword": "#edb2f1",
"syntax-string": "#00ceb9",
"syntax-primitive": "#8cb0ff",
"syntax-property": "#fab283",
"syntax-type": "#fcd53a",
"syntax-comment": "var(--v2-text-text-muted)",
"syntax-keyword": "var(--v2-pink-400)",
"syntax-string": "var(--v2-green-400)",
"syntax-primitive": "var(--v2-pink-400)",
"syntax-property": "var(--v2-orange-400)",
"syntax-type": "var(--v2-purple-400)",
"syntax-constant": "#93e9f6",
"syntax-critical": "#fab283",
"syntax-critical": "var(--v2-red-400)",
"syntax-diff-delete": "#fab283",
"syntax-diff-unknown": "#edb2f1",
"surface-critical-base": "#1F0603"
@@ -401,6 +406,7 @@
"v2-text-text-contrast": "var(--v2-grey-50)",
"v2-text-text-accent": "var(--v2-blue-400)",
"v2-text-text-accent-hover": "var(--v2-blue-300)",
"v2-text-text-code-accent": "var(--v2-blue-400)",
"v2-icon-icon-base": "var(--v2-grey-400)",
"v2-icon-icon-muted": "var(--v2-grey-600)",
"v2-icon-icon-inverse": "var(--v2-grey-1100)",
+2
View File
@@ -18,6 +18,7 @@ const light: Record<string, V2ColorValue> = {
"v2-text-text-contrast": ref("v2-grey-100"),
"v2-text-text-accent": ref("v2-blue-600"),
"v2-text-text-accent-hover": ref("v2-blue-700"),
"v2-text-text-code-accent": ref("v2-blue-900"),
"v2-border-border-muted": ref("v2-alpha-dark-8"),
"v2-border-border-base": ref("v2-alpha-dark-10"),
"v2-border-border-strong": ref("v2-alpha-dark-20"),
@@ -81,6 +82,7 @@ const dark: Record<string, V2ColorValue> = {
"v2-text-text-contrast": ref("v2-grey-100"),
"v2-text-text-accent": ref("v2-blue-400"),
"v2-text-text-accent-hover": ref("v2-blue-300"),
"v2-text-text-code-accent": ref("v2-blue-400"),
"v2-border-border-muted": ref("v2-alpha-light-8"),
"v2-border-border-base": ref("v2-alpha-light-10"),
"v2-border-border-strong": ref("v2-alpha-light-20"),
@@ -89,6 +89,30 @@
cursor: not-allowed;
}
/* Danger */
[data-component="button-v2"][data-variant="danger"] {
background-color: var(--v2-background-bg-button-neutral);
color: var(--v2-state-fg-danger);
box-shadow: var(--v2-elevation-button-neutral);
}
[data-component="button-v2"][data-variant="danger"]:is(:hover, [data-state="hover"]):not(:disabled) {
background-image:
linear-gradient(90deg, var(--v2-overlay-simple-overlay-hover) 0%, var(--v2-overlay-simple-overlay-hover) 100%),
linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
}
[data-component="button-v2"][data-variant="danger"]:is(:active, [data-state="pressed"]):not(:disabled) {
background-image:
linear-gradient(90deg, var(--v2-overlay-simple-overlay-pressed) 0%, var(--v2-overlay-simple-overlay-pressed) 100%),
linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
}
[data-component="button-v2"][data-variant="danger"]:is(:disabled, [data-state="disabled"]) {
opacity: 0.5;
cursor: not-allowed;
}
/* Contrast */
[data-component="button-v2"][data-variant="contrast"] {
background-image:
@@ -4,7 +4,7 @@ const docs = `### Overview
Button v2 with three visual variants and two sizes.
### API
- \`variant\`: "neutral" | "contrast" | "ghost" | "ghost-muted".
- \`variant\`: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted".
- \`size\`: "normal" | "large".
- \`icon\`: Optional icon name.
- Inherits Kobalte Button props and native button attributes.
@@ -39,7 +39,7 @@ export default {
},
variant: {
control: "select",
options: ["neutral", "contrast", "ghost", "ghost-muted"],
options: ["neutral", "danger", "contrast", "ghost", "ghost-muted"],
},
size: {
control: "select",
@@ -61,6 +61,7 @@ export const Variants = {
}}
>
<ButtonV2 variant="neutral">Neutral</ButtonV2>
<ButtonV2 variant="danger">Danger</ButtonV2>
<ButtonV2 variant="contrast">Contrast</ButtonV2>
<ButtonV2 variant="ghost">Ghost</ButtonV2>
<ButtonV2 variant="ghost-muted" icon="edit">
@@ -115,7 +116,7 @@ export const Icon = {
export const AllStates = {
render: () => {
const variants = ["neutral", "contrast", "ghost", "ghost-muted"] as const
const variants = ["neutral", "danger", "contrast", "ghost", "ghost-muted"] as const
const states = ["default", "hover", "pressed", "focus", "disabled"] as const
const toTitleCase = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)
return (
+1 -1
View File
@@ -7,7 +7,7 @@ export interface ButtonV2Props
extends ComponentProps<typeof Kobalte>,
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
size?: "small" | "normal" | "large"
variant?: "neutral" | "contrast" | "ghost" | "ghost-muted"
variant?: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted"
icon?: IconProps["name"]
}
+32 -8
View File
@@ -49,34 +49,51 @@
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 16px;
gap: 8px;
flex-shrink: 0;
align-self: stretch;
width: 100%;
gap: 0;
padding: 16px;
flex-shrink: 0;
[data-slot="dialog-header-title"] {
margin: 0;
flex: 1;
min-width: 0;
font-weight: 530;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
}
[data-slot="dialog-title-group"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
gap: 4px;
flex: 1;
min-width: 0;
}
[data-slot="dialog-title"] {
margin: 0;
flex: none;
flex-grow: 0;
font-weight: 530;
font-size: 15px;
line-height: 100%;
line-height: 20px;
letter-spacing: -0.13px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
}
[data-slot="dialog-description"] {
flex: none;
flex-grow: 0;
font-weight: 440;
font-size: 13px;
line-height: 100%;
line-height: 20px;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
font-variation-settings: "slnt" 0;
@@ -86,8 +103,11 @@
display: flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
width: 20px;
height: 20px;
margin-top: -2px;
margin-right: -2px;
margin-left: auto;
border-radius: 4px;
flex-shrink: 0;
cursor: pointer;
@@ -97,6 +117,10 @@
background: var(--v2-overlay-simple-overlay-hover);
}
}
&[data-hide-close] [data-slot="dialog-close-button"] {
display: none;
}
}
[data-slot="dialog-footer"] {
@@ -1,17 +1,17 @@
import { Dialog as KobalteDialog } from "@kobalte/core/dialog"
import { Dialog, DialogFooter } from "./dialog-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle, DialogTitleGroup } from "./dialog-v2"
import { ButtonV2 } from "./button-v2"
const docs = `### Overview
Dialog content wrapper built on Kobalte's dialog primitive with v2 styling.
### API
- Optional: \`title\`, \`description\`, \`action\`.
- \`size\`: normal | large | x-large.
- \`fit\` and \`transition\` control layout and animation.
Compose with \`DialogHeader\`, \`DialogTitle\`, \`DialogTitleGroup\`, \`DialogBody\`, and \`DialogFooter\`.
### Variants and states
- Sizes and optional header/action controls.
### API
- \`Dialog\`: \`size\` (normal | large | x-large), \`variant\`, \`fit\`.
- \`DialogHeader\`: row container with optional \`closeLabel\` and \`hideClose\`.
- \`DialogTitle\`: accessible single-line header title.
- \`DialogTitleGroup\`: column with \`title\` and required \`description\`.
### Accessibility
- Focus trapping and aria attributes provided by Kobalte Dialog.
@@ -34,6 +34,14 @@ export default {
},
}
function dialogHeader(title: string, description: string) {
return (
<DialogHeader>
<DialogTitleGroup title={title} description={description} />
</DialogHeader>
)
}
export const Basic = {
render: () => (
<KobalteDialog defaultOpen>
@@ -42,8 +50,9 @@ export const Basic = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Dialog" description="Description">
Dialog body content.
<Dialog>
{dialogHeader("Dialog", "Description")}
<DialogBody>Dialog body content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
@@ -59,8 +68,9 @@ export const Sizes = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Normal" description="Normal size">
Normal dialog content.
<Dialog>
{dialogHeader("Normal", "Normal size")}
<DialogBody>Normal dialog content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
@@ -71,8 +81,9 @@ export const Sizes = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog size="large" title="Large" description="Large size">
Large dialog content.
<Dialog size="large">
{dialogHeader("Large", "Large size")}
<DialogBody>Large dialog content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
@@ -83,8 +94,9 @@ export const Sizes = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog size="x-large" title="Extra large" description="X-large size">
X-large dialog content.
<Dialog size="x-large">
{dialogHeader("Extra large", "X-large size")}
<DialogBody>X-large dialog content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
@@ -92,24 +104,41 @@ export const Sizes = {
),
}
export const CustomAction = {
export const TitleOnly = {
render: () => (
<KobalteDialog>
<KobalteDialog defaultOpen>
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
Open action dialog
Open dialog
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog
title="Custom action"
description="Dialog with a custom header action"
action={
<Dialog>
<DialogHeader>
<DialogTitle>Open project</DialogTitle>
</DialogHeader>
<DialogBody>Dialog body content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
),
}
export const HeaderControls = {
render: () => (
<KobalteDialog>
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
Open dialog
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog>
<DialogHeader>
<DialogTitleGroup title="Custom header" description="Dialog with an extra header control" />
<ButtonV2 variant="neutral" size="small">
Help
</ButtonV2>
}
>
Dialog body content.
</DialogHeader>
<DialogBody>Dialog body content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
@@ -124,7 +153,8 @@ export const WithFooter = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Save changes" description="Your changes will be lost if you don't save them." fit>
<Dialog fit>
{dialogHeader("Save changes", "Your changes will be lost if you don't save them.")}
<DialogFooter>
<ButtonV2 variant="neutral">Cancel</ButtonV2>
<ButtonV2 variant="contrast">Save</ButtonV2>
@@ -143,7 +173,8 @@ export const WithFooterThreeButtons = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Unsaved changes" description="You have unsaved changes. What would you like to do?" fit>
<Dialog fit>
{dialogHeader("Unsaved changes", "You have unsaved changes. What would you like to do?")}
<DialogFooter>
<span style={{ "margin-right": "auto" }}>
<ButtonV2 variant="ghost">Remind me later</ButtonV2>
@@ -165,8 +196,9 @@ export const Fit = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Fit content" fit>
Dialog fits its content.
<Dialog fit>
{dialogHeader("Fit content", "Dialog fits its content.")}
<DialogBody>Dialog fits its content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
+66 -47
View File
@@ -3,9 +3,6 @@ import { type ComponentProps, type JSXElement, type ParentProps, Show, children,
import "./dialog-v2.css"
export interface DialogProps extends ParentProps {
title?: JSXElement
description?: JSXElement
action?: JSXElement
size?: "normal" | "large" | "x-large"
variant?: "default" | "settings"
class?: ComponentProps<"div">["class"]
@@ -13,26 +10,76 @@ export interface DialogProps extends ParentProps {
fit?: boolean
}
export interface DialogHeaderProps extends ParentProps {
closeLabel?: string
hideClose?: boolean
}
export interface DialogTitleGroupProps {
title?: JSXElement
description: JSXElement
}
export function DialogFooter(props: ParentProps) {
return <div data-slot="dialog-footer">{props.children}</div>
}
export function DialogBody(props: ParentProps & { class?: ComponentProps<"div">["class"] }) {
const [local] = splitProps(props, ["class", "children"])
return (
<div data-slot="dialog-body" class={local.class}>
{local.children}
</div>
)
}
export function DialogTitle(props: ParentProps) {
return <Kobalte.Title data-slot="dialog-header-title">{props.children}</Kobalte.Title>
}
export function DialogTitleGroup(props: DialogTitleGroupProps) {
const title = children(() => props.title)
const description = children(() => props.description)
return (
<div data-slot="dialog-title-group">
<Show when={title()}>{(t) => <Kobalte.Title data-slot="dialog-title">{t()}</Kobalte.Title>}</Show>
<Kobalte.Description data-slot="dialog-description">{description()}</Kobalte.Description>
</div>
)
}
export function DialogHeader(props: DialogHeaderProps) {
const [local] = splitProps(props, ["closeLabel", "hideClose", "children"])
const hideClose = () => local.hideClose === true
return (
<div data-slot="dialog-header" data-hide-close={hideClose() ? "" : undefined}>
{local.children}
{!hideClose() && (
<Kobalte.CloseButton data-slot="dialog-close-button" aria-label={local.closeLabel ?? "Close"}>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12.4446 3.55469L3.55566 12.4436M3.55566 3.55469L12.4446 12.4436"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</Kobalte.CloseButton>
)}
</div>
)
}
export function Dialog(props: DialogProps) {
const [local] = splitProps(props, [
"title",
"description",
"action",
"size",
"variant",
"class",
"classList",
"fit",
"children",
])
const title = children(() => local.title)
const description = children(() => local.description)
const action = children(() => local.action)
const hasHeader = () => title() || action()
const [local] = splitProps(props, ["size", "variant", "class", "classList", "fit", "children"])
return (
<div
@@ -44,7 +91,6 @@ export function Dialog(props: DialogProps) {
<div data-slot="dialog-container">
<Kobalte.Content
data-slot="dialog-content"
data-no-header={!hasHeader() ? "" : undefined}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
@@ -58,34 +104,7 @@ export function Dialog(props: DialogProps) {
}
}}
>
<Show when={hasHeader()}>
<div data-slot="dialog-header">
<div data-slot="dialog-title-group">
<Show when={title()}>{(t) => <Kobalte.Title data-slot="dialog-title">{t()}</Kobalte.Title>}</Show>
<Show when={description()}>
{(d) => <Kobalte.Description data-slot="dialog-description">{d()}</Kobalte.Description>}
</Show>
</div>
<Show when={action()}>{(a) => a()}</Show>
<Kobalte.CloseButton data-slot="dialog-close-button" aria-label="Close">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12.4446 3.55469L3.55566 12.4436M3.55566 3.55469L12.4446 12.4436"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</Kobalte.CloseButton>
</div>
</Show>
<div data-slot="dialog-body">{local.children}</div>
{local.children}
</Kobalte.Content>
</div>
</div>
@@ -0,0 +1,11 @@
[data-component="divider-v2"] {
box-sizing: border-box;
width: 100%;
height: 0.5px;
margin-block: 0.25px;
border: none;
background: var(--v2-border-border-strong);
flex: none;
align-self: stretch;
flex-grow: 0;
}
@@ -0,0 +1,38 @@
import { DividerV2 } from "./divider-v2"
const docs = `### Overview
Horizontal hairline divider for v2 layouts.
### API
- Inherits native div attributes.
- Stretches to full width of its flex parent.
### Theming/tokens
- Uses \`data-component="divider-v2"\`.
- Border color: \`--v2-border-border-strong\`.
`
export default {
title: "UI V2/Divider",
id: "components-divider-v2",
component: DividerV2,
tags: ["autodocs"],
parameters: {
frameWidth: "320px",
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => (
<div style={{ display: "flex", "flex-direction": "column", gap: "16px", padding: "16px" }}>
<span>Above</span>
<DividerV2 />
<span>Below</span>
</div>
),
}
@@ -0,0 +1,20 @@
import { type ComponentProps, splitProps } from "solid-js"
import "./divider-v2.css"
export interface DividerV2Props extends ComponentProps<"div"> {}
export function DividerV2(props: DividerV2Props) {
const [local, rest] = splitProps(props, ["class", "classList"])
return (
<div
{...rest}
role="separator"
aria-orientation="horizontal"
data-component="divider-v2"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
/>
)
}
+20
View File
@@ -49,6 +49,26 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor"/>`,
},
check: {
viewBox: "0 0 16 16",
body: `<path d="M3.53613 8.17857L6.39328 11.75L12.4647 4.25" stroke="currentColor"/>`,
},
monitor: {
viewBox: "0 0 16 16",
body: `<path d="M4.05559 9.38889H0.500007C0.500007 9.38889 0.500017 8.59298 0.500017 7.61112V2.27778C0.500017 1.29594 0.500102 0.5 0.500102 0.5H13.3889C13.3889 0.5 13.3889 1.29594 13.3889 2.27778V7.61112C13.3889 8.59298 13.3889 9.38889 13.3889 9.38889H9.83336M4.05559 9.38889V11.6111H6.94448H9.83336V9.38889M4.05559 9.38889H9.83336" transform="translate(1.05556 1.94444)" stroke="currentColor"/>`,
},
"workspace-new": {
viewBox: "0 0 16 16",
body: `<path d="M2 10.7578V14.0011H5.24324M13.9991 5.24324V2H10.7559M13.9991 10.7578V14.0011H10.7559M2 5.24324V2H5.24324" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/><path d="M8 4.5V11.5M4.5 8H11.5" stroke="currentColor" stroke-linejoin="round"/>`,
},
"workspace-isolated": {
viewBox: "0 0 16 16",
body: `<path d="M10.5 10.5V5.5H5.5V10.5H10.5Z" fill="currentColor"/><rect x="2.5" y="2.5" width="11" height="11" stroke="currentColor"/>`,
},
workspace: {
viewBox: "0 0 16 16",
body: `<path d="M2 10.668V14.0013H10.6667M13.9974 10.6667V2H2.66406M13.9974 10.668V14.0013H10.6641M2 10V2H5.33333" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/><path d="M10.6693 10.6654V5.33203H5.33594V10.6654H10.6693Z" fill="currentColor"/>`,
},
close: {
viewBox: "0 0 20 20",
body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`,
+16
View File
@@ -1,4 +1,16 @@
@layer theme {
html[data-theme="oc-2"] body:not([data-new-layout]) {
--text-diff-add-base: light-dark(#167517, #c4ffc0);
--text-diff-delete-base: light-dark(#fa3012, #ec2f14);
--syntax-comment: light-dark(#7a7a7a, #8f8f8f);
--syntax-keyword: light-dark(#a753ae, #edb2f1);
--syntax-string: #00ceb9;
--syntax-primitive: light-dark(#034cff, #8cb0ff);
--syntax-property: light-dark(#a753ae, #fab283);
--syntax-type: light-dark(#8a6f00, #fcd53a);
--syntax-critical: light-dark(#ff8c00, #fab283);
}
:root {
color-scheme: light;
@@ -22,6 +34,7 @@
--v2-text-text-contrast: var(--v2-grey-50);
--v2-text-text-accent: var(--v2-blue-600);
--v2-text-text-accent-hover: var(--v2-blue-700);
--v2-text-text-code-accent: var(--v2-blue-900);
/* ── Icon ── */
--v2-icon-icon-base: var(--v2-grey-800);
@@ -141,6 +154,7 @@
--v2-text-text-contrast: var(--v2-grey-100);
--v2-text-text-accent: var(--v2-blue-400);
--v2-text-text-accent-hover: var(--v2-blue-300);
--v2-text-text-code-accent: var(--v2-blue-400);
--v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600);
@@ -243,6 +257,7 @@
--v2-text-text-contrast: var(--v2-grey-50);
--v2-text-text-accent: var(--v2-blue-600);
--v2-text-text-accent-hover: var(--v2-blue-700);
--v2-text-text-code-accent: var(--v2-blue-900);
--v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600);
@@ -352,6 +367,7 @@
--v2-text-text-contrast: var(--v2-grey-50);
--v2-text-text-accent: var(--v2-blue-400);
--v2-text-text-accent-hover: var(--v2-blue-300);
--v2-text-text-code-accent: var(--v2-blue-400);
--v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600);
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
// Publish declarations so consumer compiler options do not typecheck our TSX implementation.
"declaration": true,
"emitDeclarationOnly": true
},
"include": ["src"],
"exclude": ["**/*.stories.*", "**/*.test.*", "**/*.mdx"]
}
+7 -8
View File
@@ -3,8 +3,7 @@ title: Enterprise
description: Using OpenCode securely in your organization.
---
import config from "../../../config.mjs"
export const email = `mailto:${config.email}`
export const enterprise = "https://opencode.ai/enterprise"
OpenCode Enterprise is for organizations that want to ensure that their code and data never leaves their infrastructure. It can do this by using a centralized config that integrates with your SSO and internal AI gateway.
@@ -15,7 +14,7 @@ OpenCode does not store any of your code or context data.
To get started with OpenCode Enterprise:
1. Do a trial internally with your team.
2. **<a href={email}>Contact us</a>** to discuss pricing and implementation options.
2. **<a href={enterprise}>Contact us</a>** to discuss pricing and implementation options.
---
@@ -63,14 +62,14 @@ We recommend you disable this for your trial.
## Pricing
We use a per-seat model for OpenCode Enterprise. If you have your own LLM gateway, we do not charge for tokens used. For further details about pricing and implementation options, **<a href={email}>contact us</a>**.
We use a per-seat model for OpenCode Enterprise. If you have your own LLM gateway, we do not charge for tokens used. For further details about pricing and implementation options, **<a href={enterprise}>contact us</a>**.
---
## Deployment
Once you have completed your trial and you are ready to use OpenCode at
your organization, you can **<a href={email}>contact us</a>** to discuss
your organization, you can **<a href={enterprise}>contact us</a>** to discuss
pricing and implementation options.
---
@@ -104,7 +103,7 @@ You can also disable all other AI providers, ensuring all requests go through yo
While we recommend disabling the share pages to ensure your data never leaves
your organization, we can also help you self-host them on your infrastructure.
This is currently on our roadmap. If you're interested, **<a href={email}>let us know</a>**.
This is currently on our roadmap. If you're interested, **<a href={enterprise}>let us know</a>**.
---
@@ -122,14 +121,14 @@ OpenCode Enterprise is for organizations that want to ensure that their code and
Simply start with an internal trial with your team. OpenCode by default does not store your code or context data, making it easy to get started.
Then **<a href={email}>contact us</a>** to discuss pricing and implementation options.
Then **<a href={enterprise}>contact us</a>** to discuss pricing and implementation options.
</details>
<details>
<summary>How does enterprise pricing work?</summary>
We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not charge for tokens used. For further details, **<a href={email}>contact us</a>** for a custom quote based on your organization's needs.
We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not charge for tokens used. For further details, **<a href={enterprise}>contact us</a>** for a custom quote based on your organization's needs.
</details>
@@ -48,6 +48,42 @@ You can customize the base URL for any provider by setting the `baseURL` option.
---
#### Hiding models
You can hide specific models from the `/models` picker for a provider using the `blacklist` option. This is useful when a provider exposes models you don't want to use or select.
```json title="opencode.json" {6}
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"anthropic": {
"blacklist": ["claude-opus-4-20250514"]
}
}
}
```
The inverse `whitelist` option hides every model except the ones listed.
```json title="opencode.json" {6}
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"anthropic": {
"whitelist": ["claude-sonnet-4-20250514"]
}
}
}
```
Both options take an array of model IDs — the same IDs shown in the `/models` picker.
- `blacklist` removes the listed models from the picker.
- `whitelist` keeps only the listed models and hides the rest.
- You can combine them: `whitelist` narrows the set, then `blacklist` removes entries from it.
---
## OpenCode Zen
OpenCode Zen is a list of models provided by the OpenCode team that have been