Compare commits

..

3 Commits

Author SHA1 Message Date
Kit Langton 2ce8dfda5f test(sdk): cover embedded session live tail 2026-06-26 00:00:38 -04:00
Kit Langton 5d27fac711 fix(sdk): preserve session stream ordering 2026-06-25 23:50:59 -04:00
Kit Langton 605fb0b6c5 feat(sdk): unify session event stream 2026-06-25 23:50:42 -04:00
121 changed files with 2570 additions and 4017 deletions
+4 -2
View File
@@ -162,12 +162,14 @@ _Avoid_: Response envelope
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `sessions.events({ sessionID, after })` is one public Session-scoped event stream. It verifies the Session, captures a fixed durable cutoff after registering observation, replays durable events in `(after, cutoff]`, emits one authoritative process-local `session.activity` value, then continues with committed durable events and live-only Session output fragments. Only durable events carry aggregate-sequence cursor metadata.
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold Session event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers resume with the last observed durable sequence; live-only fragments are not replayed, and every connection receives a fresh authoritative activity value.
- The Session event stream treats database rows as authoritative and process notifications as bounded, non-blocking wakeups. Before emitting an observed live-only fragment it drains later durable rows, preserving causal durable start boundaries without exposing an additional fence field.
- Aggregate deletion currently removes the Session's durable event rows and sequence, including deletion history. The Session event stream therefore cannot promise replay after deletion until retention or a typed history-expired outcome is designed.
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
+4 -22
View File
@@ -31,10 +31,6 @@
"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:*",
@@ -63,7 +59,7 @@
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
@@ -331,6 +327,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.0.2",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
@@ -353,6 +350,7 @@
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"drizzle-kit": "catalog:",
},
@@ -1453,20 +1451,6 @@
"@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=="],
@@ -2303,8 +2287,6 @@
"@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=="],
@@ -3799,7 +3781,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#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="],
"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-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=",
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=",
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=",
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE="
}
}
+1 -5
View File
@@ -44,10 +44,6 @@
"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:*",
@@ -76,7 +72,7 @@
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
+1 -3
View File
@@ -16,7 +16,6 @@ import {
type Component,
createEffect,
createMemo,
createRenderEffect,
createResource,
createSignal,
ErrorBoundary,
@@ -279,11 +278,10 @@ function QueryProvider(props: ParentProps) {
function BodyDesignClass() {
const settings = useSettings()
createRenderEffect(() => {
createEffect(() => {
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, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { Dialog, DialogFooter } 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,7 +27,6 @@ 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
@@ -267,12 +266,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
onCleanup(() => tree?.cleanUp())
return (
<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!">
<Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
<div class="directory-picker-v2-body">
<div class="directory-picker-v2-path" ref={pathArea}>
<TextInputV2
value={input()}
@@ -354,7 +349,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
</Show>
</div>
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
</DialogBody>
</div>
<DialogFooter>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
@@ -1,8 +1,7 @@
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"
@@ -26,23 +25,12 @@ 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: "", active: "" })
const [store, setStore] = createStore({ open: false, search: "" })
let searchRef: HTMLInputElement | undefined
const selected = () => {
@@ -65,27 +53,8 @@ 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: "", active: "" })
setStore({ open: false, search: "" })
input.onDone()
}
const select = (project: PromptProject) => {
@@ -98,7 +67,7 @@ export function createPromptProjectController(input: {
close()
}
const add = (server?: string) => {
setStore({ open: false, search: "", active: "" })
setStore("open", false)
input.controls().add(language.t("command.project.open"), server)
}
@@ -106,11 +75,8 @@ 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"),
@@ -120,202 +86,50 @@ export function createPromptProjectController(input: {
add,
select,
setOpen(open: boolean) {
if (open) {
setStore({ open: true, active: initialActive() })
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
return
}
setStore({ open: false, search: "", active: "" })
setStore("open", open)
if (open) requestAnimationFrame(() => searchRef?.focus())
},
setSearch(value: string) {
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)
setStore("search", value)
},
setSearchRef(el: HTMLInputElement) {
searchRef = el
},
focusSearch() {
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
},
}
}
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
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
}
export function PromptProjectSelector(props: { controller: PromptProjectController }) {
return (
<DropdownMenu
<Popover
open={props.controller.open()}
placement={props.placement ?? "bottom"}
placement="bottom-start"
gutter={4}
modal={false}
onOpenChange={(open) => props.controller.setOpen(open)}
>
<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"
<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"
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-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<div class="flex h-7 items-center gap-2 rounded px-3 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-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onPointerDown={(event) => event.preventDefault()}
onClick={() => props.controller.clearSearch()}
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("")}
aria-label={props.controller.labels.clear()}
>
<Icon name="close-small" size="small" />
@@ -325,80 +139,53 @@ export function PromptProjectSelector(props: {
<Show
when={props.controller.servers().length > 1}
fallback={
<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.projects()}>
{(project) => (
<ProjectItem
project={project}
selected={props.controller.selected()}
onSelect={props.controller.select}
/>
)}
</For>
}
>
<For
each={props.controller
.servers()
.filter((server) =>
props.controller.projects().some((project) => project.server?.key === server!.key),
)}
>
<For each={props.controller.servers()}>
{(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>
<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>
<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)}
/>
</div>
)}
</For>
</Show>
</div>
<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>
<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>
)
}
@@ -418,29 +205,15 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
}
function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"])
const [local, rest] = splitProps(props, ["controller", "class", "onClick"])
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-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)
}}
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)}
>
<Show
when={project()}
@@ -464,88 +237,42 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
function ProjectItem(props: {
project: PromptProject
controller: PromptProjectController
selected?: PromptProject
onSelect: (project: PromptProject) => void
}) {
const key = () => props.controller.projectKey(props.project)
return (
<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)}
<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)}
>
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
<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>
<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>
)
}
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)
function ProjectAction(props: { label: string; onSelect: () => void }) {
return (
<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)}
<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}
>
<Icon name="plus" size="small" />
<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>
<span class="min-w-0 flex-1 truncate leading-5">{props.label}</span>
</button>
)
}
@@ -1,102 +0,0 @@
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,6 +1,5 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-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"
@@ -53,12 +52,8 @@ export const DialogServerV2: Component<{
}
return (
<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">
<Dialog title={title()} fit class="settings-v2-server-dialog">
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
<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>
@@ -120,7 +115,7 @@ export const DialogServerV2: Component<{
</div>
</div>
</div>
</DialogBody>
</div>
<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 16px;
padding: 24px 24px 0;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
@@ -0,0 +1,141 @@
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)
})
})
@@ -0,0 +1,137 @@
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
}
@@ -1,33 +0,0 @@
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,6 +8,22 @@ 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)
}
@@ -15,3 +31,7 @@ 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,10 +9,6 @@
justify-content: center;
}
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none;
}
[data-titlebar-tab-list] {
gap: 13.5px;
}
+163 -143
View File
@@ -29,6 +29,9 @@ 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)
@@ -170,105 +173,112 @@ export function TabNavItem(props: {
return (
<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)]"
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)
}}
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}
>
<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
<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) => {
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()
}}
/>
</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()
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>
)
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
/>
</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>
</div>
)
@@ -285,6 +295,9 @@ export function DraftTabItem(props: {
dragging?: boolean
pressed?: boolean
hidden?: boolean
tabKey: string
dragActive: boolean
onPointerDown: (event: PointerEvent) => void
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
@@ -293,62 +306,69 @@ export function DraftTabItem(props: {
}
return (
<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 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)
}}
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}
>
<a
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
draggable={false}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
<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)
}}
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]"
>
<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"
<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]"
>
{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"
/>
<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>
</div>
</div>
)
+393 -160
View File
@@ -1,11 +1,18 @@
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
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 { 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"
@@ -15,30 +22,42 @@ import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
const sortableTransition = { duration: 0 }
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"
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))
@@ -81,79 +100,33 @@ function SessionTabSlot(props: {
})
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"
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>
<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()}
/>
)
}
@@ -169,12 +142,52 @@ 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)
@@ -187,14 +200,220 @@ export function TitlebarTabStrip(props: {
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
refreshOverflow()
if (!drag.active || !listRef) return
dragLayout = captureTabDragLayout(listRef, drag.draftOrder)
updateInsertIndex()
})
},
)
onMount(() => {
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)
})
@@ -205,54 +424,50 @@ export function TitlebarTabStrip(props: {
refreshOverflow()
})
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}
>
<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
createEffect(() => {
if (!drag.active || !scrollRef) return
onCleanup(makeEventListener(scrollRef, "scroll", syncScroll))
})
const { initialIndex, index } = source
if (initialIndex !== index) {
props.onReorder(arrayMove(current, source.initialIndex, source.index))
}
}}
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-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
<For each={props.tabs}>
<div data-titlebar-tab-list class="flex min-w-0 flex-row items-center" ref={listRef}>
<For each={displayTabs()}>
{(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)
@@ -264,50 +479,68 @@ 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}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
suppressNavigation={() => suppressNavigation()}
onPointerDown={(event) => {
if (dragged()) return
onPointerDown(id, event)
}}
onNavigate={(element) => props.onNavigate(tab, element)}
onClose={() => props.onClose(tab)}
/>
)
}
return (
<DraftTabSlot
tab={tab}
id={id}
index={index}
active={() => props.currentTab() === tab}
title={language.t("command.session.new")}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
<DraftTabItem
tabKey={id}
dragActive={drag.active}
onPointerDown={(event) => {
if (dragged()) return
onPointerDown(id, event)
}}
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")}
onNavigate={() => props.onNavigate(tab, ref)}
onClose={() => props.onClose(tab)}
suppressNavigation={() => suppressNavigation()}
active={props.currentTab() === tab}
pressed={pressedId() === id}
hidden={dragged()}
/>
)
}}
</For>
</div>
</DragDropProvider>
</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)]"
/>
</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)]"
/>
</div>
<Show when={drag.active && draggedTab() && dragPreview}>
{(_) => (
<Portal>
<div data-titlebar-tab-preview style={floaterStyle()}>
{dragPreview}
</div>
</Portal>
)}
</Show>
</>
)
}
-4
View File
@@ -656,10 +656,6 @@ 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}}",
+3 -10
View File
@@ -2,7 +2,6 @@ import type { Session } from "@opencode-ai/sdk/v2/client"
import {
createEffect,
createMemo,
createResource,
createRoot,
For,
Match,
@@ -526,16 +525,10 @@ function HomeProjectColumn(props: {
const global = useGlobal()
const dialog = useDialog()
const controller = useServerManagementController({ navigateOnAdd: false })
const [_state, setState, _, ready] = persisted(
const [state, setState] = 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]"
@@ -567,7 +560,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
@@ -580,7 +573,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()}>
+8 -42
View File
@@ -19,9 +19,6 @@ 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
@@ -54,21 +51,16 @@ export default function NewSessionPage() {
onDone: () => inputRef?.focus(),
})
const [store, setStore] = createStore<{ worktree?: string }>({})
const [store, setStore] = createStore({
worktree: "main",
})
const newSessionWorktree = createMemo(() => {
if (store.worktree) return store.worktree
if (store.worktree === "create") return "create"
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
@@ -105,7 +97,7 @@ export default function NewSessionPage() {
</div>
}
>
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}>
<div class="flex flex-col gap-3">
<PromptInput
controls={inputController()}
variant="new-session"
@@ -113,7 +105,7 @@ export default function NewSessionPage() {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
onSubmit={() => comments.clear()}
toolbar={
<Show when={!projectController.selected()}>
@@ -122,34 +114,8 @@ export default function NewSessionPage() {
}
/>
<Show when={projectController.selected()}>
<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 class="flex h-7 min-w-0 items-center gap-0 px-2">
<PromptProjectSelector controller={projectController} />
</div>
</Show>
</div>
+2 -10
View File
@@ -908,13 +908,7 @@ export default function Page() {
)
const reviewPanel = () => (
<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="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
{reviewContent({
diffStyle: layout.review.diffStyle(),
@@ -1719,9 +1713,7 @@ export default function Page() {
>
<div
classList={{
"flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
"flex-1 min-h-0 flex flex-col bg-background-stronger": true,
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}}
@@ -1,6 +1,5 @@
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"
@@ -14,7 +13,6 @@ 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
@@ -24,11 +22,7 @@ export function SessionComposerRegion(props: {
<div
ref={controller.setDockRef}
data-component="session-prompt-dock"
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(),
}}
class="w-full shrink-0 flex flex-col justify-center items-center pb-3 bg-background-stronger pointer-events-none"
>
<div
classList={{
@@ -1019,13 +1019,7 @@ export function MessageTimeline(props: {
<div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}>
{(comment) => (
<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="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
<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>
@@ -1294,11 +1288,7 @@ export function MessageTimeline(props: {
<div
data-session-title
classList={{
"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(),
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"pb-4": true,
"pr-3": true,
@@ -1519,11 +1509,7 @@ export function MessageTimeline(props: {
<Button
size="large"
variant="secondary"
class={
settings.general.newLayoutDesigns()
? "w-full shadow-none border-[0.5px] border-border-weak-base"
: "w-full shadow-none border border-border-weak-base"
}
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
+3 -10
View File
@@ -1,8 +1,7 @@
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, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Dialog } from "@opencode-ai/ui/v2/dialog-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"
@@ -31,14 +30,8 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
const language = useLanguage()
const openAddWsl = () => {
dialog.push(() => (
<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 title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
<DialogAddWslServer />
</Dialog>
))
}
+92 -29
View File
@@ -601,6 +601,14 @@ export type SessionsEventsInput = {
}
export type SessionsEventsOutput =
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.activity"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly active: boolean }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
@@ -810,6 +818,20 @@ export type SessionsEventsOutput =
readonly textID: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.text.delta"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly textID: string
readonly delta: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
@@ -824,6 +846,49 @@ export type SessionsEventsOutput =
readonly text: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.reasoning.started"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.reasoning.delta"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly delta: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.reasoning.ended"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
@@ -838,6 +903,20 @@ export type SessionsEventsOutput =
readonly name: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.tool.input.delta"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: string
readonly delta: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
@@ -932,35 +1011,6 @@ export type SessionsEventsOutput =
}
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.reasoning.started"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.reasoning.ended"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
@@ -994,6 +1044,19 @@ export type SessionsEventsOutput =
readonly reason: "auto" | "manual"
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.compaction.delta"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly text: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
+9 -1
View File
@@ -22,7 +22,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(activityEvent)}\n\n`, {
headers: { "content-type": "text/event-stream" },
}),
),
@@ -93,6 +93,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([])
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
expect(result.events[1]).toEqual(activityEvent)
expect(result.events[1]).not.toHaveProperty("durable")
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
})
@@ -145,3 +147,9 @@ const modelSwitchedEvent = {
model: { id: "claude", providerID: "anthropic" },
},
}
const activityEvent = {
id: "evt_activity",
type: "session.activity" as const,
data: { sessionID: "ses_test", active: false },
}
+14 -4
View File
@@ -25,9 +25,12 @@ test("session methods use the public HTTP contract", async () => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
requests.push({ url, init })
if (url.includes("/event")) {
return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
headers: { "content-type": "text/event-stream" },
})
return new Response(
`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(activityEvent)}\n\n`,
{
headers: { "content-type": "text/event-stream" },
},
)
}
if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/context")) return Response.json({ data: [] })
@@ -65,7 +68,8 @@ test("session methods use the public HTTP contract", async () => {
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(context).toEqual([])
expect(events).toEqual([modelSwitchedEvent])
expect(events).toEqual([modelSwitchedEvent, activityEvent])
expect(events[1]).not.toHaveProperty("durable")
expect(message).toEqual(modelSwitchedMessage)
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
@@ -153,3 +157,9 @@ const modelSwitchedEvent = {
model: { id: "claude", providerID: "anthropic" },
},
}
const activityEvent = {
id: "evt_activity",
type: "session.activity",
data: { sessionID: "ses_test", active: false },
}
+1 -68
View File
@@ -1,15 +1,10 @@
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"
@@ -23,13 +18,9 @@ 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: requestHeaders,
headers: req.headers,
body: req.body,
})
@@ -39,7 +30,6 @@ 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,
@@ -62,60 +52,3 @@ 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(", "),
)
}
+2
View File
@@ -45,6 +45,7 @@
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -116,6 +117,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.0.2",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
-35
View File
@@ -1,35 +0,0 @@
# HTML to Markdown renderer
## Goal
Replace Turndown and Domino in V2 Core only when an htmlparser2 event renderer preserves model-readable semantics and improves resource use and shipped size.
## Commands
- `bun run test tool-webfetch.test.ts` from `packages/core`
- `bun build --entrypoints src/tool/html-markdown.ts --outdir <dir> --target node --format esm --minify`
- `bun run build --single --skip-install` from `packages/cli`
## Metrics
- Primary: median conversion throughput after one warmup and nine measured runs.
- Secondary: min/max spread, minified/gzip bundle size, CLI artifact size, and peak RSS where practical.
## Experiment Log
| Experiment | Hypothesis | Before | After | Decision |
| --- | --- | --- | --- | --- |
| Event renderer | Avoiding Domino's DOM lowers conversion cost while retaining semantics. | Turndown 4.23 MiB/s median (72.55 ms, 66.93-109.75) | Candidate 10.12 MiB/s median (30.32 ms, 22.29-83.55) | Keep: 2.39x throughput |
| Safe fences | Fence length derived from code content prevents embedded backticks from closing blocks. | Turndown emitted triple fences around embedded triples | Candidate expands to four backticks | Keep |
| Tables | Row/cell events retain tabular relationships better than flattened cell blocks. | Turndown flattened cells | Candidate emits GFM-readable tables | Keep |
| Malformed inline blocks | Delimiters spanning implied block closes produce malformed Markdown. | Candidate left open emphasis | Candidate drops the delimiter and preserves visible text | Keep |
## Evaluation
Temporary snapshots from Example Domain, MDN's table reference, Python asyncio documentation, RFC 9110, and W3C's forms tutorial were evaluated on 2026-08-12. Candidate output retained the same heading counts on four sites and one additional visible MDN heading, the same link counts on three sites, two additional Python links, and the same fenced-code counts where Turndown recognized fences. Candidate output was 0-3.6% smaller; tables and preformatted code were more explicit. Snapshots and generated output are not committed.
The minified isolated evaluation bundle containing Turndown, Domino, htmlparser2, and both renderers was 311,557 bytes (98,680 gzip). The candidate renderer with htmlparser2 was 61,293 bytes (26,922 gzip). Installed Turndown plus Domino occupied 9,028 KiB; htmlparser2 was already required by Core.
The same-commit macOS arm64 CLI executable was 87,338,978 bytes with Turndown and 87,091,298 bytes with the candidate, a 247,680-byte reduction.
Real-site HTML is temporary evaluation data and is not committed.
+95 -26
View File
@@ -1,9 +1,9 @@
export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Scope, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import { and, asc, eq, gt } from "drizzle-orm"
import { and, asc, eq, gt, lte } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
@@ -67,6 +67,19 @@ export interface Interface {
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload>
readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
readonly observeAggregate: (input: {
readonly aggregateID: string
readonly after?: number
readonly live: (event: Payload) => boolean
}) => Effect.Effect<
{
readonly replay: ReadonlyArray<Payload>
readonly updates: Stream.Stream<Payload>
readonly offer: (event: Payload, position?: "after" | "before") => boolean
},
never,
Scope.Scope
>
/** @deprecated Use `all()` and consume the returned stream. */
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
@@ -94,12 +107,12 @@ export const layerWith = (options?: LayerOptions) =>
Effect.gen(function* () {
const pubsub = {
all: yield* PubSub.unbounded<Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
durable: new Map<string, Set<PubSub.PubSub<number>>>(),
typed: new Map<string, PubSub.PubSub<Payload>>(),
}
const projectors = new Map<string, Subscriber[]>()
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
const listeners = new Array<Subscriber>()
const listeners = new Set<Subscriber>()
const { db } = yield* Database.Service
const getOrCreate = (definition: Definition) =>
@@ -275,7 +288,7 @@ export const layerWith = (options?: LayerOptions) =>
if (committed) {
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
(wake) => PubSub.publish(wake, committed.seq),
{ discard: true },
)
}
@@ -472,13 +485,19 @@ export const layerWith = (options?: LayerOptions) =>
}
}
const readAfter = (aggregateID: string, after: number) =>
const readAfter = (aggregateID: string, after: number, through?: number) =>
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
Effect.andThen(
db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after)))
.where(
and(
eq(EventTable.aggregate_id, aggregateID),
gt(EventTable.seq, after),
through === undefined ? undefined : lte(EventTable.seq, through),
),
)
.orderBy(asc(EventTable.seq))
.all(),
),
@@ -498,7 +517,7 @@ export const layerWith = (options?: LayerOptions) =>
const subscribeDurable = (aggregateID: string) =>
Effect.gen(function* () {
const wake = yield* PubSub.sliding<void>(1)
const wake = yield* PubSub.sliding<number>(1)
const subscription = yield* PubSub.subscribe(wake)
yield* Effect.acquireRelease(
Effect.sync(() => {
@@ -516,34 +535,83 @@ export const layerWith = (options?: LayerOptions) =>
return subscription
})
const aggregateDrain = (
aggregateID: string,
after: number,
): ((through?: number) => Effect.Effect<ReadonlyArray<Payload>>) => {
let sequence = after
return (through?: number) =>
through !== undefined && through <= sequence
? Effect.succeed<ReadonlyArray<Payload>>([])
: Effect.suspend(() => readAfter(aggregateID, sequence, through)).pipe(
Effect.tap((events) =>
Effect.sync(() => {
sequence = events.at(-1)?.durable?.seq ?? sequence
}),
),
)
}
const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
Stream.unwrap(
Effect.gen(function* () {
const wakes = yield* subscribeDurable(input.aggregateID)
let sequence = input.after ?? -1
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
Effect.tap((events) =>
Effect.sync(() => {
sequence = events.at(-1)?.durable?.seq ?? sequence
}),
),
)
const historical = yield* read
const live = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => read),
Stream.flattenIterable,
)
const drain = aggregateDrain(input.aggregateID, input.after ?? -1)
const historical = yield* drain()
const live = Stream.fromSubscription(wakes).pipe(Stream.mapEffect(drain), Stream.flattenIterable)
return Stream.concat(Stream.fromIterable(historical), live)
}),
)
const observeAggregate: Interface["observeAggregate"] = (input) =>
Effect.gen(function* () {
type Signal =
| { readonly _tag: "durable" }
| { readonly _tag: "transient"; readonly event: Payload; readonly position: "after" | "before" }
const signals = yield* Queue.dropping<Signal, Cause.Done>(256)
const wakes = yield* subscribeDurable(input.aggregateID)
let durableQueued = false
let durableThrough = -1
const offer = (event: Payload, position: "after" | "before" = "after") => {
const offered = Queue.offerUnsafe(signals, { _tag: "transient", event, position })
if (!offered) Queue.endUnsafe(signals)
return offered
}
const unsubscribe = yield* listen((event) =>
Effect.sync(() => {
if (input.live(event)) offer(event)
}),
)
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(signals))))
yield* Stream.runForEach(Stream.fromSubscription(wakes), (sequence) =>
Effect.sync(() => {
durableThrough = Math.max(durableThrough, sequence)
if (durableQueued) return
durableQueued = Queue.offerUnsafe(signals, { _tag: "durable" })
}),
).pipe(Effect.forkScoped)
const cutoff = yield* latestSequence(db, input.aggregateID)
const replay = yield* readAfter(input.aggregateID, input.after ?? -1, cutoff)
const drain = aggregateDrain(input.aggregateID, cutoff)
const updates = Stream.fromQueue(signals).pipe(
Stream.mapEffect((signal) => {
if (signal._tag === "durable") {
durableQueued = false
return drain(durableThrough)
}
if (signal.position === "before") return Effect.succeed([signal.event])
return drain().pipe(Effect.map((events) => [...events, signal.event]))
}),
Stream.flattenIterable,
)
return { replay, updates, offer }
})
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
listeners.push(listener)
return Effect.sync(() => {
const index = listeners.indexOf(listener)
if (index >= 0) listeners.splice(index, 1)
})
listeners.add(listener)
return Effect.sync(() => listeners.delete(listener)).pipe(Effect.asVoid)
})
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
@@ -558,6 +626,7 @@ export const layerWith = (options?: LayerOptions) =>
subscribe,
all: streamAll,
durable,
observeAggregate,
listen,
project,
replay,
-276
View File
@@ -1,276 +0,0 @@
// 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,7 +7,6 @@ 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"
@@ -59,21 +58,17 @@ 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(OauthCallbackPage.error(error, { provider: "ChatGPT" }))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
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(OauthCallbackPage.error(message, { provider: "ChatGPT" }))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "ChatGPT" }))
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
@@ -290,3 +285,8 @@ 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>`
+34 -6
View File
@@ -128,7 +128,7 @@ export interface Interface {
readonly events: (input: {
sessionID: SessionSchema.ID
after?: number
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
}) => Stream.Stream<SessionEvent.StreamEvent, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: {
sessionID: SessionSchema.ID
@@ -185,7 +185,11 @@ export const layer = Layer.unwrap(
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const sessionEventTypes = new Set<string>(SessionEvent.Definitions.map((definition) => definition.type))
const isSessionEvent = (event: EventV2.Payload): event is SessionEvent.Event =>
sessionEventTypes.has(event.type)
const isDurableSessionEvent = (event: EventV2.Payload): event is SessionEvent.DurableEvent =>
event.durable !== undefined && isSessionEvent(event)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
@@ -341,10 +345,34 @@ export const layer = Layer.unwrap(
}),
events: (input) =>
Stream.unwrap(
result
.get(input.sessionID)
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
Effect.gen(function* () {
yield* result.get(input.sessionID)
const activity = yield* execution.activity(input.sessionID)
const observed = yield* events.observeAggregate({
aggregateID: input.sessionID,
after: input.after,
live: (event) =>
event.durable === undefined && isSessionEvent(event) && event.data.sessionID === input.sessionID,
})
const initialActivity = SessionEvent.makeActivity(
input.sessionID,
yield* activity.attach((active) =>
observed.offer(SessionEvent.makeActivity(input.sessionID, active), active ? "before" : "after"),
),
)
return Stream.fromIterable(observed.replay.filter(isDurableSessionEvent)).pipe(
Stream.concat(Stream.make(initialActivity)),
Stream.concat(
observed.updates.pipe(
Stream.filter(
(event): event is SessionEvent.StreamEvent =>
event.type === SessionEvent.Activity.type || isSessionEvent(event),
),
),
),
)
}),
),
prompt: Effect.fn("V2Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
+5 -1
View File
@@ -1,12 +1,15 @@
export * as SessionExecution from "./execution"
import { Context, Effect, Layer } from "effect"
import { Context, Effect, Layer, Scope } from "effect"
import { SessionRunner } from "./runner/index"
import { SessionRunCoordinator } from "./run-coordinator"
import { SessionSchema } from "./schema"
export interface Interface {
/** Snapshots active execution owned by this process. */
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
/** Observes foreground ownership with an authoritative initial snapshot. */
readonly activity: (sessionID: SessionSchema.ID) => Effect.Effect<SessionRunCoordinator.Activity, never, Scope.Scope>
/** Starts execution while idle or joins the active execution. */
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */
@@ -23,6 +26,7 @@ export const noopLayer = Layer.succeed(
Service,
Service.of({
active: Effect.succeed(new Set()),
activity: () => Effect.succeed({ attach: () => Effect.succeed(false) }),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: () => Effect.void,
@@ -29,6 +29,7 @@ export const layer = Layer.effect(
return SessionExecution.Service.of({
active: coordinator.active,
activity: coordinator.activity,
interrupt: coordinator.interrupt,
resume: coordinator.run,
wake: coordinator.wake,
+47 -3
View File
@@ -2,10 +2,17 @@ export * as SessionRunCoordinator from "./run-coordinator"
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
export interface Activity {
/** Installs a synchronous observer and snapshots current ownership atomically. */
readonly attach: (observer: (active: boolean) => void) => Effect.Effect<boolean>
}
/** Serializes execution for each key while allowing different keys to run concurrently. */
export interface Coordinator<Key, E> {
/** Snapshots keys with an execution owned by this coordinator. */
readonly active: Effect.Effect<ReadonlySet<Key>>
/** Registers transition observation before taking its authoritative snapshot. */
readonly activity: (key: Key) => Effect.Effect<Activity, never, Scope.Scope>
/** Starts execution while idle or joins the active execution. */
readonly run: (key: Key) => Effect.Effect<void, E>
/** Registers one coalesced follow-up after newly recorded work. */
@@ -26,6 +33,7 @@ export const make = <Key, E>(options: {
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
Effect.gen(function* () {
const active = new Map<Key, Entry<E>>()
const activityObservers = new Map<Key, Set<(active: boolean) => void>>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const makeEntry = (): Entry<E> => ({
@@ -34,6 +42,10 @@ export const make = <Key, E>(options: {
stopping: false,
})
const notifyActivity = (key: Key, value: boolean) => {
for (const observer of activityObservers.get(key) ?? []) observer(value)
}
const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => {
const ready = Deferred.makeUnsafe<void>()
const owner = fork(
@@ -56,8 +68,10 @@ export const make = <Key, E>(options: {
}
const successor = entry.pendingWake ? makeEntry() : undefined
if (successor === undefined) active.delete(key)
else {
if (successor === undefined) {
active.delete(key)
notifyActivity(key, false)
} else {
active.set(key, successor)
start(key, successor, false, true)
}
@@ -74,6 +88,7 @@ export const make = <Key, E>(options: {
const next = makeEntry()
active.set(key, next)
notifyActivity(key, true)
start(key, next, true)
return restore(Deferred.await(next.done))
})
@@ -88,6 +103,7 @@ export const make = <Key, E>(options: {
const next = makeEntry()
active.set(key, next)
notifyActivity(key, true)
start(key, next, false)
})
@@ -100,5 +116,33 @@ export const make = <Key, E>(options: {
return Fiber.interrupt(entry.owner)
})
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt }
const activity = (key: Key) =>
Effect.gen(function* () {
let attached: ((active: boolean) => void) | undefined
const observer = (value: boolean) => {
attached?.(value)
}
yield* Effect.acquireRelease(
Effect.sync(() => {
const observers = activityObservers.get(key) ?? new Set()
observers.add(observer)
activityObservers.set(key, observers)
}),
() =>
Effect.sync(() => {
const observers = activityObservers.get(key)
observers?.delete(observer)
if (observers?.size === 0) activityObservers.delete(key)
}),
)
return {
attach: (next: (active: boolean) => void) =>
Effect.sync(() => {
attached = next
return active.has(key)
}),
}
})
return { active: Effect.sync(() => new Set(active.keys())), activity, run, wake, interrupt }
})
+12 -60
View File
@@ -52,7 +52,6 @@ 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),
}) {}
@@ -81,15 +80,12 @@ 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 true
return yield* HttpClientRequest.get(url).pipe(
if (yield* fs.exists(destination).pipe(Effect.orDie)) return
yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
Effect.as(true),
Effect.catch((error) =>
Effect.logError("failed to download skill file", { url, error }).pipe(Effect.as(false)),
),
Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })),
)
})
@@ -124,7 +120,6 @@ 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
@@ -140,66 +135,23 @@ export const layer = Layer.effect(
return {
url: resource.href,
destination,
file,
}
})
if (files.some((file) => file === undefined)) {
return []
}
return [{ skill, root, versionFile, files: files as { url: string; destination: string; file: string }[] }]
return [{ skill, root, files: files as { url: string; destination: string }[] }]
}),
({ skill, root, versionFile, files }) =>
({ skill, root, files }) =>
Effect.gen(function* () {
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* 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)) ||
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
return exists ? [AbsolutePath.make(root)] : []
? [AbsolutePath.make(root)]
: []
}),
{ concurrency: skillConcurrency },
).pipe(Effect.map((directories) => directories.flat()))
-361
View File
@@ -1,361 +0,0 @@
import { Parser } from "htmlparser2"
const omitted = new Set(["script", "style", "noscript", "iframe", "object", "embed", "meta", "link", "template"])
const blocks = new Set([
"address",
"article",
"aside",
"details",
"dialog",
"div",
"dl",
"fieldset",
"figcaption",
"figure",
"footer",
"form",
"header",
"main",
"nav",
"p",
"section",
"summary",
])
type Frame = {
tag: string
suppressed: boolean
link?: { href: string; title?: string }
marker?: { index: number; block: number; value: string }
code?: { inline: boolean; text: string; language?: string }
list?: { ordered: boolean; next: number }
table?: { cells: number; header: boolean; rows: number }
cell?: { start: number }
}
export function convertHTMLToMarkdown(html: string) {
if (hasPathologicalDepth(html)) return extractPathologicalText(html)
const output: string[] = []
const stack: Frame[] = []
let pendingSpace = false
let last = ""
let quoteDepth = 0
let needsQuotePrefix = false
let blockCount = 0
let listDepth = 0
let activeCode: NonNullable<Frame["code"]> | undefined
let activeTable: NonNullable<Frame["table"]> | undefined
let tableDepth = 0
const raw: string[] = []
const append = (value: string) => {
if (!value) return
output.push(value)
last = value.at(-1) ?? last
}
const prefixQuote = () => {
if (!needsQuotePrefix || quoteDepth === 0) return
append(`${"> ".repeat(Math.min(8, quoteDepth))}`)
needsQuotePrefix = false
}
const flushSpace = () => {
if (!pendingSpace) return
const marker = stack.at(-1)?.marker
if (marker && output.length === marker.index + 1 && last !== " " && last !== "\n") {
output[marker.index] = ` ${output[marker.index]}`
pendingSpace = false
return
}
if (last && last !== "\n" && last !== " ") append(" ")
pendingSpace = false
}
const inline = (value: string, open = false) => {
if (open) flushSpace()
prefixQuote()
append(value)
}
const block = () => {
pendingSpace = false
append("\n\n")
blockCount++
needsQuotePrefix = quoteDepth > 0
}
const text = (value: string) => {
if (activeCode) {
activeCode.text += value
return
}
for (const part of value.split(/([\t\n\f\r ]+)/)) {
if (!part) continue
if (/^[\t\n\f\r ]+$/.test(part)) {
pendingSpace = true
continue
}
flushSpace()
prefixQuote()
append(
part
.replace(/([\\`*_[\]<>|])/g, "\\$1")
.replace(/~/g, "\\~")
.replace(/^([#+-])/, "\\$1")
.replace(/^(\d+)\./, "$1\\."),
)
}
}
const destination = (value: string) => value.replace(/([\\()])/g, "\\$1").replace(/[\t\n\r ]+/g, "%20")
const title = (value: string | undefined) => (value ? ` "${value.replace(/([\\"])/g, "\\$1")}"` : "")
const finishCode = (code: NonNullable<Frame["code"]>) => {
let longest = 0
let current = 0
for (const character of code.text) {
current = character === "`" ? current + 1 : 0
longest = Math.max(longest, current)
}
const fence = "`".repeat(Math.max(code.inline ? 1 : 3, longest + 1))
if (code.inline) {
const padding = /^ | $/.test(code.text) && !/^ +$/.test(code.text) ? " " : ""
flushSpace()
inline(`${fence}${padding}${code.text}${padding}${fence}`)
return
}
block()
const value = `${fence}${code.language ?? ""}\n${code.text}${code.text.endsWith("\n") ? "" : "\n"}${fence}`
const quoted = quoteDepth > 0 ? value.replace(/^/gm, `${"> ".repeat(Math.min(8, quoteDepth))}`) : value
const placeholder = `\u0000${raw.length}\u0000`
raw.push(quoted)
append(placeholder)
block()
}
const parser = new Parser({
onopentag(name, attributes) {
const suppressed = (stack.at(-1)?.suppressed ?? false) || omitted.has(name)
const frame: Frame = { tag: name, suppressed }
stack.push(frame)
if (suppressed) return
if (activeCode && !activeCode.inline) {
if (name === "code" && attributes.class) activeCode.language = attributes.class.match(/(?:language-|lang-)([^\s]+)/)?.[1]
return
}
if (name === "pre") {
frame.code = { inline: false, text: "" }
activeCode = frame.code
return
}
if (name === "code") {
frame.code = { inline: true, text: "" }
activeCode = frame.code
return
}
if (/^h[1-6]$/.test(name)) {
block()
inline(`${"#".repeat(Number(name[1]))} `)
return
}
if (blocks.has(name)) {
if (name === "p" && last === " ") return
block()
return
}
if (name === "br") {
pendingSpace = false
inline(" \n")
needsQuotePrefix = quoteDepth > 0
return
}
if (name === "hr") {
block()
inline("---")
block()
return
}
if (name === "strong" || name === "b") {
inline("**", true)
frame.marker = { index: output.length - 1, block: blockCount, value: "**" }
return
}
if (name === "em" || name === "i") {
inline("*", true)
frame.marker = { index: output.length - 1, block: blockCount, value: "*" }
return
}
if (name === "s" || name === "strike" || name === "del") {
inline("~~", true)
frame.marker = { index: output.length - 1, block: blockCount, value: "~~" }
return
}
if (name === "a") {
frame.link = { href: attributes.href ?? "", title: attributes.title }
return inline(`[`, true)
}
if (name === "img") {
inline(`![${(attributes.alt ?? "").replace(/([\\\]])/g, "\\$1")}](${destination(attributes.src ?? "")}${title(attributes.title)})`, true)
return
}
if (name === "blockquote") {
block()
quoteDepth++
needsQuotePrefix = true
return
}
if (name === "ul" || name === "ol") {
frame.list = { ordered: name === "ol", next: Number.parseInt(attributes.start ?? "1") || 1 }
listDepth++
block()
return
}
if (name === "li") {
block()
const list = stack.findLast((item) => item.list)?.list
const marker = list?.ordered ? `${list.next++}.` : "-"
inline(`${" ".repeat(Math.min(8, Math.max(0, listDepth - 1)))}${marker} `)
return
}
if (name === "table") {
tableDepth++
if (tableDepth === 1) {
frame.table = { cells: 0, header: false, rows: 0 }
activeTable = frame.table
block()
} else pendingSpace = true
return
}
if (name === "tr") {
if (tableDepth !== 1) {
pendingSpace = true
return
}
const table = activeTable
pendingSpace = false
if (table && table.rows > 0) {
append("\n")
needsQuotePrefix = quoteDepth > 0
}
inline("|")
return
}
if (name === "th" || name === "td") {
if (tableDepth !== 1) {
pendingSpace = true
return
}
const table = activeTable
if (table) {
table.cells++
table.header ||= name === "th"
}
inline(" ")
frame.cell = { start: output.length }
}
},
ontext(value) {
if (stack.at(-1)?.suppressed) return
text(value)
},
onclosetag(name) {
const frame = stack.pop()
if (!frame || frame.suppressed) return
if (frame.code) {
activeCode = undefined
return finishCode(frame.code)
}
if (name === "strong" || name === "b" || name === "em" || name === "i" || name === "s" || name === "strike" || name === "del") {
const value = name === "strong" || name === "b" ? "**" : name === "em" || name === "i" ? "*" : "~~"
if (frame.marker && frame.marker.block !== blockCount) {
output[frame.marker.index] = ""
return
}
if (frame.marker && output.length === frame.marker.index + 1) {
output[frame.marker.index] = ""
return
}
return inline(value)
}
if (name === "a") {
return inline(`](${destination(frame.link?.href ?? "")}${title(frame.link?.title)})`)
}
if (/^h[1-6]$/.test(name) || blocks.has(name)) return block()
if (name === "blockquote") {
quoteDepth--
return block()
}
if (name === "li") return block()
if (name === "ul" || name === "ol") {
listDepth--
return block()
}
if ((name === "th" || name === "td") && tableDepth === 1) {
if (frame.cell) {
const value = output
.splice(frame.cell.start)
.join("")
.replace(/[\t\r\n ]+/g, " ")
.trim()
.replace(/(?<!\\)\|/g, "\\|")
append(value)
}
return inline(" |")
}
if (name === "tr") {
if (tableDepth !== 1) return
const table = activeTable
if (table && table.rows === 0) {
inline("\n")
needsQuotePrefix = quoteDepth > 0
inline(`|${" --- |".repeat(table.cells)}`)
}
if (table) {
table.rows++
table.cells = 0
}
return
}
if (name === "table") {
tableDepth--
if (tableDepth === 0) {
activeTable = undefined
return block()
}
pendingSpace = true
}
},
})
parser.write(html)
parser.end()
return output
.join("")
.replace(/[ \t]+\n/g, (value) => (value.startsWith(" ") ? " \n" : "\n"))
.replace(/\n{3,}/g, "\n\n")
.trim()
.replace(/\u0000(\d+)\u0000/g, (_, index) => raw[Number(index)] ?? "")
}
function hasPathologicalDepth(html: string) {
let depth = 0
for (const match of html.matchAll(/<\s*(\/)?\s*([a-z][\w:-]*)\b[^>]*>/gi)) {
if (match[1]) depth = Math.max(0, depth - 1)
else if (!/\/$/.test(match[0].slice(0, -1).trim()) && !["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"].includes(match[2].toLowerCase())) depth++
if (depth > 10_000) return true
}
return false
}
function extractPathologicalText(html: string) {
let output = ""
let suppressed = 0
const parser = new Parser({
onopentag(name) {
if (suppressed > 0 || omitted.has(name)) suppressed++
},
ontext(value) {
if (suppressed === 0) output += value
},
onclosetag() {
if (suppressed > 0) suppressed--
},
})
parser.write(html.replace(/<\/?(?:[^>]+)>/g, (tag) => (omitted.has(tag.match(/^<\/?\s*([^\s/>]+)/)?.[1]?.toLowerCase() ?? "") ? tag : " ")))
parser.end()
return output.replace(/[\t\n\f\r ]+/g, " ").trim()
}
+12 -2
View File
@@ -4,8 +4,8 @@ import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import TurndownService from "turndown"
import { PermissionV2 } from "../permission"
import { convertHTMLToMarkdown } from "./html-markdown"
import { collectBoundedResponseBody } from "./http-body"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -196,4 +196,14 @@ export function extractTextFromHTML(html: string) {
return text.trim()
}
export { convertHTMLToMarkdown }
export function convertHTMLToMarkdown(html: string) {
const turndown = new TurndownService({
headingStyle: "atx",
hr: "---",
bulletListMarker: "-",
codeBlockStyle: "fenced",
emDelimiter: "*",
})
turndown.remove(["script", "style", "meta", "link"])
return turndown.turndown(html)
}
+121
View File
@@ -418,6 +418,127 @@ describe("EventV2", () => {
}),
)
it.effect("observes a fixed replay cutoff and delivers commits during replay as updates", () =>
Effect.gen(function* () {
const readStarted = yield* Deferred.make<void>()
const continueRead = yield* Deferred.make<void>()
let pause = true
const eventLayer = EventV2.layerWith({
beforeAggregateRead: () =>
pause
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
: Effect.void,
}).pipe(Layer.provide(Database.defaultLayer))
yield* Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = Session.ID.create()
const observer = yield* events.observeAggregate({ aggregateID, live: () => false }).pipe(Effect.forkScoped)
yield* Deferred.await(readStarted)
pause = false
yield* events.publish(DurableMessage, durableData(aggregateID, "after cutoff"))
yield* Deferred.succeed(continueRead, undefined)
const observed = yield* Fiber.join(observer)
const update = yield* observed.updates.pipe(Stream.take(1), Stream.runCollect)
expect(observed.replay).toEqual([])
expect(Array.from(update).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "after cutoff")],
])
}).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
}),
)
it.effect("observes replay commits that are not republished", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = Session.ID.create()
const observed = yield* events.observeAggregate({ aggregateID, live: () => false })
const update = yield* observed.updates.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* events.replay(
{
id: EventV2.ID.create(),
aggregateID,
seq: 0,
type: EventV2.versionedType(DurableMessage.type, 1),
data: durableData(aggregateID, "replayed"),
},
{ publish: false },
)
expect(Array.from(yield* Fiber.join(update)).map((event) => event.data)).toEqual([
durableData(aggregateID, "replayed"),
])
}),
)
it.effect("drains causally preceding durable rows before a live payload", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = Session.ID.create()
const observed = yield* events.observeAggregate({
aggregateID,
live: (event) => event.type === Message.type,
})
const updates = yield* observed.updates.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* events.publish(DurableMessage, durableData(aggregateID, "start"))
const live = yield* events.publish(Message, { text: "delta" })
expect(Array.from(yield* Fiber.join(updates))).toEqual([
expect.objectContaining({ durable: expect.objectContaining({ seq: 0 }) }),
live,
])
}),
)
it.effect("coalesces saturated observer signals without losing durable rows", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = Session.ID.create()
const count = 300
const observed = yield* events.observeAggregate({ aggregateID, live: () => false })
for (let index = 0; index < count; index++) {
yield* events.publish(DurableMessage, durableData(aggregateID, String(index)))
}
const updates = yield* observed.updates.pipe(Stream.take(count), Stream.runCollect)
expect(Array.from(updates, (event) => event.durable?.seq)).toEqual(
Array.from({ length: count }, (_, index) => index),
)
}),
)
it.effect("coalesces durable notifications without repeated empty reads", () =>
Effect.gen(function* () {
let reads = 0
const eventLayer = EventV2.layerWith({
beforeAggregateRead: () =>
Effect.sync(() => {
reads++
}),
}).pipe(Layer.provide(Database.defaultLayer))
yield* Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = Session.ID.create()
const count = 20
const observed = yield* events.observeAggregate({ aggregateID, live: () => false })
for (let index = 0; index < count; index++) {
yield* events.publish(DurableMessage, durableData(aggregateID, String(index)))
}
const updates = yield* observed.updates.pipe(Stream.take(count), Stream.runCollect)
expect(updates).toHaveLength(count)
expect(reads).toBe(2)
}).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
}),
)
it.effect("coalesces durable aggregate wakes while draining every committed event", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
-15
View File
@@ -1,15 +0,0 @@
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>`)
})
})
+137 -5
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { DateTime, Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
@@ -23,10 +23,29 @@ const executionCalls: SessionV2.ID[] = []
const interruptCalls: SessionV2.ID[] = []
const wakeCalls: SessionV2.ID[] = []
const activeSessions = new Set<SessionV2.ID>()
let activityObserver: ((active: boolean) => void) | undefined
let activityState = false
const setActivity = (active: boolean) =>
Effect.sync(() => {
activityState = active
activityObserver?.(active)
})
const execution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.sync(() => new Set(activeSessions)),
activity: () =>
Effect.sync(() => {
activityState = false
activityObserver = undefined
return {
attach: (observer: (active: boolean) => void) =>
Effect.sync(() => {
activityObserver = observer
return activityState
}),
}
}),
resume: (sessionID) =>
Effect.sync(() => {
executionCalls.push(sessionID)
@@ -179,7 +198,7 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
const fiber = yield* session.events({ sessionID }).pipe(Stream.take(5), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
@@ -188,6 +207,7 @@ describe("SessionV2.prompt", () => {
const streamed = Array.from(yield* Fiber.join(fiber))
expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
[undefined, "session.activity"],
[0, "session.next.prompt.admitted"],
[1, "session.next.prompt.admitted"],
[2, "session.next.prompted"],
@@ -196,10 +216,122 @@ describe("SessionV2.prompt", () => {
expect(
Array.from(
yield* session
.events({ sessionID, after: streamed[0]!.durable?.seq })
.pipe(Stream.take(1), Stream.runCollect),
.events({ sessionID, after: streamed[2]?.durable?.seq })
.pipe(Stream.take(3), Stream.runCollect),
).map((event) => [event.durable?.seq, event.type]),
).toEqual([[1, "session.next.prompt.admitted"]])
).toEqual([
[2, "session.next.prompted"],
[3, "session.next.prompted"],
[undefined, "session.activity"],
])
}),
)
it.effect("replays history, emits activity, then fences live deltas behind durable starts", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID,
textID: "text-1",
timestamp: yield* DateTime.now,
})
const streamed = yield* session.events({ sessionID }).pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* events.publish(SessionEvent.Text.Delta, {
sessionID,
assistantMessageID,
textID: "text-1",
delta: "hello",
timestamp: yield* DateTime.now,
})
expect(Array.from(yield* Fiber.join(streamed)).map((event) => event.type)).toEqual([
"session.next.text.started",
"session.activity",
"session.next.text.delta",
])
}),
)
it.effect("orders activity around the durable rows owned by a run", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const assistantMessageID = SessionMessage.ID.create()
const streamed = yield* session.events({ sessionID }).pipe(Stream.take(5), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* setActivity(true)
yield* events.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID,
textID: "text-activity",
timestamp: yield* DateTime.now,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID,
assistantMessageID,
textID: "text-activity",
text: "done",
timestamp: yield* DateTime.now,
})
yield* setActivity(false)
expect(Array.from(yield* Fiber.join(streamed)).map((event) => [event.type, event.data])).toEqual([
["session.activity", { sessionID, active: false }],
["session.activity", { sessionID, active: true }],
["session.next.text.started", expect.objectContaining({ sessionID })],
["session.next.text.ended", expect.objectContaining({ sessionID })],
["session.activity", { sessionID, active: false }],
])
}),
)
it.effect("terminates the Session stream when live event buffering saturates", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const initial = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let seenInitial = false
const streamed = yield* session.events({ sessionID }).pipe(
Stream.mapEffect((event) => {
if (!seenInitial) {
seenInitial = true
return Deferred.succeed(initial, undefined).pipe(Effect.as(event))
}
return Deferred.await(release).pipe(Effect.as(event))
}),
Stream.runCollect,
Effect.forkScoped,
)
yield* Deferred.await(initial)
const timestamp = yield* DateTime.now
const assistantMessageID = SessionMessage.ID.create()
yield* Effect.forEach(
Array.from({ length: 1_000 }, (_, index) => index),
(index) =>
events.publish(SessionEvent.Text.Delta, {
sessionID,
assistantMessageID,
textID: "text-saturation",
delta: String(index),
timestamp,
}),
{ concurrency: "unbounded", discard: true },
)
yield* Deferred.succeed(release, undefined)
const result = Array.from(yield* Fiber.join(streamed))
expect(result[0]?.type).toBe("session.activity")
expect(result.length).toBeLessThan(1_001)
}),
)
@@ -99,6 +99,27 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("reflects activity races in the snapshot or subsequent transitions", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const gate = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make({
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(gate))),
})
const activity = yield* coordinator.activity("session")
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(started)
const transitions = new Array<boolean>()
expect(yield* activity.attach((active) => transitions.push(active))).toBeTrue()
yield* Deferred.succeed(gate, undefined)
yield* Fiber.join(run)
expect(transitions).toEqual([false])
}),
),
)
it.effect("cleans active executions after failure and defect", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -96,6 +96,7 @@ const execution = Layer.effect(
})
return SessionExecution.Service.of({
active: coordinator.active,
activity: coordinator.activity,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
@@ -28,6 +28,7 @@ const capture = () => {
}),
subscribe: () => Stream.empty,
all: () => Stream.empty,
observeAggregate: () => Effect.die("not implemented"),
durable: () => Stream.empty,
listen: () => Effect.succeed(Effect.void),
project: () => Effect.void,
@@ -255,6 +255,7 @@ const execution = Layer.effect(
})
return SessionExecution.Service.of({
active: coordinator.active,
activity: coordinator.activity,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
+2 -62
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> = {}, cache?: Awaited<ReturnType<typeof tmpdir>>) {
const tmp = cache ?? (await tmpdir())
async function pull(skills: unknown[], files: Record<string, string> = {}) {
const tmp = await tmpdir()
const requests: string[] = []
const http = Layer.succeed(
HttpClient.HttpClient,
@@ -101,64 +101,4 @@ 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]()
}
})
})
+6 -106
View File
@@ -66,109 +66,9 @@ describe("WebFetchTool helpers", () => {
})
test("ports HTML text and markdown conversions without active content", () => {
const html =
"<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong> <product-name>today</product-name></p><style>.bad {}</style>"
expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide today")
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide** today")
})
test("renders headings, inline semantics, links, images, breaks, and thematic breaks", () => {
const html = `<h2>Read <em>this</em></h2><p><a href="https://example.com/a (b)" title="Example">docs</a><br><img src="diagram.png" alt="a ] b"></p><hr><p><del>old</del></p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`## Read *this*\n\n[docs](https://example.com/a%20\\(b\\) "Example") \n![a \\] b](diagram.png)\n\n---\n\n~~old~~`,
)
})
test("preserves inline and preformatted code verbatim with safe fences", () => {
const html = `<p>Use <code>say(\`hello\`)</code> now.</p><pre><code class="language-ts">const fence = \`\`\`\n&amp; stays decoded</code></pre>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`Use \`\`say(\`hello\`)\`\` now.\n\n\`\`\`\`ts\nconst fence = \`\`\`\n& stays decoded\n\`\`\`\``,
)
})
test("keeps nested ordered and unordered lists structurally readable", () => {
const html = `<ol start="3"><li>alpha<ul><li>nested <strong>item</strong></li></ul></li><li><p>beta first</p><p>beta second</p></li></ol>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`3. alpha\n\n - nested **item**\n\n4. beta first\n\nbeta second`,
)
})
test("renders blockquotes and tables as readable Markdown", () => {
const html = `<blockquote><p>quoted <em>text</em></p><ul><li>point</li></ul></blockquote><table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>one</td><td><code>1</code></td></tr></tbody></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`> quoted *text*\n\n> - point\n\n| Name | Value |\n| --- | --- |\n| one | \`1\` |`,
)
})
test("decodes entities and normalizes prose whitespace without joining words", () => {
const html = `<p>alpha\n <span>&amp; beta</span> <unknown>caf&eacute;</unknown>&nbsp;gamma 😀</p><p>delta</p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`alpha & beta café gamma 😀\n\ndelta`)
})
test("omits active and fallback content while retaining surrounding prose", () => {
const html = `<p>before <script><b>bad</b></script><style>bad</style><noscript>bad</noscript><iframe>bad</iframe><object>bad</object><embed src="bad"><meta content="bad"><link href="bad"><template>bad</template> after</p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("before after")
})
test("is deterministic and bounded for malformed maximum-size input", () => {
const html = `<main><p>${"visible &amp; text ".repeat(250_000)}</main></p></unknown>`
const first = WebFetchTool.convertHTMLToMarkdown(html)
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(first)
expect(first.startsWith("visible & text visible & text")).toBe(true)
expect(first.length).toBeLessThanOrEqual(html.length)
})
test("bounds deeply nested list output and fragmented code fences", () => {
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
const code = `<pre>${"` x ".repeat(250_000)}</pre>`
expect(WebFetchTool.convertHTMLToMarkdown(lists).length).toBeLessThan(lists.length * 4)
expect(WebFetchTool.convertHTMLToMarkdown(quotes).length).toBeLessThan(quotes.length * 4)
expect(() => WebFetchTool.convertHTMLToMarkdown(code)).not.toThrow()
expect(
WebFetchTool.convertHTMLToMarkdown(
"<div>".repeat(20_000) + "safe<script><b>bad</b>&amp;</script><p>tail &amp;</p>",
),
).toBe("safe tail &")
})
test("escapes prose that would otherwise become Markdown structure", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p># heading</p><p>1. item</p><p>---</p><p>a | b</p>`)).toBe(
`\\# heading\n\n1\\. item\n\n\\---\n\na \\| b`,
)
})
test("preserves code whitespace and quotes every line of multiline blocks", () => {
const html = `<blockquote><pre>line \n\n\nnext</pre><table><tr><td>a|b</td><td>c</td></tr></table></blockquote>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`> \`\`\`\n> line \n> \n> \n> next\n> \`\`\`\n\n> | a\\|b | c |\n> | --- | --- |`,
)
})
test("keeps visible whitespace around inline emphasis", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>a<strong> b</strong> c a <em>b </em>c</p>`)).toBe(
`a **b** c a *b* c`,
)
})
test("normalizes multiline table cells without changing their columns", () => {
const html = `<table><tr><td>x<br>y</td><td><code>a|b</code></td><td><p>first</p><p>second</p></td></tr></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`| x y | \`a\\|b\` | first second |\n| --- | --- | --- |`,
)
})
test("flattens nested tables without corrupting the outer table", () => {
const html = `<table><tr><th>Parent</th><th>Sibling</th></tr><tr><td>Before<table><tr><th>Key</th><th>Value</th></tr><tr><td>A</td><td>1</td></tr></table>After</td><td>Tail</td></tr></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`| Parent | Sibling |\n| --- | --- |\n| Before Key Value A 1 After | Tail |`,
)
})
test("escapes tilde fences and removes empty emphasis markers", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>~~~</p><p><strong></strong>content</p><p>~~~</p>`)).toBe(
`\\~\\~\\~\n\ncontent\n\n\\~\\~\\~`,
)
const html = "<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong></p><style>.bad {}</style>"
expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide")
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide**")
})
})
@@ -276,7 +176,7 @@ describe("WebFetchTool registration", () => {
}),
)
it.effect("converts deeply nested HTML without overflowing", () =>
it.effect("returns an error result when HTML-to-Markdown conversion throws", () =>
Effect.gen(function* () {
reset()
respond = () =>
@@ -289,8 +189,8 @@ describe("WebFetchTool registration", () => {
const url = "https://1.1.1.1/deep-html"
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({
type: "text",
value: "content",
type: "error",
value: `Unable to fetch ${url}`,
})
}),
)
+5 -13
View File
@@ -53,7 +53,7 @@ const OpenAIResponsesReasoningSummaryText = Schema.Struct({
const OpenAIResponsesReasoningItem = Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.optionalKey(Schema.String),
id: Schema.String,
summary: Schema.Array(OpenAIResponsesReasoningSummaryText),
encrypted_content: optionalNull(Schema.String),
})
@@ -101,7 +101,6 @@ 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"),
@@ -367,7 +366,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
@@ -384,7 +383,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
flushText()
const reasoning = lowerReasoning(part)
if (!reasoning) continue
if (store !== false) {
if (store !== false && reasoning.id) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
@@ -396,13 +395,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
existing.encrypted_content = reasoning.encrypted_content
continue
}
const replay = {
type: reasoning.type,
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
reasoningItems[reasoning.id] = replay
input.push(replay)
reasoningItems[reasoning.id] = reasoning
input.push(reasoning)
continue
}
if (part.type === "tool-call") {
@@ -980,7 +974,6 @@ export const route = Route.make({
endpoint,
auth,
transport: httpTransport,
defaults: { providerOptions: { openai: { store: false } } },
})
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
@@ -1008,7 +1001,6 @@ 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\",\"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\",\"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}"
},
"response": {
"status": 200,
@@ -50,7 +50,6 @@ 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,
@@ -162,16 +161,16 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.updateRequest(request, {
model: OpenAIResponses.webSocketRoute
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" }),
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
"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", store: false, stream: true })
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", stream: true })
}),
)
@@ -357,13 +356,7 @@ 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,
})
}),
)
@@ -526,6 +519,7 @@ 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." }],
},
@@ -870,9 +864,7 @@ describe("OpenAI Responses route", () => {
it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.updateRequest(request, { providerOptions: { openai: { store: true } } }),
).pipe(
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
@@ -933,12 +925,12 @@ describe("OpenAI Responses route", () => {
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
const body = yield* Effect.promise(() => web.json())
expect(body).toMatchObject({
expect(yield* Effect.promise(() => web.json())).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." }],
},
@@ -946,7 +938,6 @@ 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." },
@@ -993,6 +984,7 @@ 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." }],
},
@@ -1086,6 +1078,7 @@ 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", summary: [], encrypted_content: "encrypted-state" },
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" },
{ type: "function_call", call_id: "call_1", name: "get_weather" },
{ type: "function_call_output", call_id: "call_1" },
],
+1 -8
View File
@@ -46,7 +46,6 @@ 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 =
@@ -82,13 +81,7 @@ export function toRequestError(error: Error) {
case "ACPUnsupportedOperationError":
return RequestError.methodNotFound(error.method)
case "ACPServiceFailureError":
return RequestError.internalError(
{
...(error.service ? { service: error.service } : {}),
...(error.errorName ? { errorName: error.errorName } : {}),
},
error.safeMessage,
)
return RequestError.internalError({ service: error.service }, error.safeMessage)
}
}
+9 -139
View File
@@ -1,16 +1,9 @@
import type {
AgentSideConnection,
PermissionOption,
RequestPermissionResponse,
ToolCallContent,
ToolCallLocation,
ToolCallUpdate,
} from "@agentclientprotocol/sdk"
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } 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 { pendingToolCall, toLocations, type ToolInput } from "./tool"
import { toLocations, toToolKind, type ToolInput } from "./tool"
import { Effect } from "effect"
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
@@ -61,11 +54,14 @@ export class Handler {
const result = await this.input.connection
.requestPermission({
sessionId: permission.sessionID,
toolCall: await permissionToolCall({
toolCall: {
toolCallId: permission.tool?.callID ?? permission.id,
toolName: permission.permission,
input: permission.metadata,
}),
status: "pending",
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
options: permissionOptions,
})
.catch(async () => {
@@ -115,107 +111,6 @@ 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
@@ -226,29 +121,4 @@ 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"
+10 -57
View File
@@ -30,7 +30,7 @@ import {
type SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import type { AssistantMessage, Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import type { 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,6 +314,7 @@ 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, {
@@ -520,7 +521,7 @@ export function make(input: {
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return yield* promptResponse(response.info, params.messageId)
return promptResponse(response.info, params.messageId)
}
const known = snapshot.availableCommands.find((item) => item.name === command.name)
@@ -542,7 +543,7 @@ export function make(input: {
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return yield* promptResponse(response.info, params.messageId)
return promptResponse(response.info, params.messageId)
}
if (command.name === "compact") {
@@ -562,7 +563,7 @@ export function make(input: {
}
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return yield* promptResponse(undefined, params.messageId)
return promptResponse(undefined, params.messageId)
}),
cancel,
}
@@ -694,8 +695,7 @@ type MessageInfo = {
readonly agent?: Message["agent"]
}
type AssistantError = NonNullable<AssistantMessage["error"]>
type AssistantInfo = (UsageService.AssistantTokenCost & Pick<AssistantMessage, "error">) | undefined
type AssistantInfo = UsageService.AssistantTokenCost | undefined
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return Effect.tryPromise({
@@ -811,60 +811,13 @@ function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
return { name, args: rest.join(" ").trim() }
}
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),
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
return {
stopReason: "end_turn",
...(info ? { 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(
+4 -1
View File
@@ -192,8 +192,11 @@ export function completedToolUpdate(input: {
return {
toolCallId: input.toolCallId,
status: "completed",
...(input.state.title ? { title: input.state.title } : {}),
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.state.input, input.state.title),
locations: toLocations(input.toolName, input.state.input, input.cwd),
content: completedToolContent(input.toolName, input.state),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
rawOutput: completedToolRawOutput(input.state),
}
}
+1 -9
View File
@@ -1,5 +1,4 @@
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"
@@ -133,19 +132,12 @@ 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() {
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")
return item.content
},
hints: [],
}
+11 -13
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 { McpOAuthPendingProvider, McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
import { 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, { transport: TransportWithAuth; provider?: McpOAuthPendingProvider }>()
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
// 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 McpOAuthPendingProvider(
const authProvider = new McpOAuthProvider(
mcpName,
mcpConfig.url,
{
@@ -845,16 +845,15 @@ export const layer = Layer.effect(
return yield* Effect.tryPromise({
try: () => {
const client = createClient(directory)
return client.connect(transport).then(async () => {
await authProvider.commit()
return { authorizationUrl: "", oauthState, client } satisfies AuthResult
})
return client
.connect(transport)
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
},
catch: (error) => error,
}).pipe(
Effect.catch((error) => {
if (error instanceof UnauthorizedError && capturedUrl) {
pendingOAuthTransports.set(mcpName, { transport, provider: authProvider })
pendingOAuthTransports.set(mcpName, transport)
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult)
}
return Effect.die(error)
@@ -925,11 +924,11 @@ export const layer = Layer.effect(
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
yield* requireMcpConfig(mcpName)
const pending = pendingOAuthTransports.get(mcpName)
if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
const transport = pendingOAuthTransports.get(mcpName)
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
const result = yield* Effect.tryPromise({
try: () => pending.transport.finishAuth(authorizationCode).then(() => true as const),
try: () => transport.finishAuth(authorizationCode).then(() => true as const),
catch: (error) => {
return error
},
@@ -939,7 +938,6 @@ 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)
+47 -6
View File
@@ -1,6 +1,6 @@
import { createConnection } from "net"
import { createServer } from "http"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { escapeHtml } from "@/util/html"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
const OAUTH_CALLBACK_HOST = "127.0.0.1"
@@ -9,6 +9,47 @@ 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
@@ -57,7 +98,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(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
res.end(HTML_ERROR(errorMsg))
return
}
@@ -71,14 +112,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(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
res.end(HTML_ERROR(errorMsg))
stopIfIdle()
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(OauthCallbackPage.error("No authorization code provided", { provider: "MCP" }))
res.end(HTML_ERROR("No authorization code provided"))
return
}
@@ -86,7 +127,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(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
res.end(HTML_ERROR(errorMsg))
return
}
@@ -98,7 +139,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(OauthCallbackPage.success({ provider: "MCP" }))
res.end(HTML_SUCCESS)
stopIfIdle()
}
+9 -62
View File
@@ -25,11 +25,11 @@ export interface McpOAuthCallbacks {
export class McpOAuthProvider implements OAuthClientProvider {
constructor(
protected mcpName: string,
protected serverUrl: string,
protected config: McpOAuthConfig,
private mcpName: string,
private serverUrl: string,
private config: McpOAuthConfig,
private callbacks: McpOAuthCallbacks,
protected auth: McpAuth.Interface,
private auth: McpAuth.Interface,
) {}
get redirectUrl(): string {
@@ -53,6 +53,7 @@ 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,
@@ -163,7 +164,10 @@ 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))
@@ -180,63 +184,6 @@ 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 }
/**
+60 -2
View File
@@ -1,7 +1,6 @@
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"
@@ -59,6 +58,65 @@ 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) => {
@@ -66,7 +124,7 @@ async function startOAuthServer(): Promise<void> {
if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) {
res.writeHead(200, { "Content-Type": "text/html" })
res.end(OauthCallbackPage.bootstrap({ tokenPath: OAUTH_TOKEN_PATH, provider: "DigitalOcean" }))
res.end(HTML_CALLBACK)
return
}
+91 -4
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 { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { escapeHtml } from "@/util/html"
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const ISSUER = "https://auth.openai.com"
@@ -138,8 +138,95 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis
return response.json()
}
// 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" })
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>`
interface PendingOAuth {
pkce: PkceCodes
@@ -200,7 +287,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(OauthCallbackPage.success({ provider: "ChatGPT" }))
res.end(HTML_SUCCESS)
return
}
@@ -1,7 +1,6 @@
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"
@@ -157,6 +156,29 @@ 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
@@ -181,7 +203,7 @@ async function startOAuthServer() {
pendingOAuth?.reject(new Error(message))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
res.end(htmlError(message))
return
}
@@ -192,7 +214,7 @@ async function startOAuthServer() {
const message = errorDescription || error
current.reject(new Error(message))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
res.end(htmlError(message))
return
}
@@ -200,7 +222,7 @@ async function startOAuthServer() {
const message = "Missing authorization code"
current.reject(new Error(message))
res.writeHead(400, { "Content-Type": "text/html" })
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
res.end(htmlError(message))
return
}
@@ -209,7 +231,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(OauthCallbackPage.success({ provider: "Snowflake" }))
res.end(HTML_SUCCESS)
})
await new Promise<void>((resolve, reject) => {
+95 -5
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 { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { escapeHtml } from "@/util/html"
// 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,6 +285,96 @@ 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
@@ -335,7 +425,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(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
res.end(HTML_ERROR(errorMsg))
return
}
@@ -344,7 +434,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(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
res.end(HTML_ERROR(errorMsg))
return
}
@@ -353,7 +443,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(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
res.end(HTML_ERROR(errorMsg))
return
}
@@ -365,7 +455,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
.catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(OauthCallbackPage.success({ provider: "xAI" }))
res.end(HTML_SUCCESS)
return
}
+10 -47
View File
@@ -13,7 +13,6 @@ 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")({
@@ -77,53 +76,17 @@ 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)))
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
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
}),
{ concurrency: skillConcurrency },
)
+2 -2
View File
@@ -1,5 +1,6 @@
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"
@@ -16,7 +17,6 @@ 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>${escapeHtml(skill.location)}</location>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
+3 -130
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from "bun:test"
import { describe, expect, it } from "bun:test"
import type {
AgentSideConnection,
RequestPermissionRequest,
@@ -6,22 +6,13 @@ 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>,
@@ -146,14 +137,6 @@ 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()
@@ -168,7 +151,7 @@ describe("acp permissions", () => {
toolCall: {
toolCallId: "call_1",
status: "pending",
title: "printf hello",
title: "bash",
rawInput: { command: "printf hello" },
kind: "execute",
locations: [],
@@ -182,116 +165,6 @@ 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")
@@ -316,7 +189,7 @@ describe("acp permissions", () => {
toolCall: {
toolCallId: "call_1",
status: "pending",
title: "Create external directory",
title: "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 { AssistantMessage, OpencodeClient } from "@opencode-ai/sdk/v2"
import type { 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,10 +144,7 @@ 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 }>
prompt?: (input: unknown) => Promise<{ data: { info: ReturnType<typeof assistantInfo> } }>
},
options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> },
) => {
const updates: SessionNotification[] = []
const mcpAdds: string[] = []
@@ -196,21 +193,19 @@ describe("ACP service sessions", () => {
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
}),
messages: () => Promise.resolve({ data: messages }),
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 },
}),
},
})
}),
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({
@@ -394,29 +389,15 @@ describe("ACP service sessions", () => {
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_2", "ses_1"])
})
it("resumes a session and stores restored state without replaying transcript chunks", async () => {
const { service, updates } = makeService([
it("resumes a session and stores restored state", async () => {
const { service } = makeService([
{
info: {
id: "msg_user",
sessionID: "ses_resume",
role: "user",
model: { providerID: "test", modelID: "test-model", variant: "high" },
agent: "plan",
},
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",
},
],
parts: [],
},
])
const resumed = await Effect.runPromise(
@@ -428,11 +409,6 @@ 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 () => {
@@ -1018,52 +994,6 @@ 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: [] }))
@@ -1215,17 +1145,13 @@ describe("ACP service sessions", () => {
})
})
function assistantInfo(
tokens: UsageService.AssistantTokenCost["tokens"],
error?: AssistantMessage["error"],
): UsageService.AssistantMessage & Pick<AssistantMessage, "error"> {
function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage {
return {
role: "assistant",
providerID: "test",
modelID: "test-model",
cost: 0,
tokens,
...(error ? { error } : {}),
}
}
-81
View File
@@ -2,11 +2,9 @@ import { resolve } from "path"
import { describe, expect, test } from "bun:test"
import {
completedToolContent,
completedToolUpdate,
completedToolRawOutput,
extractImageAttachments,
imageContents,
pendingToolCall,
shellOutputSnapshot,
toLocations,
toToolKind,
@@ -113,85 +111,6 @@ 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\",\"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\",\"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}"
},
"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\",\"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\",\"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}"
},
"response": {
"status": 200,
@@ -23,8 +23,6 @@ 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", () => ({
@@ -34,10 +32,6 @@ 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 }) {
@@ -55,8 +49,6 @@ 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()
@@ -73,14 +65,7 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
}
throw new MockUnauthorizedError()
}
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() {}
async finishAuth(_code: string) {}
},
}))
@@ -140,8 +125,6 @@ beforeEach(() => {
connectSucceedsImmediately = false
serverCapabilities = { tools: {} }
listToolsCalls = 0
finishAuthFails = false
finishAuthStoresCredentials = false
})
// Import modules after mocking
@@ -150,7 +133,6 @@ 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")
@@ -245,59 +227,6 @@ 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('<pre class="detail" id="oc-detail">The user denied access</pre>')
expect(await response.text()).toContain('<div class="error">The user denied access</div>')
})
test("binds the callback server to IPv4 loopback", async () => {
@@ -115,9 +115,10 @@ 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, encryptedContent: string) => ({
openaiReasoning: (text: string, options: { readonly itemId: string; readonly encryptedContent: string }) => ({
type: "reasoning",
encrypted_content: encryptedContent,
id: options.itemId,
encrypted_content: options.encryptedContent,
summary: [{ type: "summary_text", text }],
}),
}
@@ -656,7 +657,10 @@ describe("session.llm-native.request", () => {
expectedBody: {
input: [
openAIResponses.user("What changed?"),
openAIResponses.openaiReasoning("Checked the previous diff.", "encrypted-state"),
openAIResponses.openaiReasoning("Checked the previous diff.", {
itemId: "rs_1",
encryptedContent: "encrypted-state",
}),
openAIResponses.assistant("The parser changed."),
openAIResponses.user("Summarize it."),
],
@@ -679,7 +683,7 @@ describe("session.llm-native.request", () => {
],
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
expectedBody: {
input: [{ type: "reasoning", summary: [], encrypted_content: "encrypted-state" }],
input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }],
include: ["reasoning.encrypted_content"],
store: false,
},
@@ -11,10 +11,6 @@ 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")
@@ -28,15 +24,6 @@ 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/", "")
@@ -149,37 +136,4 @@ 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,33 +77,6 @@ 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) =>
+3 -2
View File
@@ -295,7 +295,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
query: {
after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional),
},
success: HttpApiSchema.StreamSse({ data: SessionEvent.Durable }),
success: HttpApiSchema.StreamSse({ data: SessionEvent.Stream }),
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
@@ -303,7 +303,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
OpenApi.annotations({
identifier: "v2.session.events",
summary: "Subscribe to session events",
description: "Replay durable events after an aggregate sequence, then continue with new durable events.",
description:
"Replay durable events after an aggregate sequence, emit current process-local activity, then continue with durable and live-only Session events.",
}),
),
)
+18
View File
@@ -51,6 +51,21 @@ const stepSettlementOptions = {
export const UnknownError = SessionMessage.UnknownError
export type UnknownError = SessionMessage.UnknownError
export const Activity = Event.define({
type: "session.activity",
schema: {
sessionID: SessionID,
active: Schema.Boolean,
},
})
export type Activity = typeof Activity.Type
export const makeActivity = (sessionID: SessionID, active: boolean): Activity => ({
id: Event.ID.create(),
type: Activity.type,
data: { sessionID, active },
})
export const AgentSwitched = Event.define({
type: "session.next.agent.switched",
...options,
@@ -517,3 +532,6 @@ export type DurableEvent = typeof Durable.Type
export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
export type Event = typeof All.Type
export type Type = Event["type"]
export const Stream = Schema.Union([Activity, All], { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
export type StreamEvent = typeof Stream.Type
+141 -1
View File
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Effect, Option, Schema, Stream } from "effect"
import { Effect, Fiber, Option, Schema, Stream } from "effect"
test("embedded client uses the real router and handlers", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
@@ -103,6 +103,146 @@ test("embedded client uses the real router and handlers", async () => {
}
})
test("embedded session events live-tail location-owned durable rows through provider failure", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-live-tail-"))
const database = Flag.OPENCODE_DB
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
let requests = 0
const server = Bun.serve({
port: 0,
async fetch(request) {
const body = await request.json()
if (JSON.stringify(body).includes("Generate a title for this conversation")) {
return new Response(
[
'data: {"choices":[{"delta":{"role":"assistant"}}]}',
'data: {"choices":[{"delta":{"content":"Live tail"}}]}',
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}',
"data: [DONE]",
"",
].join("\n\n"),
{ headers: { "content-type": "text/event-stream" } },
)
}
requests++
if (requests > 1) {
return Response.json({ error: { message: "Provider failed on turn B", type: "server_error" } }, { status: 500 })
}
return new Response(
[
'data: {"choices":[{"delta":{"role":"assistant"}}]}',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_live_tail","type":"function","function":{"name":"live_tail_tool","arguments":""}}]}}]}',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"value\\":\\"A\\"}"}}]}}]}',
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
"data: [DONE]",
"",
].join("\n\n"),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
try {
const git = Bun.spawn(["git", "init"], { cwd: directory, stdout: "ignore", stderr: "ignore" })
expect(await git.exited).toBe(0)
await Bun.write(
join(directory, "opencode.json"),
JSON.stringify({
formatter: false,
lsp: false,
providers: {
test: {
name: "Test",
api: {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: `http://127.0.0.1:${server.port}/v1`,
settings: {},
},
request: { body: { apiKey: "test-key" } },
models: {
"test-model": {
name: "Test Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
limit: { context: 100_000, output: 10_000 },
},
},
},
},
}),
)
const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src")
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
const executions: string[] = []
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const opencode = yield* OpenCode.create()
yield* opencode.tools.register({
live_tail_tool: Tool.make({
description: "Record step A",
input: Schema.Struct({ value: Schema.String }),
output: Schema.Struct({ value: Schema.String }),
execute: ({ value }) =>
Effect.sync(() => {
executions.push(value)
return { value }
}),
}),
})
yield* opencode.sessions.create({
id: sessionID,
agent: Agent.ID.make("build"),
model: Model.Ref.make({ id: Model.ID.make("test-model"), providerID: Provider.ID.make("test") }),
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
let observedActive = false
const streamed = yield* opencode.sessions.events({ sessionID }).pipe(
Stream.takeUntil((event) => {
if (event.type !== "session.activity") return false
if (event.data.active) {
observedActive = true
return false
}
return observedActive
}),
Stream.runCollect,
Effect.timeout("10 seconds"),
Effect.forkScoped,
)
yield* Effect.yieldNow
yield* opencode.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Run step A, then fail B" }) })
const received = Array.from(yield* Fiber.join(streamed))
const durable = received.filter((event) => event.durable !== undefined)
expect(executions).toEqual(["A"])
expect(requests).toBeGreaterThanOrEqual(2)
expect(durable.at(-1)?.type).toBe("session.next.step.failed")
expect(durable.map((event) => event.durable!.seq)).toEqual(
Array.from({ length: durable.length }, (_, index) => durable[0]!.durable!.seq + index),
)
expect(durable.map((event) => event.type)).toEqual(
expect.arrayContaining([
"session.next.tool.called",
"session.next.tool.success",
"session.next.step.ended",
"session.next.step.failed",
]),
)
expect(received.at(-2)?.type).toBe("session.next.step.failed")
expect(received.at(-1)).toMatchObject({ type: "session.activity", data: { active: false } })
}),
),
)
} finally {
server.stop(true)
Flag.OPENCODE_DB = database
await rm(directory, { recursive: true, force: true })
}
}, 20_000)
test("embedded client is available as a Layer service", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-"))
const database = Flag.OPENCODE_DB
+1 -1
View File
@@ -5713,7 +5713,7 @@ export class Session3 extends HeyApiClient {
/**
* Subscribe to session events
*
* Replay durable events after an aggregate sequence, then continue with new durable events.
* Replay durable events after an aggregate sequence, emit current process-local activity, then continue with durable and live-only Session events.
*/
public events<ThrowOnError extends boolean = false>(
parameters: {
+144 -43
View File
@@ -4036,6 +4036,24 @@ export type SessionMessage =
| SessionMessageAssistant
| SessionMessageCompaction
export type SessionActivity = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.activity"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
}
location?: LocationRef
data: {
sessionID: string
active: boolean
}
}
export type SessionNextAgentSwitched = {
id: string
metadata?: {
@@ -4313,6 +4331,27 @@ export type SessionNextTextStarted = {
}
}
export type SessionNextTextDelta = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.next.text.delta"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
}
location?: LocationRef
data: {
timestamp: number
sessionID: string
assistantMessageID: string
textID: string
delta: string
}
}
export type SessionNextTextEnded = {
id: string
metadata?: {
@@ -4334,6 +4373,70 @@ export type SessionNextTextEnded = {
}
}
export type SessionNextReasoningStarted = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.next.reasoning.started"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
}
location?: LocationRef
data: {
timestamp: number
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: LlmProviderMetadata
}
}
export type SessionNextReasoningDelta = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.next.reasoning.delta"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
}
location?: LocationRef
data: {
timestamp: number
sessionID: string
assistantMessageID: string
reasoningID: string
delta: string
}
}
export type SessionNextReasoningEnded = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.next.reasoning.ended"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
}
location?: LocationRef
data: {
timestamp: number
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
providerMetadata?: LlmProviderMetadata
}
}
export type SessionNextToolInputStarted = {
id: string
metadata?: {
@@ -4355,6 +4458,27 @@ export type SessionNextToolInputStarted = {
}
}
export type SessionNextToolInputDelta = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.next.tool.input.delta"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
}
location?: LocationRef
data: {
timestamp: number
sessionID: string
assistantMessageID: string
callID: string
delta: string
}
}
export type SessionNextToolInputEnded = {
id: string
metadata?: {
@@ -4484,49 +4608,6 @@ export type SessionNextToolFailed = {
}
}
export type SessionNextReasoningStarted = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.next.reasoning.started"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
}
location?: LocationRef
data: {
timestamp: number
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: LlmProviderMetadata
}
}
export type SessionNextReasoningEnded = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.next.reasoning.ended"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
}
location?: LocationRef
data: {
timestamp: number
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
providerMetadata?: LlmProviderMetadata
}
}
export type SessionNextRetried = {
id: string
metadata?: {
@@ -4567,6 +4648,26 @@ export type SessionNextCompactionStarted = {
}
}
export type SessionNextCompactionDelta = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.next.compaction.delta"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
}
location?: LocationRef
data: {
timestamp: number
sessionID: string
messageID: string
text: string
}
}
export type SessionNextCompactionEnded = {
id: string
metadata?: {
@@ -43,7 +43,7 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--v2-text-text-faint);
color: var(--text-weak);
[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(--v2-text-text-base);
color: var(--text-strong);
&.capitalize {
text-transform: capitalize;
}
&.agent-title {
color: var(--v2-text-text-base);
color: var(--text-strong);
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(--v2-text-text-muted);
color: var(--text-base);
&.clickable {
cursor: pointer;
@@ -122,7 +122,7 @@
transition: color 0.15s ease;
&:hover {
color: var(--v2-text-text-muted);
color: var(--text-base);
}
}
@@ -160,7 +160,7 @@
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--v2-text-text-muted);
color: var(--text-base);
}
[data-slot="basic-tool-tool-action"] {
@@ -178,7 +178,7 @@
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
border: 0.5px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
border: 1px 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(--v2-text-text-faint);
color: var(--icon-weak);
margin-left: auto;
opacity: 0;
transition:
@@ -233,7 +233,7 @@
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--v2-text-text-base);
color: var(--text-strong);
}
&:hover,
@@ -246,34 +246,3 @@
}
}
}
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);
}
}
}
@@ -1,32 +0,0 @@
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()
})
})
@@ -1,15 +0,0 @@
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"
}
+24 -137
View File
@@ -1,17 +1,9 @@
[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(--v2-text-text-base);
color: var(--text-strong);
font-family: var(--font-family-sans);
font-size: var(--font-size-base); /* 14px */
line-height: 160%;
@@ -30,31 +22,15 @@
margin-bottom: 0;
}
/* 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);
}
/* Headings: Same size, distinguished by color and spacing */
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 13px;
color: var(--v2-text-text-base);
font-size: 14px;
color: var(--text-strong);
font-weight: var(--font-weight-medium);
margin-top: 0px;
margin-bottom: 24px;
@@ -64,7 +40,7 @@
/* Emphasis & Strong: Neutral strong color */
strong,
b {
color: var(--v2-text-text-base);
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
@@ -119,7 +95,7 @@
}
li::marker {
color: var(--v2-text-text-muted);
color: var(--text-weak);
}
/* Nested lists spacing */
@@ -136,10 +112,10 @@
/* Blockquotes */
blockquote {
border-left: 0.5px solid var(--v2-border-border-base);
border-left: 2px solid var(--border-weak-base);
margin: 1.5rem 0;
padding-left: 0.5rem;
color: var(--v2-text-text-muted);
color: var(--text-weak);
font-style: normal;
}
@@ -156,27 +132,13 @@
font-size: 13px;
padding: 12px;
border-radius: 6px;
border: 0.5px solid var(--v2-border-border-base);
border: 0.5px solid var(--border-weak-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;
@@ -198,7 +160,7 @@
background: var(--surface-float-base);
color: var(--text-invert-strong);
padding: 2px 8px;
border: 0.5px solid var(--v2-border-border-base, rgba(0, 0, 0, 0.07));
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
box-shadow: var(--shadow-md);
pointer-events: none;
@@ -223,11 +185,11 @@
[data-slot="markdown-copy-button"][data-variant="secondary"] {
box-shadow: none;
border: 0.5px solid var(--v2-border-border-base);
border: 1px solid var(--border-weak-base);
}
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--v2-icon-icon-base);
color: var(--icon-base);
}
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {
@@ -260,28 +222,15 @@
:not(pre) > code {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
color: var(--v2-text-text-base);
color: var(--syntax-string);
font-weight: var(--font-weight-medium);
padding: 2px 4px;
border-radius: 4px;
background: color-mix(in oklch, var(--v2-text-text-base) var(--markdown-inline-code-bg-mix), transparent);
}
/* font-size: 13px; */
: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;
/* 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); */
}
/* Tables */
@@ -297,16 +246,16 @@
th,
td {
/* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */
border-bottom: 0.5px solid var(--v2-border-border-muted);
border-bottom: 1px solid var(--border-weaker-base);
padding: 12px;
text-align: left;
vertical-align: top;
}
th {
color: var(--v2-text-text-base);
color: var(--text-strong);
font-weight: var(--font-weight-medium);
border-bottom: 0.5px solid var(--v2-border-border-base);
border-bottom: 1px solid var(--border-weak-base);
}
/* Images */
@@ -319,68 +268,6 @@
}
}
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,7 +25,6 @@ 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"> })
@@ -141,35 +140,6 @@ 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
@@ -177,15 +147,12 @@ 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,
)
@@ -229,23 +196,11 @@ 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)
}
@@ -567,8 +522,6 @@ 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, {
@@ -599,7 +552,6 @@ 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(--v2-text-text-base);
color: var(--text-strong);
display: flex;
flex-direction: column;
align-items: flex-end;
@@ -41,15 +41,15 @@
min-width: 0;
border-radius: 6px;
overflow: hidden;
background: var(--v2-background-bg-layer-02);
border: 0.5px solid var(--v2-border-border-base);
background: var(--surface-weak);
border: 1px solid var(--border-weak-base);
cursor: default;
transition:
border-color 0.15s ease,
opacity 0.3s ease;
&:hover {
border-color: var(--v2-border-border-strong);
border-color: var(--border-strong-base);
}
&[data-clickable] {
@@ -107,7 +107,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--v2-text-text-muted);
color: var(--text-base);
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(--v2-background-bg-layer-02);
border: none;
background: var(--surface-base);
border: 1px solid var(--border-weak-base);
padding: 8px 12px;
border-radius: 10px;
border-radius: 6px;
[data-highlight="file"] {
color: var(--syntax-property);
@@ -199,7 +199,7 @@
}
.text-text-strong {
color: var(--v2-text-text-base);
color: var(--text-strong);
}
.font-medium {
@@ -267,7 +267,7 @@
[data-slot="compaction-part-line"] {
flex: 1 1 auto;
height: 1px;
background: var(--v2-border-border-base);
background: var(--border-weak-base);
}
[data-slot="compaction-part-label"] {
@@ -279,18 +279,18 @@
[data-component="reasoning-part"] {
width: 100%;
color: var(--v2-text-text-muted);
color: var(--text-base);
line-height: var(--line-height-normal);
[data-component="markdown"] {
margin-top: 16px;
font-style: normal;
font-size: 13px;
color: var(--v2-text-text-muted);
color: var(--text-weak);
strong,
b {
color: var(--v2-text-text-muted);
color: var(--text-weak);
}
p:has(strong) {
@@ -357,7 +357,7 @@
[data-component="bash-output"] {
width: 100%;
border: 0.5px solid var(--v2-border-border-base);
border: 1px solid var(--border-weak-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: 0.5px solid var(--v2-border-border-base);
border: 1px solid var(--border-weak-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(--v2-text-text-muted);
color: var(--text-base);
}
[data-slot="message-part-title-spinner"] {
@@ -453,7 +453,7 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--v2-text-text-muted);
color: var(--text-weak);
[data-component="spinner"] {
width: 16px;
@@ -464,7 +464,7 @@
[data-slot="message-part-title-text"] {
flex-shrink: 0;
text-transform: capitalize;
color: var(--v2-text-text-base);
color: var(--text-strong);
}
[data-slot="message-part-title-filename"] {
@@ -484,7 +484,7 @@
}
[data-slot="message-part-directory"] {
color: var(--v2-text-text-muted);
color: var(--text-weak);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
@@ -493,7 +493,7 @@
}
[data-slot="message-part-filename"] {
color: var(--v2-text-text-base);
color: var(--text-strong);
flex-shrink: 0;
}
@@ -508,7 +508,7 @@
[data-component="edit-content"] {
border-radius: inherit;
border-top: 0.5px solid var(--v2-border-border-muted);
border-top: 1px solid var(--border-weaker-base);
overflow-x: hidden;
overflow-y: visible;
@@ -527,7 +527,7 @@
[data-component="write-content"] {
border-radius: inherit;
border-top: 0.5px solid var(--v2-border-border-muted);
border-top: 1px solid var(--border-weaker-base);
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(--v2-text-text-muted);
color: var(--text-base);
}
[data-slot="basic-tool-tool-subtitle"].exa-tool-query {
@@ -583,7 +583,7 @@
max-width: 100%;
font: inherit;
line-height: inherit;
color: var(--v2-text-text-accent);
color: var(--text-interactive-base);
text-decoration: underline;
text-underline-offset: 2px;
overflow: hidden;
@@ -591,11 +591,11 @@
white-space: nowrap;
&:hover {
color: var(--v2-text-text-accent);
color: var(--text-interactive-base);
}
&:visited {
color: var(--v2-text-text-accent);
color: var(--text-interactive-base);
}
}
@@ -615,7 +615,7 @@
&[data-completed="completed"] {
text-decoration: line-through;
color: var(--v2-text-text-faint);
color: var(--text-weaker);
}
}
}
@@ -660,8 +660,8 @@
flex-direction: column;
gap: 4px;
padding: 8px 12px;
background-color: var(--v2-state-bg-danger);
border-top: 0.5px solid var(--v2-state-border-danger);
background-color: var(--surface-critical-weak);
border-top: 1px solid var(--border-critical-base);
[data-slot="diagnostic"] {
display: flex;
@@ -673,7 +673,7 @@
}
[data-slot="diagnostic-label"] {
color: var(--v2-state-fg-danger);
color: var(--text-on-critical-base);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
letter-spacing: -0.5px;
@@ -681,12 +681,12 @@
}
[data-slot="diagnostic-location"] {
color: var(--v2-state-fg-danger);
color: var(--text-on-critical-weak);
flex-shrink: 0;
}
[data-slot="diagnostic-message"] {
color: var(--v2-state-fg-danger);
color: var(--text-on-critical-base);
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(--v2-text-text-base);
color: var(--text-strong);
min-width: 0;
}
@@ -778,7 +778,7 @@
font-size: 14px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--v2-text-text-muted);
color: var(--text-weak);
padding: 0;
}
@@ -856,7 +856,7 @@
font-size: 14px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--v2-text-text-base);
color: var(--text-strong);
min-width: 0;
}
@@ -916,7 +916,7 @@
font-size: 14px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--v2-text-text-base);
color: var(--text-strong);
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(--v2-text-text-muted);
color: var(--text-weak);
padding: 0 10px;
}
@@ -953,8 +953,8 @@
align-items: flex-start;
gap: 12px;
padding: 8px 8px 8px 10px;
background-color: var(--v2-background-bg-layer-01);
border: 0.5px solid var(--v2-border-border-base);
background-color: var(--surface-raised-stronger-non-alpha);
border: 1px solid var(--border-weak-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(--v2-background-bg-base);
background-color: var(--background-base);
}
&[data-picked="true"] {
background-color: var(--v2-state-bg-info);
background-color: var(--surface-interactive-weak);
border-color: transparent;
box-shadow: var(--shadow-xs-border-hover);
}
@@ -991,7 +991,7 @@
height: 16px;
padding: 2px;
border-radius: var(--radius-sm);
border: 0.5px solid var(--v2-border-border-base);
border: 1px solid var(--border-weak-base);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1014,7 +1014,7 @@
width: 6px;
height: 6px;
border-radius: 999px;
background-color: var(--v2-background-bg-layer-01);
background-color: var(--background-stronger);
opacity: 0;
}
@@ -1052,7 +1052,7 @@
font-size: 14px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--v2-text-text-base);
color: var(--text-strong);
}
[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(--v2-text-text-muted);
color: var(--text-base);
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(--v2-text-text-muted);
color: var(--text-base);
min-width: 0;
cursor: text;
resize: none;
@@ -1096,11 +1096,11 @@
overflow-wrap: anywhere;
&::placeholder {
color: var(--v2-text-text-muted);
color: var(--text-weak);
}
&:focus-visible {
outline: 0.5px solid var(--v2-border-border-focus);
outline: 1px solid var(--border-interactive-base);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
@@ -1139,11 +1139,11 @@
font-size: 13px;
[data-slot="question-text"] {
color: var(--v2-text-text-muted);
color: var(--text-weak);
}
[data-slot="answer-text"] {
color: var(--v2-text-text-base);
color: var(--text-strong);
}
}
}
@@ -1166,13 +1166,13 @@
z-index: 20;
height: calc(32px + var(--tool-content-gap));
padding-bottom: var(--tool-content-gap);
background-color: var(--v2-background-bg-base);
background-color: var(--background-stronger);
}
}
[data-component="accordion"][data-scope="apply-patch"] {
[data-slot="accordion-trigger"] {
background-color: var(--v2-background-bg-base) !important;
background-color: var(--background-stronger) !important;
}
[data-slot="apply-patch-trigger-content"] {
@@ -1199,7 +1199,7 @@
}
[data-slot="apply-patch-directory"] {
color: var(--v2-text-text-muted);
color: var(--text-base);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
@@ -1208,7 +1208,7 @@
}
[data-slot="apply-patch-filename"] {
color: var(--v2-text-text-base);
color: var(--text-strong);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -1268,199 +1268,10 @@
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--v2-text-text-muted);
color: var(--text-weak);
[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(--v2-background-bg-base);
background-color: var(--background-stronger);
height: 40px;
padding-bottom: 8px;
flex-shrink: 0;
@@ -211,7 +211,7 @@
[data-slot="session-review-large-diff"] {
padding: 12px;
background: var(--v2-background-bg-base);
background: var(--background-stronger);
}
[data-slot="session-review-large-diff-title"] {
@@ -235,13 +235,3 @@
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(--v2-text-text-base);
color: var(--text-on-critical-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(--v2-background-bg-base);
background-color: var(--background-stronger);
height: 44px;
}
@@ -226,16 +226,6 @@
}
}
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,19 +2,6 @@
--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;
}
@@ -56,34 +43,10 @@
}
[data-slot="tool-error-card-content"] :where(*)::selection {
background: var(--v2-state-bg-danger);
color: var(--v2-text-text-base);
background: var(--surface-critical-base);
color: var(--text-on-critical-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,7 +31,6 @@ import {
type ModelCatalogEntry,
} from "../model-catalog"
import { runStatsEffect } from "../../stats-runtime"
import { setStatsPageCacheHeaders } from "../stats-cache"
import {
applyThemePreference,
Footer,
@@ -87,7 +86,7 @@ export default function StatsModel() {
const i18n = useI18n()
const language = useLanguage()
const event = getRequestEvent()
setStatsPageCacheHeaders(event?.response.headers)
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
const params = useParams()
const labParam = createMemo(() => params.lab ?? "")
const modelParam = createMemo(() => params.model ?? "")
@@ -21,7 +21,6 @@ import {
type ModelCatalogLab,
} from "../model-catalog"
import { runStatsEffect } from "../../stats-runtime"
import { setStatsPageCacheHeaders } from "../stats-cache"
import {
applyThemePreference,
Footer,
@@ -44,7 +43,7 @@ export default function StatsLab() {
const i18n = useI18n()
const language = useLanguage()
const event = getRequestEvent()
setStatsPageCacheHeaders(event?.response.headers)
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
const params = useParams()
const labParam = createMemo(() => params.lab ?? "")
const catalog = createAsync(() => getModelCatalog())
+1 -2
View File
@@ -32,7 +32,6 @@ 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,
@@ -122,7 +121,7 @@ export default function StatsHome() {
const i18n = useI18n()
const language = useLanguage()
const event = getRequestEvent()
setStatsPageCacheHeaders(event?.response.headers)
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
const statsHomeUrl = localizedUrl(language.locale(), "/data/")
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
const data = createAsync(() => getData())
@@ -1,27 +0,0 @@
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(", "),
)
}
+16 -18
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 } from "@opentui/core"
import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
import {
Switch,
@@ -183,23 +183,21 @@ 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({
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))),
}),
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" }],
},
}),
),
(renderer) =>
Effect.sync(() => {
destroyRenderer(renderer)
-2
View File
@@ -15,7 +15,6 @@
"**/*.css"
],
"files": [
"dist/**/*.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
"!src/**/*.test.ts",
@@ -56,7 +55,6 @@
"./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,7 +246,6 @@
--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
@@ -1,40 +0,0 @@
#!/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,7 +4,6 @@ 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)))
@@ -19,7 +18,8 @@ if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode ==
try {
await $`bun run typecheck`
await $`bun run test`
await pack()
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --access public --tag ${Script.channel}`
} finally {
await rm(tarball, { force: true })

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