Merge branch 'dev' into desktop-poilsh-styles-ui-ux
This commit is contained in:
commit
e3b78fc03a
170 changed files with 12745 additions and 7027 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.23",
|
||||
"version": "1.1.25",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { Suspense } from "solid-js"
|
|||
|
||||
const Home = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
const Loading = () => <div class="size-full flex items-center justify-center text-text-weak">Loading...</div>
|
||||
const Loading = () => <div class="size-full" />
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { useParams } from "@solidjs/router"
|
||||
|
|
@ -133,14 +134,14 @@ export function DialogSelectFile() {
|
|||
})
|
||||
|
||||
return (
|
||||
<Dialog title="Search">
|
||||
<Dialog class="pt-3 pb-0 !max-h-[480px]">
|
||||
<List
|
||||
search={{ placeholder: "Search files and commands", autofocus: true }}
|
||||
search={{ placeholder: "Search files and commands", autofocus: true, hideIcon: true, class: "pl-3 pr-2 !mb-0" }}
|
||||
emptyMessage="No results found"
|
||||
items={items}
|
||||
key={(item) => item.id}
|
||||
filterKeys={["title", "description", "category"]}
|
||||
groupBy={(item) => (grouped() ? item.category : "")}
|
||||
groupBy={(item) => item.category}
|
||||
onMove={handleMove}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
|
|
@ -148,7 +149,7 @@ export function DialogSelectFile() {
|
|||
<Show
|
||||
when={item.type === "command"}
|
||||
fallback={
|
||||
<div class="w-full flex items-center justify-between rounded-md">
|
||||
<div class="w-full flex items-center justify-between rounded-md pl-1">
|
||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||
<FileIcon node={{ path: item.path ?? "", type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular">
|
||||
|
|
@ -161,7 +162,7 @@ export function DialogSelectFile() {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<div class="w-full flex items-center justify-between gap-4">
|
||||
<div class="w-full flex items-center justify-between gap-4 pl-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
|
||||
<Show when={item.description}>
|
||||
|
|
@ -169,7 +170,7 @@ export function DialogSelectFile() {
|
|||
</Show>
|
||||
</div>
|
||||
<Show when={item.keybind}>
|
||||
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(item.keybind ?? "")}</span>
|
||||
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,24 @@
|
|||
import { createMemo, createResource, Show } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useCommand } from "@/context/command"
|
||||
// import { useServer } from "@/context/server"
|
||||
// import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
|
||||
export function SessionHeader() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
|
|
@ -25,6 +28,7 @@ export function SessionHeader() {
|
|||
// const server = useServer()
|
||||
// const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const platform = usePlatform()
|
||||
|
||||
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
|
||||
const project = createMemo(() => {
|
||||
|
|
@ -44,6 +48,78 @@ export function SessionHeader() {
|
|||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
|
||||
const [state, setState] = createStore({
|
||||
share: false,
|
||||
unshare: false,
|
||||
copied: false,
|
||||
timer: undefined as number | undefined,
|
||||
})
|
||||
const shareUrl = createMemo(() => currentSession()?.share?.url)
|
||||
|
||||
createEffect(() => {
|
||||
const url = shareUrl()
|
||||
if (url) return
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
setState({ copied: false, timer: undefined })
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
})
|
||||
|
||||
function shareSession() {
|
||||
const session = currentSession()
|
||||
if (!session || state.share) return
|
||||
setState("share", true)
|
||||
globalSDK.client.session
|
||||
.share({ sessionID: session.id, directory: projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to share session", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setState("share", false)
|
||||
})
|
||||
}
|
||||
|
||||
function unshareSession() {
|
||||
const session = currentSession()
|
||||
if (!session || state.unshare) return
|
||||
setState("unshare", true)
|
||||
globalSDK.client.session
|
||||
.unshare({ sessionID: session.id, directory: projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to unshare session", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setState("unshare", false)
|
||||
})
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
if (state.timer) window.clearTimeout(state.timer)
|
||||
setState("copied", true)
|
||||
const timer = window.setTimeout(() => {
|
||||
setState("copied", false)
|
||||
setState("timer", undefined)
|
||||
}, 3000)
|
||||
setState("timer", timer)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to copy share link", error)
|
||||
})
|
||||
}
|
||||
|
||||
function viewShare() {
|
||||
const url = shareUrl()
|
||||
if (!url) return
|
||||
platform.openLink(url)
|
||||
}
|
||||
|
||||
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
||||
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
||||
|
||||
|
|
@ -57,23 +133,14 @@ export function SessionHeader() {
|
|||
class="hidden md:flex w-[320px] p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
|
||||
onClick={() => command.trigger("file.open")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="magnifying-glass" size="normal" class="icon-base" />
|
||||
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate" style={{ "line-height": 1 }}>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-visible">
|
||||
<Icon name="magnifying-glass" size="normal" class="icon-base shrink-0" />
|
||||
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate h-4.5 flex items-center">
|
||||
Search {name()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={hotkey()}>
|
||||
{(keybind) => (
|
||||
<span
|
||||
class="shrink-0 flex items-center justify-center h-5 px-2 rounded-[2px] bg-surface-base text-12-medium text-text-weak"
|
||||
style={{ "box-shadow": "var(--shadow-xxs-border)" }}
|
||||
>
|
||||
{keybind()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={hotkey()}>{(keybind) => <Keybind class="shrink-0">{keybind()}</Keybind>}</Show>
|
||||
</button>
|
||||
</Portal>
|
||||
)}
|
||||
|
|
@ -167,40 +234,81 @@ export function SessionHeader() {
|
|||
</TooltipKeybind>
|
||||
</div>
|
||||
<Show when={shareEnabled() && currentSession()}>
|
||||
<Popover
|
||||
title="Share session"
|
||||
trigger={
|
||||
<Tooltip class="shrink-0" value="Share session">
|
||||
<IconButton icon="share" variant="ghost" class="" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{iife(() => {
|
||||
const [url] = createResource(
|
||||
() => currentSession(),
|
||||
async (session) => {
|
||||
if (!session) return
|
||||
let shareURL = session.share?.url
|
||||
if (!shareURL) {
|
||||
shareURL = await globalSDK.client.session
|
||||
.share({ sessionID: session.id, directory: projectDirectory() })
|
||||
.then((r) => r.data?.share?.url)
|
||||
.catch((e) => {
|
||||
console.error("Failed to share session", e)
|
||||
return undefined
|
||||
})
|
||||
<div class="flex items-center">
|
||||
<Popover
|
||||
title="Publish on web"
|
||||
description={
|
||||
shareUrl()
|
||||
? "This session is public on the web. It is accessible to anyone with the link."
|
||||
: "Share session publicly on the web. It will be accessible to anyone with the link."
|
||||
}
|
||||
trigger={
|
||||
<Tooltip class="shrink-0" value="Share session">
|
||||
<Button
|
||||
variant="secondary"
|
||||
classList={{ "rounded-r-none": shareUrl() !== undefined }}
|
||||
style={{ scale: 1 }}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-1/2"
|
||||
onClick={shareSession}
|
||||
disabled={state.share}
|
||||
>
|
||||
{state.share ? "Publishing..." : "Publish"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
return shareURL
|
||||
},
|
||||
{ initialValue: "" },
|
||||
)
|
||||
return (
|
||||
<Show when={url.latest}>
|
||||
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
|
||||
>
|
||||
<div class="flex flex-col gap-2 w-72">
|
||||
<TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
{state.unshare ? "Unpublishing..." : "Unpublish"}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
})}
|
||||
</Popover>
|
||||
</div>
|
||||
</Popover>
|
||||
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
|
||||
<Tooltip value={state.copied ? "Copied" : "Copy link"} placement="top" gutter={8}>
|
||||
<IconButton
|
||||
icon={state.copied ? "check" : "copy"}
|
||||
variant="secondary"
|
||||
class="rounded-l-none"
|
||||
onClick={copyLink}
|
||||
disabled={state.unshare}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Portal>
|
||||
|
|
|
|||
|
|
@ -81,13 +81,15 @@ export function Titlebar() {
|
|||
>
|
||||
<Show when={mac()}>
|
||||
<div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
|
||||
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||
<IconButton icon="menu" variant="ghost" class="size-8 rounded-md" onClick={layout.mobileSidebar.toggle} />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!mac()}>
|
||||
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
|
||||
<IconButton icon="menu" variant="ghost" class="size-8 rounded-md" onClick={layout.mobileSidebar.toggle} />
|
||||
</div>
|
||||
</Show>
|
||||
<IconButton
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="xl:hidden size-8 rounded-md"
|
||||
onClick={layout.mobileSidebar.toggle}
|
||||
/>
|
||||
<TooltipKeybind
|
||||
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0"}
|
||||
placement="bottom"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
|
|
@ -104,7 +105,15 @@ export function formatKeybind(config: string): string {
|
|||
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
|
||||
|
||||
if (kb.key) {
|
||||
const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
|
||||
const arrows: Record<string, string> = {
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
}
|
||||
const displayKey =
|
||||
arrows[kb.key.toLowerCase()] ??
|
||||
(kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1))
|
||||
parts.push(displayKey)
|
||||
}
|
||||
|
||||
|
|
@ -114,6 +123,7 @@ export function formatKeybind(config: string): string {
|
|||
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
||||
name: "Command",
|
||||
init: () => {
|
||||
const dialog = useDialog()
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
|
||||
|
|
@ -157,7 +167,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (suspended()) return
|
||||
if (suspended() || dialog.active) return
|
||||
|
||||
const paletteKeybinds = parseKeybind("mod+shift+p")
|
||||
if (matchKeybind(paletteKeybinds, event)) {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ function createGlobalSync() {
|
|||
})
|
||||
|
||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||
|
||||
function child(directory: string) {
|
||||
if (!directory) console.error("No directory provided")
|
||||
if (!children[directory]) {
|
||||
|
|
@ -122,29 +123,33 @@ function createGlobalSync() {
|
|||
if (!cache) throw new Error("Failed to create persisted cache")
|
||||
vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] })
|
||||
|
||||
children[directory] = createStore<State>({
|
||||
project: "",
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
status: "loading" as const,
|
||||
agent: [],
|
||||
command: [],
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
vcs: cache[0].value,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
})
|
||||
bootstrapInstance(directory)
|
||||
const init = () => {
|
||||
children[directory] = createStore<State>({
|
||||
project: "",
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
status: "loading" as const,
|
||||
agent: [],
|
||||
command: [],
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
vcs: cache[0].value,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
})
|
||||
bootstrapInstance(directory)
|
||||
}
|
||||
|
||||
runWithOwner(owner, init)
|
||||
}
|
||||
const childStore = children[directory]
|
||||
if (!childStore) throw new Error("Failed to create store")
|
||||
|
|
@ -346,6 +351,23 @@ function createGlobalSync() {
|
|||
bootstrapInstance(directory)
|
||||
break
|
||||
}
|
||||
case "session.created": {
|
||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore("session", result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
if (!event.properties.info.parentID) {
|
||||
setStore("sessionTotal", store.sessionTotal + 1)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "session.updated": {
|
||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (event.properties.info.time.archived) {
|
||||
|
|
@ -357,6 +379,8 @@ function createGlobalSync() {
|
|||
}),
|
||||
)
|
||||
}
|
||||
if (event.properties.info.parentID) break
|
||||
setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
if (result.found) {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||
createStore({
|
||||
list: [] as string[],
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
lastProject: {} as Record<string, string>,
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -197,6 +198,16 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||
result.splice(toIndex, 0, item)
|
||||
setStore("projects", key, result)
|
||||
},
|
||||
last() {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
return store.lastProject[key]
|
||||
},
|
||||
touch(directory: string) {
|
||||
const key = origin()
|
||||
if (!key) return
|
||||
setStore("lastProject", key, directory)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
|
|
@ -12,6 +11,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
|
||||
export default function Home() {
|
||||
const sync = useGlobalSync()
|
||||
|
|
@ -24,6 +24,7 @@ export default function Home() {
|
|||
|
||||
function openProject(directory: string) {
|
||||
layout.projects.open(directory)
|
||||
server.projects.touch(directory)
|
||||
navigate(`/${base64Encode(directory)}`)
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -419,7 +419,6 @@ export default function Page() {
|
|||
{
|
||||
id: "session.new",
|
||||
title: "New session",
|
||||
description: "Create a new session",
|
||||
category: "Session",
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
|
|
@ -437,7 +436,7 @@ export default function Page() {
|
|||
{
|
||||
id: "terminal.toggle",
|
||||
title: "Toggle terminal",
|
||||
description: "Show or hide the terminal",
|
||||
description: "",
|
||||
category: "View",
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
|
|
@ -446,7 +445,7 @@ export default function Page() {
|
|||
{
|
||||
id: "review.toggle",
|
||||
title: "Toggle review",
|
||||
description: "Show or hide the review panel",
|
||||
description: "",
|
||||
category: "View",
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => view().reviewPanel.toggle(),
|
||||
|
|
@ -534,10 +533,6 @@ export default function Page() {
|
|||
keybind: "shift+mod+t",
|
||||
onSelect: () => {
|
||||
local.model.variant.cycle()
|
||||
showToast({
|
||||
title: "Thinking effort changed",
|
||||
description: "The thinking effort has been changed to " + (local.model.variant.current() ?? "Default"),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -655,6 +650,72 @@ export default function Page() {
|
|||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: () => dialog.show(() => <DialogFork />),
|
||||
},
|
||||
...(sync.data.config.share !== "disabled"
|
||||
? [
|
||||
{
|
||||
id: "session.share",
|
||||
title: "Share session",
|
||||
description: "Share this session and copy the URL to clipboard",
|
||||
category: "Session",
|
||||
slash: "share",
|
||||
disabled: !params.id || !!info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.share({ sessionID: params.id })
|
||||
.then((res) => {
|
||||
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
|
||||
showToast({
|
||||
title: "Failed to copy URL to clipboard",
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
})
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: "Session shared",
|
||||
description: "Share URL copied to clipboard!",
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: "Failed to share session",
|
||||
description: "An error occurred while sharing the session",
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "session.unshare",
|
||||
title: "Unshare session",
|
||||
description: "Stop sharing this session",
|
||||
category: "Session",
|
||||
slash: "unshare",
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID: params.id })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: "Session unshared",
|
||||
description: "Session unshared successfully!",
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: "Failed to unshare session",
|
||||
description: "An error occurred while unsharing the session",
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
])
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
|
@ -1092,7 +1153,7 @@ export default function Page() {
|
|||
file.load(path)
|
||||
}}
|
||||
classes={{
|
||||
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
|
||||
root: "pb-[calc(var(--prompt-height,8rem)+24px)]",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
}}
|
||||
|
|
@ -1237,7 +1298,7 @@ export default function Page() {
|
|||
{/* Prompt input */}
|
||||
<div
|
||||
ref={(el) => (promptDock = el)}
|
||||
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
|
||||
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.23",
|
||||
"version": "1.1.25",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -183,7 +183,12 @@ export async function POST(input: APIEvent) {
|
|||
.set({
|
||||
customerID,
|
||||
subscriptionID,
|
||||
subscriptionCouponID: couponID,
|
||||
subscription: {
|
||||
status: "subscribed",
|
||||
coupon: couponID,
|
||||
seats: 1,
|
||||
plan: "200",
|
||||
},
|
||||
paymentMethodID: paymentMethod.id,
|
||||
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
|
||||
paymentMethodType: paymentMethod.type,
|
||||
|
|
@ -408,7 +413,7 @@ export async function POST(input: APIEvent) {
|
|||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({ subscriptionID: null, subscriptionCouponID: null })
|
||||
.set({ subscriptionID: null, subscription: null })
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
|
||||
|
|
|
|||
|
|
@ -64,23 +64,20 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||
newBuffer.set(value, buffer.length)
|
||||
buffer = newBuffer
|
||||
|
||||
if (buffer.length < 4) return
|
||||
// The first 4 bytes are the total length (big-endian).
|
||||
const totalLength = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(0, false)
|
||||
const messages = []
|
||||
while (buffer.length >= 4) {
|
||||
// first 4 bytes are the total length (big-endian)
|
||||
const totalLength = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(0, false)
|
||||
|
||||
// If we don't have the full message yet, wait for more chunks.
|
||||
if (buffer.length < totalLength) return
|
||||
// wait for more chunks
|
||||
if (buffer.length < totalLength) break
|
||||
|
||||
try {
|
||||
// Decode exactly the sub-slice for this event.
|
||||
const subView = buffer.subarray(0, totalLength)
|
||||
const decoded = codec.decode(subView)
|
||||
try {
|
||||
const subView = buffer.subarray(0, totalLength)
|
||||
const decoded = codec.decode(subView)
|
||||
buffer = buffer.slice(totalLength)
|
||||
|
||||
// Slice the used bytes out of the buffer, removing this message.
|
||||
buffer = buffer.slice(totalLength)
|
||||
|
||||
// Process message
|
||||
/* Example of Bedrock data
|
||||
/* Example of Bedrock data
|
||||
```
|
||||
{
|
||||
bytes: 'eyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtb3B1cy00LTUtMjAyNTExMDEiLCJpZCI6Im1zZ19iZHJrXzAxMjVGdHRGb2lkNGlwWmZ4SzZMbktxeCIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwidXNhZ2UiOnsiaW5wdXRfdG9rZW5zIjo0LCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjEsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjoxMTk2MywiY2FjaGVfY3JlYXRpb24iOnsiZXBoZW1lcmFsXzVtX2lucHV0X3Rva2VucyI6MSwiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6MH0sIm91dHB1dF90b2tlbnMiOjF9fX0=',
|
||||
|
|
@ -112,22 +109,30 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||
```
|
||||
*/
|
||||
|
||||
/* Example of Anthropic data
|
||||
/* Example of Anthropic data
|
||||
```
|
||||
event: message_delta
|
||||
data: {"type":"message_start","message":{"model":"claude-opus-4-5-20251101","id":"msg_01ETvwVWSKULxzPdkQ1xAnk2","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":11543,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":11543,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}}
|
||||
```
|
||||
*/
|
||||
if (decoded.headers[":message-type"]?.value !== "event") return
|
||||
const data = decoder.decode(decoded.body, { stream: true })
|
||||
if (decoded.headers[":message-type"]?.value === "event") {
|
||||
const data = decoder.decode(decoded.body, { stream: true })
|
||||
|
||||
const parsedDataResult = JSON.parse(data)
|
||||
delete parsedDataResult.p
|
||||
const utf8 = atob(parsedDataResult.bytes)
|
||||
return encoder.encode(["event: message_start", "\n", "data: " + utf8, "\n\n"].join(""))
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
const parsedDataResult = JSON.parse(data)
|
||||
delete parsedDataResult.p
|
||||
const binary = atob(parsedDataResult.bytes)
|
||||
const uint8 = Uint8Array.from(binary, (c) => c.charCodeAt(0))
|
||||
const bytes = decoder.decode(uint8)
|
||||
const eventName = JSON.parse(bytes).type
|
||||
messages.push([`event: ${eventName}`, "\n", `data: ${bytes}`, "\n\n"].join(""))
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("@@@EE@@@")
|
||||
console.log(e)
|
||||
break
|
||||
}
|
||||
}
|
||||
return encoder.encode(messages.join(""))
|
||||
}
|
||||
},
|
||||
streamSeparator: "\n\n",
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `billing` ADD `subscription` json;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `billing` DROP COLUMN `subscription_coupon_id`;
|
||||
1242
packages/console/core/migrations/meta/0053_snapshot.json
Normal file
1242
packages/console/core/migrations/meta/0053_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1235
packages/console/core/migrations/meta/0054_snapshot.json
Normal file
1235
packages/console/core/migrations/meta/0054_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -372,6 +372,20 @@
|
|||
"when": 1768343920467,
|
||||
"tag": "0052_aromatic_agent_zero",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 53,
|
||||
"version": "5",
|
||||
"when": 1768599366758,
|
||||
"tag": "0053_gigantic_hardball",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 54,
|
||||
"version": "5",
|
||||
"when": 1768603665356,
|
||||
"tag": "0054_numerous_annihilus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.23",
|
||||
"version": "1.1.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
112
packages/console/core/script/black-gift.ts
Normal file
112
packages/console/core/script/black-gift.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { Billing } from "../src/billing.js"
|
||||
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
|
||||
import { Identifier } from "../src/identifier.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
|
||||
const plan = "200"
|
||||
const workspaceID = process.argv[2]
|
||||
const seats = parseInt(process.argv[3])
|
||||
|
||||
console.log(`Gifting ${seats} seats of Black to workspace ${workspaceID}`)
|
||||
|
||||
if (!workspaceID || !seats) throw new Error("Usage: bun foo.ts <workspaceID> <seats>")
|
||||
|
||||
// Get workspace user
|
||||
const users = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
id: UserTable.id,
|
||||
role: UserTable.role,
|
||||
email: AuthTable.subject,
|
||||
})
|
||||
.from(UserTable)
|
||||
.leftJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
|
||||
)
|
||||
if (users.length === 0) throw new Error(`Error: No users found in workspace ${workspaceID}`)
|
||||
if (users.length !== seats)
|
||||
throw new Error(`Error: Workspace ${workspaceID} has ${users.length} users, expected ${seats}`)
|
||||
const adminUser = users.find((user) => user.role === "admin")
|
||||
if (!adminUser) throw new Error(`Error: No admin user found in workspace ${workspaceID}`)
|
||||
if (!adminUser.email) throw new Error(`Error: Admin user ${adminUser.id} has no email`)
|
||||
|
||||
// Get Billing
|
||||
const billing = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
customerID: BillingTable.customerID,
|
||||
subscriptionID: BillingTable.subscriptionID,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!billing) throw new Error(`Error: Workspace ${workspaceID} has no billing record`)
|
||||
if (billing.subscriptionID) throw new Error(`Error: Workspace ${workspaceID} already has a subscription`)
|
||||
|
||||
// Look up the Stripe customer by email
|
||||
const customerID =
|
||||
billing.customerID ??
|
||||
(await (() =>
|
||||
Billing.stripe()
|
||||
.customers.create({
|
||||
email: adminUser.email,
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
.then((customer) => customer.id))())
|
||||
console.log(`Customer ID: ${customerID}`)
|
||||
|
||||
const couponID = "JAIr0Pe1"
|
||||
const subscription = await Billing.stripe().subscriptions.create({
|
||||
customer: customerID!,
|
||||
items: [
|
||||
{
|
||||
price: `price_1SmfyI2StuRr0lbXovxJNeZn`,
|
||||
discounts: [{ coupon: couponID }],
|
||||
quantity: 2,
|
||||
},
|
||||
],
|
||||
})
|
||||
console.log(`Subscription ID: ${subscription.id}`)
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
// Set customer id, subscription id, and payment method on workspace billing
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID,
|
||||
subscriptionID: subscription.id,
|
||||
subscription: { status: "subscribed", coupon: couponID, seats, plan },
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
// Create a row in subscription table
|
||||
for (const user of users) {
|
||||
await tx.insert(SubscriptionTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("subscription"),
|
||||
userID: user.id,
|
||||
})
|
||||
}
|
||||
//
|
||||
// // Create a row in payments table
|
||||
// await tx.insert(PaymentTable).values({
|
||||
// workspaceID,
|
||||
// id: Identifier.create("payment"),
|
||||
// amount: centsToMicroCents(amountInCents),
|
||||
// customerID,
|
||||
// invoiceID,
|
||||
// paymentID,
|
||||
// enrichment: {
|
||||
// type: "subscription",
|
||||
// couponID,
|
||||
// },
|
||||
// })
|
||||
})
|
||||
|
||||
console.log(`done`)
|
||||
|
|
@ -12,7 +12,7 @@ const email = process.argv[3]
|
|||
console.log(`Onboarding workspace ${workspaceID} for email ${email}`)
|
||||
|
||||
if (!workspaceID || !email) {
|
||||
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
|
||||
console.error("Usage: bun foo.ts <workspaceID> <email>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ const existingSubscription = await Database.use((tx) =>
|
|||
tx
|
||||
.select({ workspaceID: BillingTable.workspaceID })
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.subscriptionID, subscriptionID))
|
||||
.where(sql`JSON_EXTRACT(${BillingTable.subscription}, '$.id') = ${subscriptionID}`)
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (existingSubscription) {
|
||||
|
|
@ -128,10 +128,15 @@ await Database.transaction(async (tx) => {
|
|||
.set({
|
||||
customerID,
|
||||
subscriptionID,
|
||||
subscriptionCouponID: couponID,
|
||||
paymentMethodID,
|
||||
paymentMethodLast4,
|
||||
paymentMethodType,
|
||||
subscription: {
|
||||
status: "subscribed",
|
||||
coupon: couponID,
|
||||
seats: 1,
|
||||
plan: "200",
|
||||
},
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ const fromBilling = await Database.use((tx) =>
|
|||
.select({
|
||||
customerID: BillingTable.customerID,
|
||||
subscriptionID: BillingTable.subscriptionID,
|
||||
subscriptionCouponID: BillingTable.subscriptionCouponID,
|
||||
subscription: BillingTable.subscription,
|
||||
paymentMethodID: BillingTable.paymentMethodID,
|
||||
paymentMethodType: BillingTable.paymentMethodType,
|
||||
paymentMethodLast4: BillingTable.paymentMethodLast4,
|
||||
|
|
@ -119,7 +119,7 @@ await Database.transaction(async (tx) => {
|
|||
.set({
|
||||
customerID: fromPrevPayment.customerID,
|
||||
subscriptionID: null,
|
||||
subscriptionCouponID: null,
|
||||
subscription: null,
|
||||
paymentMethodID: fromPrevPaymentMethods.data[0].id,
|
||||
paymentMethodLast4: fromPrevPaymentMethods.data[0].card?.last4 ?? null,
|
||||
paymentMethodType: fromPrevPaymentMethods.data[0].type,
|
||||
|
|
@ -131,7 +131,7 @@ await Database.transaction(async (tx) => {
|
|||
.set({
|
||||
customerID: fromBilling.customerID,
|
||||
subscriptionID: fromBilling.subscriptionID,
|
||||
subscriptionCouponID: fromBilling.subscriptionCouponID,
|
||||
subscription: fromBilling.subscription,
|
||||
paymentMethodID: fromBilling.paymentMethodID,
|
||||
paymentMethodLast4: fromBilling.paymentMethodLast4,
|
||||
paymentMethodType: fromBilling.paymentMethodType,
|
||||
|
|
|
|||
|
|
@ -55,8 +55,9 @@ if (identifier.startsWith("wrk_")) {
|
|||
),
|
||||
)
|
||||
|
||||
// Get all payments for these workspaces
|
||||
await Promise.all(users.map((u: { workspaceID: string }) => printWorkspace(u.workspaceID)))
|
||||
for (const user of users) {
|
||||
await printWorkspace(user.workspaceID)
|
||||
}
|
||||
}
|
||||
|
||||
async function printWorkspace(workspaceID: string) {
|
||||
|
|
@ -114,11 +115,11 @@ async function printWorkspace(workspaceID: string) {
|
|||
balance: BillingTable.balance,
|
||||
customerID: BillingTable.customerID,
|
||||
reload: BillingTable.reload,
|
||||
subscriptionID: BillingTable.subscriptionID,
|
||||
subscription: {
|
||||
id: BillingTable.subscriptionID,
|
||||
couponID: BillingTable.subscriptionCouponID,
|
||||
plan: BillingTable.subscriptionPlan,
|
||||
booked: BillingTable.timeSubscriptionBooked,
|
||||
enrichment: BillingTable.subscription,
|
||||
},
|
||||
})
|
||||
.from(BillingTable)
|
||||
|
|
@ -128,8 +129,13 @@ async function printWorkspace(workspaceID: string) {
|
|||
rows.map((row) => ({
|
||||
...row,
|
||||
balance: `$${(row.balance / 100000000).toFixed(2)}`,
|
||||
subscription: row.subscription.id
|
||||
? `Subscribed ${row.subscription.couponID ? `(coupon: ${row.subscription.couponID}) ` : ""}`
|
||||
subscription: row.subscriptionID
|
||||
? [
|
||||
`Black ${row.subscription.enrichment!.plan}`,
|
||||
row.subscription.enrichment!.seats > 1 ? `X ${row.subscription.enrichment!.seats} seats` : "",
|
||||
row.subscription.enrichment!.coupon ? `(coupon: ${row.subscription.enrichment!.coupon})` : "",
|
||||
`(ref: ${row.subscriptionID})`,
|
||||
].join(" ")
|
||||
: row.subscription.booked
|
||||
? `Waitlist ${row.subscription.plan} plan`
|
||||
: undefined,
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ export namespace Billing {
|
|||
customer: customer.customerID,
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
address: "auto",
|
||||
},
|
||||
}
|
||||
: {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,13 @@ export const BillingTable = mysqlTable(
|
|||
reloadError: varchar("reload_error", { length: 255 }),
|
||||
timeReloadError: utc("time_reload_error"),
|
||||
timeReloadLockedTill: utc("time_reload_locked_till"),
|
||||
subscription: json("subscription").$type<{
|
||||
status: "subscribed"
|
||||
coupon?: string
|
||||
seats: number
|
||||
plan: "20" | "100" | "200"
|
||||
}>(),
|
||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
|
||||
subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const),
|
||||
timeSubscriptionBooked: utc("time_subscription_booked"),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.23",
|
||||
"version": "1.1.25",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.23",
|
||||
"version": "1.1.25",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.23",
|
||||
"version": "1.1.25",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { relaunch } from "@tauri-apps/plugin-process"
|
|||
import { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
|
||||
import { Store } from "@tauri-apps/plugin-store"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
|
||||
|
||||
import { UPDATER_ENABLED } from "./updater"
|
||||
|
|
@ -26,6 +26,17 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
|||
)
|
||||
}
|
||||
|
||||
// Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements).
|
||||
// This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows.
|
||||
const originalGetComputedStyle = window.getComputedStyle
|
||||
window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => {
|
||||
if (!(elt instanceof Element)) {
|
||||
// Fall back to a safe element when a non-element is passed.
|
||||
return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined)
|
||||
}
|
||||
return originalGetComputedStyle(elt, pseudoElt ?? undefined)
|
||||
}) as typeof window.getComputedStyle
|
||||
|
||||
let update: Update | null = null
|
||||
|
||||
const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
|
|
@ -345,8 +356,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
|
|||
when={serverData.state !== "pending" && serverData()}
|
||||
fallback={
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Logo class="w-xl opacity-12 animate-pulse" />
|
||||
<div class="mt-8 text-14-regular text-text-weak">Initializing...</div>
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.23",
|
||||
"version": "1.1.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.23"
|
||||
version = "1.1.25"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
|
|
@ -11,26 +11,26 @@ name = "OpenCode"
|
|||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.23/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.23/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.23/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.23/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.23/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.23",
|
||||
"version": "1.1.25",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.23",
|
||||
"version": "1.1.25",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
"@ai-sdk/vercel": "1.0.31",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.1.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.1.2",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
|
|
@ -82,8 +82,8 @@
|
|||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@opentui/core": "0.1.73",
|
||||
"@opentui/solid": "0.1.73",
|
||||
"@opentui/core": "0.1.74",
|
||||
"@opentui/solid": "0.1.74",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
|
|
|||
|
|
@ -90,6 +90,11 @@ const targets = singleFlag
|
|||
return baselineFlag
|
||||
}
|
||||
|
||||
// also skip abi-specific builds for the same reason
|
||||
if (item.abi !== undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
: allTargets
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from "@agentclientprotocol/sdk"
|
||||
import { Log } from "../util/log"
|
||||
import { ACPSessionManager } from "./session"
|
||||
import type { ACPConfig, ACPSessionState } from "./types"
|
||||
import type { ACPConfig } from "./types"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { Agent as AgentModule } from "../agent/agent"
|
||||
import { Installation } from "@/installation"
|
||||
|
|
@ -29,7 +29,7 @@ import { Config } from "@/config/config"
|
|||
import { Todo } from "@/session/todo"
|
||||
import { z } from "zod"
|
||||
import { LoadAPIKeyError } from "ai"
|
||||
import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
||||
import type { Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
|
||||
export namespace ACP {
|
||||
|
|
@ -47,304 +47,354 @@ export namespace ACP {
|
|||
private connection: AgentSideConnection
|
||||
private config: ACPConfig
|
||||
private sdk: OpencodeClient
|
||||
private sessionManager
|
||||
private sessionManager: ACPSessionManager
|
||||
private eventAbort = new AbortController()
|
||||
private eventStarted = false
|
||||
private permissionQueues = new Map<string, Promise<void>>()
|
||||
private permissionOptions: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
constructor(connection: AgentSideConnection, config: ACPConfig) {
|
||||
this.connection = connection
|
||||
this.config = config
|
||||
this.sdk = config.sdk
|
||||
this.sessionManager = new ACPSessionManager(this.sdk)
|
||||
this.startEventSubscription()
|
||||
}
|
||||
|
||||
private setupEventSubscriptions(session: ACPSessionState) {
|
||||
const sessionId = session.id
|
||||
const directory = session.cwd
|
||||
private startEventSubscription() {
|
||||
if (this.eventStarted) return
|
||||
this.eventStarted = true
|
||||
this.runEventSubscription().catch((error) => {
|
||||
if (this.eventAbort.signal.aborted) return
|
||||
log.error("event subscription failed", { error })
|
||||
})
|
||||
}
|
||||
|
||||
const options: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
this.config.sdk.event.subscribe({ directory }).then(async (events) => {
|
||||
private async runEventSubscription() {
|
||||
while (true) {
|
||||
if (this.eventAbort.signal.aborted) return
|
||||
const events = await this.sdk.global.event({
|
||||
signal: this.eventAbort.signal,
|
||||
})
|
||||
for await (const event of events.stream) {
|
||||
switch (event.type) {
|
||||
case "permission.asked":
|
||||
try {
|
||||
const permission = event.properties
|
||||
const res = await this.connection
|
||||
.requestPermission({
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: permission.tool?.callID ?? permission.id,
|
||||
status: "pending",
|
||||
title: permission.permission,
|
||||
rawInput: permission.metadata,
|
||||
kind: toToolKind(permission.permission),
|
||||
locations: toLocations(permission.permission, permission.metadata),
|
||||
},
|
||||
options,
|
||||
if (this.eventAbort.signal.aborted) return
|
||||
const payload = (event as any)?.payload
|
||||
if (!payload) continue
|
||||
await this.handleEvent(payload as Event).catch((error) => {
|
||||
log.error("failed to handle event", { error, type: payload.type })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleEvent(event: Event) {
|
||||
switch (event.type) {
|
||||
case "permission.asked": {
|
||||
const permission = event.properties
|
||||
const session = this.sessionManager.tryGet(permission.sessionID)
|
||||
if (!session) return
|
||||
|
||||
const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.then(async () => {
|
||||
const directory = session.cwd
|
||||
|
||||
const res = await this.connection
|
||||
.requestPermission({
|
||||
sessionId: permission.sessionID,
|
||||
toolCall: {
|
||||
toolCallId: permission.tool?.callID ?? permission.id,
|
||||
status: "pending",
|
||||
title: permission.permission,
|
||||
rawInput: permission.metadata,
|
||||
kind: toToolKind(permission.permission),
|
||||
locations: toLocations(permission.permission, permission.metadata),
|
||||
},
|
||||
options: this.permissionOptions,
|
||||
})
|
||||
.catch(async (error) => {
|
||||
log.error("failed to request permission from ACP", {
|
||||
error,
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
.catch(async (error) => {
|
||||
log.error("failed to request permission from ACP", {
|
||||
error,
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
await this.config.sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
directory,
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!res) return
|
||||
if (res.outcome.outcome !== "selected") {
|
||||
await this.config.sdk.permission.reply({
|
||||
await this.sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
directory,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
|
||||
const metadata = permission.metadata || {}
|
||||
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
|
||||
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
|
||||
return undefined
|
||||
})
|
||||
|
||||
const content = await Bun.file(filepath).text()
|
||||
const newContent = getNewContent(content, diff)
|
||||
|
||||
if (newContent) {
|
||||
this.connection.writeTextFile({
|
||||
sessionId: sessionId,
|
||||
path: filepath,
|
||||
content: newContent,
|
||||
})
|
||||
}
|
||||
}
|
||||
await this.config.sdk.permission.reply({
|
||||
if (!res) return
|
||||
if (res.outcome.outcome !== "selected") {
|
||||
await this.sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: res.outcome.optionId as "once" | "always" | "reject",
|
||||
reply: "reject",
|
||||
directory,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error("unexpected error when handling permission", { error: err })
|
||||
} finally {
|
||||
break
|
||||
return
|
||||
}
|
||||
|
||||
case "message.part.updated":
|
||||
log.info("message part updated", { event: event.properties })
|
||||
try {
|
||||
const props = event.properties
|
||||
const { part } = props
|
||||
if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
|
||||
const metadata = permission.metadata || {}
|
||||
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
|
||||
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
|
||||
|
||||
const message = await this.config.sdk.session
|
||||
.message(
|
||||
{
|
||||
sessionID: part.sessionID,
|
||||
messageID: part.messageID,
|
||||
directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
log.error("unexpected error when fetching message", { error: err })
|
||||
return undefined
|
||||
const content = await Bun.file(filepath).text()
|
||||
const newContent = getNewContent(content, diff)
|
||||
|
||||
if (newContent) {
|
||||
this.connection.writeTextFile({
|
||||
sessionId: session.id,
|
||||
path: filepath,
|
||||
content: newContent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!message || message.info.role !== "assistant") return
|
||||
await this.sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: res.outcome.optionId as "once" | "always" | "reject",
|
||||
directory,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to handle permission", { error, permissionID: permission.id })
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.permissionQueues.get(permission.sessionID) === next) {
|
||||
this.permissionQueues.delete(permission.sessionID)
|
||||
}
|
||||
})
|
||||
this.permissionQueues.set(permission.sessionID, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "tool") {
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: part.callID,
|
||||
title: part.tool,
|
||||
kind: toToolKind(part.tool),
|
||||
status: "pending",
|
||||
locations: [],
|
||||
rawInput: {},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool pending to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
case "running":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool in_progress to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
case "completed":
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
case "message.part.updated": {
|
||||
log.info("message part updated", { event: event.properties })
|
||||
const props = event.properties
|
||||
const part = props.part
|
||||
const session = this.sessionManager.tryGet(part.sessionID)
|
||||
if (!session) return
|
||||
const sessionId = session.id
|
||||
const directory = session.cwd
|
||||
|
||||
const message = await this.sdk.session
|
||||
.message(
|
||||
{
|
||||
sessionID: part.sessionID,
|
||||
messageID: part.messageID,
|
||||
directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((error) => {
|
||||
log.error("unexpected error when fetching message", { error })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!message || message.info.role !== "assistant") return
|
||||
|
||||
if (part.type === "tool") {
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: part.callID,
|
||||
title: part.tool,
|
||||
kind: toToolKind(part.tool),
|
||||
status: "pending",
|
||||
locations: [],
|
||||
rawInput: {},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool pending to ACP", { error })
|
||||
})
|
||||
return
|
||||
|
||||
case "running":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool in_progress to ACP", { error })
|
||||
})
|
||||
return
|
||||
|
||||
case "completed": {
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (kind === "edit") {
|
||||
const input = part.state.input
|
||||
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText,
|
||||
newText,
|
||||
})
|
||||
}
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
|
||||
if (parsedTodos.success) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "plan",
|
||||
entries: parsedTodos.data.map((todo) => {
|
||||
const status: PlanEntry["status"] =
|
||||
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
|
||||
return {
|
||||
priority: "medium",
|
||||
status,
|
||||
content: todo.content,
|
||||
}
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send session update for todo", { error })
|
||||
})
|
||||
} else {
|
||||
log.error("failed to parse todo output", { error: parsedTodos.error })
|
||||
}
|
||||
}
|
||||
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "completed",
|
||||
kind,
|
||||
content,
|
||||
title: part.state.title,
|
||||
rawInput: part.state.input,
|
||||
rawOutput: {
|
||||
output: part.state.output,
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool completed to ACP", { error })
|
||||
})
|
||||
return
|
||||
}
|
||||
case "error":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "failed",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
rawInput: part.state.input,
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
text: part.state.error,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (kind === "edit") {
|
||||
const input = part.state.input
|
||||
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText,
|
||||
newText,
|
||||
})
|
||||
}
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
|
||||
if (parsedTodos.success) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "plan",
|
||||
entries: parsedTodos.data.map((todo) => {
|
||||
const status: PlanEntry["status"] =
|
||||
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
|
||||
return {
|
||||
priority: "medium",
|
||||
status,
|
||||
content: todo.content,
|
||||
}
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send session update for todo", { error: err })
|
||||
})
|
||||
} else {
|
||||
log.error("failed to parse todo output", { error: parsedTodos.error })
|
||||
}
|
||||
}
|
||||
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "completed",
|
||||
kind,
|
||||
content,
|
||||
title: part.state.title,
|
||||
rawInput: part.state.input,
|
||||
rawOutput: {
|
||||
output: part.state.output,
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool completed to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
case "error":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "failed",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
rawInput: part.state.input,
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.error,
|
||||
},
|
||||
},
|
||||
],
|
||||
rawOutput: {
|
||||
error: part.state.error,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool error to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
}
|
||||
} else if (part.type === "text") {
|
||||
const delta = props.delta
|
||||
if (delta && part.synthetic !== true) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send text to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
} else if (part.type === "reasoning") {
|
||||
const delta = props.delta
|
||||
if (delta) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send reasoning to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
break
|
||||
}
|
||||
],
|
||||
rawOutput: {
|
||||
error: part.state.error,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool error to ACP", { error })
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
const delta = props.delta
|
||||
if (delta && part.ignored !== true) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send text to ACP", { error })
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
const delta = props.delta
|
||||
if (delta) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send reasoning to ACP", { error })
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
|
||||
|
|
@ -409,8 +459,6 @@ export namespace ACP {
|
|||
sessionId,
|
||||
})
|
||||
|
||||
this.setupEventSubscriptions(state)
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
models: load.models,
|
||||
|
|
@ -436,18 +484,16 @@ export namespace ACP {
|
|||
const model = await defaultModel(this.config, directory)
|
||||
|
||||
// Store ACP session state
|
||||
const state = await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
|
||||
await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
|
||||
|
||||
log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
|
||||
|
||||
const mode = await this.loadSessionMode({
|
||||
const result = await this.loadSessionMode({
|
||||
cwd: directory,
|
||||
mcpServers: params.mcpServers,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
this.setupEventSubscriptions(state)
|
||||
|
||||
// Replay session history
|
||||
const messages = await this.sdk.session
|
||||
.messages(
|
||||
|
|
@ -463,12 +509,20 @@ export namespace ACP {
|
|||
return undefined
|
||||
})
|
||||
|
||||
const lastUser = messages?.findLast((m) => m.info.role === "user")?.info
|
||||
if (lastUser?.role === "user") {
|
||||
result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}`
|
||||
if (result.modes.availableModes.some((m) => m.id === lastUser.agent)) {
|
||||
result.modes.currentModeId = lastUser.agent
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages ?? []) {
|
||||
log.debug("replay message", msg)
|
||||
await this.processMessage(msg)
|
||||
}
|
||||
|
||||
return mode
|
||||
return result
|
||||
} catch (e) {
|
||||
const error = MessageV2.fromError(e, {
|
||||
providerID: this.config.defaultModel?.providerID ?? "unknown",
|
||||
|
|
@ -633,7 +687,7 @@ export namespace ACP {
|
|||
break
|
||||
}
|
||||
} else if (part.type === "text") {
|
||||
if (part.text) {
|
||||
if (part.text && !part.ignored) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
|
|
@ -649,6 +703,83 @@ export namespace ACP {
|
|||
log.error("failed to send text to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
} else if (part.type === "file") {
|
||||
// Replay file attachments as appropriate ACP content blocks.
|
||||
// OpenCode stores files internally as { type: "file", url, filename, mime }.
|
||||
// We convert these back to ACP blocks based on the URL scheme and MIME type:
|
||||
// - file:// URLs → resource_link
|
||||
// - data: URLs with image/* → image block
|
||||
// - data: URLs with text/* or application/json → resource with text
|
||||
// - data: URLs with other types → resource with blob
|
||||
const url = part.url
|
||||
const filename = part.filename ?? "file"
|
||||
const mime = part.mime || "application/octet-stream"
|
||||
const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk"
|
||||
|
||||
if (url.startsWith("file://")) {
|
||||
// Local file reference - send as resource_link
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: messageChunk,
|
||||
content: { type: "resource_link", uri: url, name: filename, mimeType: mime },
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send resource_link to ACP", { error: err })
|
||||
})
|
||||
} else if (url.startsWith("data:")) {
|
||||
// Embedded content - parse data URL and send as appropriate block type
|
||||
const base64Match = url.match(/^data:([^;]+);base64,(.*)$/)
|
||||
const dataMime = base64Match?.[1]
|
||||
const base64Data = base64Match?.[2] ?? ""
|
||||
|
||||
const effectiveMime = dataMime || mime
|
||||
|
||||
if (effectiveMime.startsWith("image/")) {
|
||||
// Image - send as image block
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: messageChunk,
|
||||
content: {
|
||||
type: "image",
|
||||
mimeType: effectiveMime,
|
||||
data: base64Data,
|
||||
uri: `file://${filename}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send image to ACP", { error: err })
|
||||
})
|
||||
} else {
|
||||
// Non-image: text types get decoded, binary types stay as blob
|
||||
const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
|
||||
const resource = isText
|
||||
? {
|
||||
uri: `file://${filename}`,
|
||||
mimeType: effectiveMime,
|
||||
text: Buffer.from(base64Data, "base64").toString("utf-8"),
|
||||
}
|
||||
: { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data }
|
||||
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: messageChunk,
|
||||
content: { type: "resource", resource },
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send resource to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
}
|
||||
// URLs that don't match file:// or data: are skipped (unsupported)
|
||||
} else if (part.type === "reasoning") {
|
||||
if (part.text) {
|
||||
await this.connection
|
||||
|
|
@ -847,39 +978,57 @@ export namespace ACP {
|
|||
text: part.text,
|
||||
})
|
||||
break
|
||||
case "image":
|
||||
case "image": {
|
||||
const parsed = parseUri(part.uri ?? "")
|
||||
const filename = parsed.type === "file" ? parsed.filename : "image"
|
||||
if (part.data) {
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: `data:${part.mimeType};base64,${part.data}`,
|
||||
filename: "image",
|
||||
filename,
|
||||
mime: part.mimeType,
|
||||
})
|
||||
} else if (part.uri && part.uri.startsWith("http:")) {
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: part.uri,
|
||||
filename: "image",
|
||||
filename,
|
||||
mime: part.mimeType,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "resource_link":
|
||||
const parsed = parseUri(part.uri)
|
||||
// Use the name from resource_link if available
|
||||
if (part.name && parsed.type === "file") {
|
||||
parsed.filename = part.name
|
||||
}
|
||||
parts.push(parsed)
|
||||
|
||||
break
|
||||
|
||||
case "resource":
|
||||
case "resource": {
|
||||
const resource = part.resource
|
||||
if ("text" in resource) {
|
||||
if ("text" in resource && resource.text) {
|
||||
parts.push({
|
||||
type: "text",
|
||||
text: resource.text,
|
||||
})
|
||||
} else if ("blob" in resource && resource.blob && resource.mimeType) {
|
||||
// Binary resource (PDFs, etc.): store as file part with data URL
|
||||
const parsed = parseUri(resource.uri ?? "")
|
||||
const filename = parsed.type === "file" ? parsed.filename : "file"
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: `data:${resource.mimeType};base64,${resource.blob}`,
|
||||
filename,
|
||||
mime: resource.mimeType,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ export class ACPSessionManager {
|
|||
this.sdk = sdk
|
||||
}
|
||||
|
||||
tryGet(sessionId: string): ACPSessionState | undefined {
|
||||
return this.sessions.get(sessionId)
|
||||
}
|
||||
|
||||
async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise<ACPSessionState> {
|
||||
const session = await this.sdk.session
|
||||
.create(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { Config } from "../config/config"
|
||||
import z from "zod"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { generateObject, type ModelMessage } from "ai"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Truncate } from "../tool/truncation"
|
||||
import { Auth } from "../auth"
|
||||
import { ProviderTransform } from "../provider/transform"
|
||||
|
||||
import PROMPT_GENERATE from "./generate.txt"
|
||||
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||
|
|
@ -276,10 +278,12 @@ export namespace Agent {
|
|||
const defaultModel = input.model ?? (await Provider.defaultModel())
|
||||
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
|
||||
const language = await Provider.getLanguage(model)
|
||||
|
||||
const system = SystemPrompt.header(defaultModel.providerID)
|
||||
system.push(PROMPT_GENERATE)
|
||||
const existing = await list()
|
||||
const result = await generateObject({
|
||||
|
||||
const params = {
|
||||
experimental_telemetry: {
|
||||
isEnabled: cfg.experimental?.openTelemetry,
|
||||
metadata: {
|
||||
|
|
@ -305,7 +309,24 @@ export namespace Agent {
|
|||
whenToUse: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
}),
|
||||
})
|
||||
} satisfies Parameters<typeof generateObject>[0]
|
||||
|
||||
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
|
||||
const result = streamObject({
|
||||
...params,
|
||||
providerOptions: ProviderTransform.providerOptions(model, {
|
||||
instructions: SystemPrompt.instructions(),
|
||||
store: false,
|
||||
}),
|
||||
onError: () => {},
|
||||
})
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") throw part.error
|
||||
}
|
||||
return result.object
|
||||
}
|
||||
|
||||
const result = await generateObject(params)
|
||||
return result.object
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import z from "zod"
|
|||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { readableStreamToText } from "bun"
|
||||
import { createRequire } from "module"
|
||||
|
|
@ -71,7 +72,10 @@ export namespace BunProc {
|
|||
await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2))
|
||||
return result
|
||||
})
|
||||
if (parsed.dependencies[pkg] === version) return mod
|
||||
const dependencies = parsed.dependencies ?? {}
|
||||
if (!parsed.dependencies) parsed.dependencies = dependencies
|
||||
const modExists = await Filesystem.exists(mod)
|
||||
if (dependencies[pkg] === version && modExists) return mod
|
||||
|
||||
const proxied = !!(
|
||||
process.env.HTTP_PROXY ||
|
||||
|
|
|
|||
|
|
@ -70,8 +70,8 @@ export const AgentCommand = cmd({
|
|||
})
|
||||
|
||||
async function getAvailableTools(agent: Agent.Info) {
|
||||
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
|
||||
return ToolRegistry.tools(providerID, agent)
|
||||
const model = agent.model ?? (await Provider.defaultModel())
|
||||
return ToolRegistry.tools(model, agent)
|
||||
}
|
||||
|
||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import * as prompts from "@clack/prompts"
|
|||
import { UI } from "../ui"
|
||||
import { MCP } from "../../mcp"
|
||||
import { McpAuth } from "../../mcp/auth"
|
||||
import { McpOAuthCallback } from "../../mcp/oauth-callback"
|
||||
import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
||||
import { Config } from "../../config/config"
|
||||
import { Instance } from "../../project/instance"
|
||||
|
|
@ -683,10 +682,6 @@ export const McpDebugCommand = cmd({
|
|||
|
||||
// Try to discover OAuth metadata
|
||||
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
||||
|
||||
// Start callback server
|
||||
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
|
||||
|
||||
const authProvider = new McpOAuthProvider(
|
||||
serverName,
|
||||
serverConfig.url,
|
||||
|
|
@ -694,7 +689,6 @@ export const McpDebugCommand = cmd({
|
|||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async () => {},
|
||||
|
|
|
|||
|
|
@ -200,11 +200,6 @@ function App() {
|
|||
renderer.console.onCopySelection = async (text: string) => {
|
||||
if (!text || text.length === 0) return
|
||||
|
||||
const base64 = Buffer.from(text).toString("base64")
|
||||
const osc52 = `\x1b]52;c;${base64}\x07`
|
||||
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
|
||||
// @ts-expect-error writeOut is not in type definitions
|
||||
renderer.writeOut(finalOsc52)
|
||||
await Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
|
@ -293,6 +288,10 @@ function App() {
|
|||
keybind: "session_list",
|
||||
category: "Session",
|
||||
suggested: sync.data.session.length > 0,
|
||||
slash: {
|
||||
name: "sessions",
|
||||
aliases: ["resume", "continue"],
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogSessionList />)
|
||||
},
|
||||
|
|
@ -303,6 +302,10 @@ function App() {
|
|||
value: "session.new",
|
||||
keybind: "session_new",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "new",
|
||||
aliases: ["clear"],
|
||||
},
|
||||
onSelect: () => {
|
||||
const current = promptRef.current
|
||||
// Don't require focus - if there's any text, preserve it
|
||||
|
|
@ -320,26 +323,29 @@ function App() {
|
|||
keybind: "model_list",
|
||||
suggested: true,
|
||||
category: "Agent",
|
||||
slash: {
|
||||
name: "models",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogModel />)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Model cycle",
|
||||
disabled: true,
|
||||
value: "model.cycle_recent",
|
||||
keybind: "model_cycle_recent",
|
||||
category: "Agent",
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
local.model.cycle(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Model cycle reverse",
|
||||
disabled: true,
|
||||
value: "model.cycle_recent_reverse",
|
||||
keybind: "model_cycle_recent_reverse",
|
||||
category: "Agent",
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
local.model.cycle(-1)
|
||||
},
|
||||
|
|
@ -349,6 +355,7 @@ function App() {
|
|||
value: "model.cycle_favorite",
|
||||
keybind: "model_cycle_favorite",
|
||||
category: "Agent",
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
local.model.cycleFavorite(1)
|
||||
},
|
||||
|
|
@ -358,6 +365,7 @@ function App() {
|
|||
value: "model.cycle_favorite_reverse",
|
||||
keybind: "model_cycle_favorite_reverse",
|
||||
category: "Agent",
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
local.model.cycleFavorite(-1)
|
||||
},
|
||||
|
|
@ -367,6 +375,9 @@ function App() {
|
|||
value: "agent.list",
|
||||
keybind: "agent_list",
|
||||
category: "Agent",
|
||||
slash: {
|
||||
name: "agents",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogAgent />)
|
||||
},
|
||||
|
|
@ -375,6 +386,9 @@ function App() {
|
|||
title: "Toggle MCPs",
|
||||
value: "mcp.list",
|
||||
category: "Agent",
|
||||
slash: {
|
||||
name: "mcps",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogMcp />)
|
||||
},
|
||||
|
|
@ -384,7 +398,7 @@ function App() {
|
|||
value: "agent.cycle",
|
||||
keybind: "agent_cycle",
|
||||
category: "Agent",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
local.agent.move(1)
|
||||
},
|
||||
|
|
@ -394,6 +408,7 @@ function App() {
|
|||
value: "variant.cycle",
|
||||
keybind: "variant_cycle",
|
||||
category: "Agent",
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
local.model.variant.cycle()
|
||||
},
|
||||
|
|
@ -403,7 +418,7 @@ function App() {
|
|||
value: "agent.cycle.reverse",
|
||||
keybind: "agent_cycle_reverse",
|
||||
category: "Agent",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
local.agent.move(-1)
|
||||
},
|
||||
|
|
@ -412,6 +427,9 @@ function App() {
|
|||
title: "Connect provider",
|
||||
value: "provider.connect",
|
||||
suggested: !connected(),
|
||||
slash: {
|
||||
name: "connect",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogProviderList />)
|
||||
},
|
||||
|
|
@ -421,6 +439,9 @@ function App() {
|
|||
title: "View status",
|
||||
keybind: "status_view",
|
||||
value: "opencode.status",
|
||||
slash: {
|
||||
name: "status",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogStatus />)
|
||||
},
|
||||
|
|
@ -430,6 +451,9 @@ function App() {
|
|||
title: "Switch theme",
|
||||
value: "theme.switch",
|
||||
keybind: "theme_list",
|
||||
slash: {
|
||||
name: "themes",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogThemeList />)
|
||||
},
|
||||
|
|
@ -447,6 +471,9 @@ function App() {
|
|||
{
|
||||
title: "Help",
|
||||
value: "help.show",
|
||||
slash: {
|
||||
name: "help",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogHelp />)
|
||||
},
|
||||
|
|
@ -473,6 +500,10 @@ function App() {
|
|||
{
|
||||
title: "Exit the app",
|
||||
value: "app.exit",
|
||||
slash: {
|
||||
name: "exit",
|
||||
aliases: ["quit", "q"],
|
||||
},
|
||||
onSelect: () => exit(),
|
||||
category: "System",
|
||||
},
|
||||
|
|
@ -513,6 +544,7 @@ function App() {
|
|||
value: "terminal.suspend",
|
||||
keybind: "terminal_suspend",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
process.once("SIGCONT", () => {
|
||||
renderer.resume()
|
||||
|
|
@ -627,11 +659,6 @@ function App() {
|
|||
}
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (text && text.length > 0) {
|
||||
const base64 = Buffer.from(text).toString("base64")
|
||||
const osc52 = `\x1b]52;c;${base64}\x07`
|
||||
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
|
||||
/* @ts-expect-error */
|
||||
renderer.writeOut(finalOsc52)
|
||||
await Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
|
|
|||
|
|
@ -16,9 +16,17 @@ import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
|
|||
type Context = ReturnType<typeof init>
|
||||
const ctx = createContext<Context>()
|
||||
|
||||
export type CommandOption = DialogSelectOption & {
|
||||
export type Slash = {
|
||||
name: string
|
||||
aliases?: string[]
|
||||
}
|
||||
|
||||
export type CommandOption = DialogSelectOption<string> & {
|
||||
keybind?: keyof KeybindsConfig
|
||||
suggested?: boolean
|
||||
slash?: Slash
|
||||
hidden?: boolean
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function init() {
|
||||
|
|
@ -26,27 +34,35 @@ function init() {
|
|||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
const options = createMemo(() => {
|
||||
|
||||
const entries = createMemo(() => {
|
||||
const all = registrations().flatMap((x) => x())
|
||||
const suggested = all.filter((x) => x.suggested)
|
||||
return [
|
||||
...suggested.map((x) => ({
|
||||
...x,
|
||||
category: "Suggested",
|
||||
value: "suggested." + x.value,
|
||||
})),
|
||||
...all,
|
||||
].map((x) => ({
|
||||
return all.map((x) => ({
|
||||
...x,
|
||||
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
const isEnabled = (option: CommandOption) => option.enabled !== false
|
||||
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
|
||||
|
||||
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
|
||||
const suggestedOptions = createMemo(() =>
|
||||
visibleOptions()
|
||||
.filter((option) => option.suggested)
|
||||
.map((option) => ({
|
||||
...option,
|
||||
value: `suggested:${option.value}`,
|
||||
category: "Suggested",
|
||||
})),
|
||||
)
|
||||
const suspended = () => suspendCount() > 0
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (suspended()) return
|
||||
if (dialog.stack.length > 0) return
|
||||
for (const option of options()) {
|
||||
for (const option of entries()) {
|
||||
if (!isEnabled(option)) continue
|
||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||
evt.preventDefault()
|
||||
option.onSelect?.(dialog)
|
||||
|
|
@ -56,20 +72,33 @@ function init() {
|
|||
})
|
||||
|
||||
const result = {
|
||||
trigger(name: string, source?: "prompt") {
|
||||
for (const option of options()) {
|
||||
trigger(name: string) {
|
||||
for (const option of entries()) {
|
||||
if (option.value === name) {
|
||||
option.onSelect?.(dialog, source)
|
||||
if (!isEnabled(option)) return
|
||||
option.onSelect?.(dialog)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
slashes() {
|
||||
return visibleOptions().flatMap((option) => {
|
||||
const slash = option.slash
|
||||
if (!slash) return []
|
||||
return {
|
||||
display: "/" + slash.name,
|
||||
description: option.description ?? option.title,
|
||||
aliases: slash.aliases?.map((alias) => "/" + alias),
|
||||
onSelect: () => result.trigger(option.value),
|
||||
}
|
||||
})
|
||||
},
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||
},
|
||||
suspended,
|
||||
show() {
|
||||
dialog.replace(() => <DialogCommand options={options()} />)
|
||||
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
|
||||
},
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb)
|
||||
|
|
@ -78,9 +107,6 @@ function init() {
|
|||
setRegistrations((arr) => arr.filter((x) => x !== results))
|
||||
})
|
||||
},
|
||||
get options() {
|
||||
return options()
|
||||
},
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -104,7 +130,7 @@ export function CommandProvider(props: ParentProps) {
|
|||
if (evt.defaultPrevented) return
|
||||
if (keybind.match("command_list", evt)) {
|
||||
evt.preventDefault()
|
||||
dialog.replace(() => <DialogCommand options={value.options} />)
|
||||
value.show()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
|
@ -112,13 +138,11 @@ export function CommandProvider(props: ParentProps) {
|
|||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
function DialogCommand(props: { options: CommandOption[] }) {
|
||||
function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
|
||||
let ref: DialogSelectRef<string>
|
||||
return (
|
||||
<DialogSelect
|
||||
ref={(r) => (ref = r)}
|
||||
title="Commands"
|
||||
options={props.options.filter((x) => !ref?.filter || !x.value.startsWith("suggested."))}
|
||||
/>
|
||||
)
|
||||
const list = () => {
|
||||
if (ref?.filter) return props.options
|
||||
return [...props.suggestedOptions, ...props.options]
|
||||
}
|
||||
return <DialogSelect ref={(r) => (ref = r)} title="Commands" options={list()} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -332,16 +332,15 @@ export function Autocomplete(props: {
|
|||
)
|
||||
})
|
||||
|
||||
const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined))
|
||||
const commands = createMemo((): AutocompleteOption[] => {
|
||||
const results: AutocompleteOption[] = []
|
||||
const s = session()
|
||||
for (const command of sync.data.command) {
|
||||
const results: AutocompleteOption[] = [...command.slashes()]
|
||||
|
||||
for (const serverCommand of sync.data.command) {
|
||||
results.push({
|
||||
display: "/" + command.name + (command.mcp ? " (MCP)" : ""),
|
||||
description: command.description,
|
||||
display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""),
|
||||
description: serverCommand.description,
|
||||
onSelect: () => {
|
||||
const newText = "/" + command.name + " "
|
||||
const newText = "/" + serverCommand.name + " "
|
||||
const cursor = props.input().logicalCursor
|
||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||
props.input().insertText(newText)
|
||||
|
|
@ -349,138 +348,9 @@ export function Autocomplete(props: {
|
|||
},
|
||||
})
|
||||
}
|
||||
if (s) {
|
||||
results.push(
|
||||
{
|
||||
display: "/undo",
|
||||
description: "undo the last message",
|
||||
onSelect: () => {
|
||||
command.trigger("session.undo")
|
||||
},
|
||||
},
|
||||
{
|
||||
display: "/redo",
|
||||
description: "redo the last message",
|
||||
onSelect: () => command.trigger("session.redo"),
|
||||
},
|
||||
{
|
||||
display: "/compact",
|
||||
aliases: ["/summarize"],
|
||||
description: "compact the session",
|
||||
onSelect: () => command.trigger("session.compact"),
|
||||
},
|
||||
{
|
||||
display: "/unshare",
|
||||
disabled: !s.share,
|
||||
description: "unshare a session",
|
||||
onSelect: () => command.trigger("session.unshare"),
|
||||
},
|
||||
{
|
||||
display: "/rename",
|
||||
description: "rename session",
|
||||
onSelect: () => command.trigger("session.rename"),
|
||||
},
|
||||
{
|
||||
display: "/copy",
|
||||
description: "copy session transcript to clipboard",
|
||||
onSelect: () => command.trigger("session.copy"),
|
||||
},
|
||||
{
|
||||
display: "/export",
|
||||
description: "export session transcript to file",
|
||||
onSelect: () => command.trigger("session.export"),
|
||||
},
|
||||
{
|
||||
display: "/timeline",
|
||||
description: "jump to message",
|
||||
onSelect: () => command.trigger("session.timeline"),
|
||||
},
|
||||
{
|
||||
display: "/fork",
|
||||
description: "fork from message",
|
||||
onSelect: () => command.trigger("session.fork"),
|
||||
},
|
||||
{
|
||||
display: "/thinking",
|
||||
description: "toggle thinking visibility",
|
||||
onSelect: () => command.trigger("session.toggle.thinking"),
|
||||
},
|
||||
)
|
||||
if (sync.data.config.share !== "disabled") {
|
||||
results.push({
|
||||
display: "/share",
|
||||
disabled: !!s.share?.url,
|
||||
description: "share a session",
|
||||
onSelect: () => command.trigger("session.share"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
results.push(
|
||||
{
|
||||
display: "/new",
|
||||
aliases: ["/clear"],
|
||||
description: "create a new session",
|
||||
onSelect: () => command.trigger("session.new"),
|
||||
},
|
||||
{
|
||||
display: "/models",
|
||||
description: "list models",
|
||||
onSelect: () => command.trigger("model.list"),
|
||||
},
|
||||
{
|
||||
display: "/agents",
|
||||
description: "list agents",
|
||||
onSelect: () => command.trigger("agent.list"),
|
||||
},
|
||||
{
|
||||
display: "/session",
|
||||
aliases: ["/resume", "/continue"],
|
||||
description: "list sessions",
|
||||
onSelect: () => command.trigger("session.list"),
|
||||
},
|
||||
{
|
||||
display: "/status",
|
||||
description: "show status",
|
||||
onSelect: () => command.trigger("opencode.status"),
|
||||
},
|
||||
{
|
||||
display: "/mcp",
|
||||
description: "toggle MCPs",
|
||||
onSelect: () => command.trigger("mcp.list"),
|
||||
},
|
||||
{
|
||||
display: "/theme",
|
||||
description: "toggle theme",
|
||||
onSelect: () => command.trigger("theme.switch"),
|
||||
},
|
||||
{
|
||||
display: "/editor",
|
||||
description: "open editor",
|
||||
onSelect: () => command.trigger("prompt.editor", "prompt"),
|
||||
},
|
||||
{
|
||||
display: "/connect",
|
||||
description: "connect to a provider",
|
||||
onSelect: () => command.trigger("provider.connect"),
|
||||
},
|
||||
{
|
||||
display: "/help",
|
||||
description: "show help",
|
||||
onSelect: () => command.trigger("help.show"),
|
||||
},
|
||||
{
|
||||
display: "/commands",
|
||||
description: "show all commands",
|
||||
onSelect: () => command.show(),
|
||||
},
|
||||
{
|
||||
display: "/exit",
|
||||
aliases: ["/quit", "/q"],
|
||||
description: "exit the app",
|
||||
onSelect: () => command.trigger("app.exit"),
|
||||
},
|
||||
)
|
||||
results.sort((a, b) => a.display.localeCompare(b.display))
|
||||
|
||||
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
|
||||
if (!max) return results
|
||||
return results.map((item) => ({
|
||||
|
|
@ -494,9 +364,8 @@ export function Autocomplete(props: {
|
|||
const agentsValue = agents()
|
||||
const commandsValue = commands()
|
||||
|
||||
const mixed: AutocompleteOption[] = (
|
||||
const mixed: AutocompleteOption[] =
|
||||
store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
|
||||
).filter((x) => x.disabled !== true)
|
||||
|
||||
const currentFilter = filter()
|
||||
|
||||
|
|
|
|||
|
|
@ -145,9 +145,9 @@ export function Prompt(props: PromptProps) {
|
|||
const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
|
||||
if (msg.agent && isPrimaryAgent) {
|
||||
local.agent.set(msg.agent)
|
||||
if (msg.model) local.model.set(msg.model)
|
||||
if (msg.variant) local.model.variant.set(msg.variant)
|
||||
}
|
||||
if (msg.model) local.model.set(msg.model)
|
||||
if (msg.variant) local.model.variant.set(msg.variant)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ export function Prompt(props: PromptProps) {
|
|||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
category: "Prompt",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
input.extmarks.clear()
|
||||
input.clear()
|
||||
|
|
@ -167,9 +167,9 @@ export function Prompt(props: PromptProps) {
|
|||
{
|
||||
title: "Submit prompt",
|
||||
value: "prompt.submit",
|
||||
disabled: true,
|
||||
keybind: "input_submit",
|
||||
category: "Prompt",
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
if (!input.focused) return
|
||||
submit()
|
||||
|
|
@ -179,9 +179,9 @@ export function Prompt(props: PromptProps) {
|
|||
{
|
||||
title: "Paste",
|
||||
value: "prompt.paste",
|
||||
disabled: true,
|
||||
keybind: "input_paste",
|
||||
category: "Prompt",
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const content = await Clipboard.read()
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
|
|
@ -197,8 +197,9 @@ export function Prompt(props: PromptProps) {
|
|||
title: "Interrupt session",
|
||||
value: "session.interrupt",
|
||||
keybind: "session_interrupt",
|
||||
disabled: status().type === "idle",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
enabled: status().type !== "idle",
|
||||
onSelect: (dialog) => {
|
||||
if (autocomplete.visible) return
|
||||
if (!input.focused) return
|
||||
|
|
@ -229,7 +230,10 @@ export function Prompt(props: PromptProps) {
|
|||
category: "Session",
|
||||
keybind: "editor_open",
|
||||
value: "prompt.editor",
|
||||
onSelect: async (dialog, trigger) => {
|
||||
slash: {
|
||||
name: "editor",
|
||||
},
|
||||
onSelect: async (dialog) => {
|
||||
dialog.clear()
|
||||
|
||||
// replace summarized text parts with the actual text
|
||||
|
|
@ -242,7 +246,7 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
|
||||
|
||||
const value = trigger === "prompt" ? "" : text
|
||||
const value = text
|
||||
const content = await Editor.open({ value, renderer })
|
||||
if (!content) return
|
||||
|
||||
|
|
@ -432,7 +436,7 @@ export function Prompt(props: PromptProps) {
|
|||
title: "Stash prompt",
|
||||
value: "prompt.stash",
|
||||
category: "Prompt",
|
||||
disabled: !store.prompt.input,
|
||||
enabled: !!store.prompt.input,
|
||||
onSelect: (dialog) => {
|
||||
if (!store.prompt.input) return
|
||||
stash.push({
|
||||
|
|
@ -450,7 +454,7 @@ export function Prompt(props: PromptProps) {
|
|||
title: "Stash pop",
|
||||
value: "prompt.stash.pop",
|
||||
category: "Prompt",
|
||||
disabled: stash.list().length === 0,
|
||||
enabled: stash.list().length > 0,
|
||||
onSelect: (dialog) => {
|
||||
const entry = stash.pop()
|
||||
if (entry) {
|
||||
|
|
@ -466,7 +470,7 @@ export function Prompt(props: PromptProps) {
|
|||
title: "Stash list",
|
||||
value: "prompt.stash.list",
|
||||
category: "Prompt",
|
||||
disabled: stash.list().length === 0,
|
||||
enabled: stash.list().length > 0,
|
||||
onSelect: (dialog) => {
|
||||
dialog.replace(() => (
|
||||
<DialogStash
|
||||
|
|
@ -1065,9 +1069,11 @@ export function Prompt(props: PromptProps) {
|
|||
<box gap={2} flexDirection="row">
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
|
||||
</text>
|
||||
<Show when={local.model.variant.list().length > 0}>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
|
||||
</text>
|
||||
|
|
|
|||
|
|
@ -113,8 +113,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
})
|
||||
|
||||
const file = Bun.file(path.join(Global.Path.state, "model.json"))
|
||||
const state = {
|
||||
pending: false,
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!modelStore.ready) {
|
||||
state.pending = true
|
||||
return
|
||||
}
|
||||
state.pending = false
|
||||
Bun.write(
|
||||
file,
|
||||
JSON.stringify({
|
||||
|
|
@ -135,6 +143,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setModelStore("ready", true)
|
||||
if (state.pending) save()
|
||||
})
|
||||
|
||||
const args = useArgs()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export const TuiEvent = {
|
|||
"session.compact",
|
||||
"session.page.up",
|
||||
"session.page.down",
|
||||
"session.line.up",
|
||||
"session.line.down",
|
||||
"session.half.page.up",
|
||||
"session.half.page.down",
|
||||
"session.first",
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import { TodoWriteTool } from "@/tool/todo"
|
|||
import type { GrepTool } from "@/tool/grep"
|
||||
import type { ListTool } from "@/tool/ls"
|
||||
import type { EditTool } from "@/tool/edit"
|
||||
import type { PatchTool } from "@/tool/patch"
|
||||
import type { ApplyPatchTool } from "@/tool/apply_patch"
|
||||
import type { WebFetchTool } from "@/tool/webfetch"
|
||||
import type { TaskTool } from "@/tool/task"
|
||||
import type { QuestionTool } from "@/tool/question"
|
||||
|
|
@ -295,37 +295,39 @@ export function Session() {
|
|||
|
||||
const command = useCommandDialog()
|
||||
command.register(() => [
|
||||
...(sync.data.config.share !== "disabled"
|
||||
? [
|
||||
{
|
||||
title: "Share session",
|
||||
value: "session.share",
|
||||
suggested: route.type === "session",
|
||||
keybind: "session_share" as const,
|
||||
disabled: !!session()?.share?.url,
|
||||
category: "Session",
|
||||
onSelect: async (dialog: any) => {
|
||||
await sdk.client.session
|
||||
.share({
|
||||
sessionID: route.sessionID,
|
||||
})
|
||||
.then((res) =>
|
||||
Clipboard.copy(res.data!.share!.url).catch(() =>
|
||||
toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
|
||||
),
|
||||
)
|
||||
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
|
||||
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: "Share session",
|
||||
value: "session.share",
|
||||
suggested: route.type === "session",
|
||||
keybind: "session_share",
|
||||
category: "Session",
|
||||
enabled: sync.data.config.share !== "disabled" && !session()?.share?.url,
|
||||
slash: {
|
||||
name: "share",
|
||||
},
|
||||
onSelect: async (dialog) => {
|
||||
await sdk.client.session
|
||||
.share({
|
||||
sessionID: route.sessionID,
|
||||
})
|
||||
.then((res) =>
|
||||
Clipboard.copy(res.data!.share!.url).catch(() =>
|
||||
toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
|
||||
),
|
||||
)
|
||||
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
|
||||
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Rename session",
|
||||
value: "session.rename",
|
||||
keybind: "session_rename",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "rename",
|
||||
},
|
||||
onSelect: (dialog) => {
|
||||
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
|
||||
},
|
||||
|
|
@ -335,6 +337,9 @@ export function Session() {
|
|||
value: "session.timeline",
|
||||
keybind: "session_timeline",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "timeline",
|
||||
},
|
||||
onSelect: (dialog) => {
|
||||
dialog.replace(() => (
|
||||
<DialogTimeline
|
||||
|
|
@ -355,6 +360,9 @@ export function Session() {
|
|||
value: "session.fork",
|
||||
keybind: "session_fork",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "fork",
|
||||
},
|
||||
onSelect: (dialog) => {
|
||||
dialog.replace(() => (
|
||||
<DialogForkFromTimeline
|
||||
|
|
@ -374,6 +382,10 @@ export function Session() {
|
|||
value: "session.compact",
|
||||
keybind: "session_compact",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "compact",
|
||||
aliases: ["summarize"],
|
||||
},
|
||||
onSelect: (dialog) => {
|
||||
const selectedModel = local.model.current()
|
||||
if (!selectedModel) {
|
||||
|
|
@ -396,8 +408,11 @@ export function Session() {
|
|||
title: "Unshare session",
|
||||
value: "session.unshare",
|
||||
keybind: "session_unshare",
|
||||
disabled: !session()?.share?.url,
|
||||
category: "Session",
|
||||
enabled: !!session()?.share?.url,
|
||||
slash: {
|
||||
name: "unshare",
|
||||
},
|
||||
onSelect: async (dialog) => {
|
||||
await sdk.client.session
|
||||
.unshare({
|
||||
|
|
@ -413,6 +428,9 @@ export function Session() {
|
|||
value: "session.undo",
|
||||
keybind: "messages_undo",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "undo",
|
||||
},
|
||||
onSelect: async (dialog) => {
|
||||
const status = sync.data.session_status?.[route.sessionID]
|
||||
if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
|
||||
|
|
@ -447,8 +465,11 @@ export function Session() {
|
|||
title: "Redo",
|
||||
value: "session.redo",
|
||||
keybind: "messages_redo",
|
||||
disabled: !session()?.revert?.messageID,
|
||||
category: "Session",
|
||||
enabled: !!session()?.revert?.messageID,
|
||||
slash: {
|
||||
name: "redo",
|
||||
},
|
||||
onSelect: (dialog) => {
|
||||
dialog.clear()
|
||||
const messageID = session()?.revert?.messageID
|
||||
|
|
@ -495,6 +516,10 @@ export function Session() {
|
|||
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
|
||||
value: "session.toggle.timestamps",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "timestamps",
|
||||
aliases: ["toggle-timestamps"],
|
||||
},
|
||||
onSelect: (dialog) => {
|
||||
setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
|
||||
dialog.clear()
|
||||
|
|
@ -504,6 +529,10 @@ export function Session() {
|
|||
title: showThinking() ? "Hide thinking" : "Show thinking",
|
||||
value: "session.toggle.thinking",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "thinking",
|
||||
aliases: ["toggle-thinking"],
|
||||
},
|
||||
onSelect: (dialog) => {
|
||||
setShowThinking((prev) => !prev)
|
||||
dialog.clear()
|
||||
|
|
@ -513,6 +542,9 @@ export function Session() {
|
|||
title: "Toggle diff wrapping",
|
||||
value: "session.toggle.diffwrap",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "diffwrap",
|
||||
},
|
||||
onSelect: (dialog) => {
|
||||
setDiffWrapMode((prev) => (prev === "word" ? "none" : "word"))
|
||||
dialog.clear()
|
||||
|
|
@ -552,7 +584,7 @@ export function Session() {
|
|||
value: "session.page.up",
|
||||
keybind: "messages_page_up",
|
||||
category: "Session",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
scroll.scrollBy(-scroll.height / 2)
|
||||
dialog.clear()
|
||||
|
|
@ -563,18 +595,40 @@ export function Session() {
|
|||
value: "session.page.down",
|
||||
keybind: "messages_page_down",
|
||||
category: "Session",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
scroll.scrollBy(scroll.height / 2)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Line up",
|
||||
value: "session.line.up",
|
||||
keybind: "messages_line_up",
|
||||
category: "Session",
|
||||
disabled: true,
|
||||
onSelect: (dialog) => {
|
||||
scroll.scrollBy(-1)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Line down",
|
||||
value: "session.line.down",
|
||||
keybind: "messages_line_down",
|
||||
category: "Session",
|
||||
disabled: true,
|
||||
onSelect: (dialog) => {
|
||||
scroll.scrollBy(1)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Half page up",
|
||||
value: "session.half.page.up",
|
||||
keybind: "messages_half_page_up",
|
||||
category: "Session",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
scroll.scrollBy(-scroll.height / 4)
|
||||
dialog.clear()
|
||||
|
|
@ -585,7 +639,7 @@ export function Session() {
|
|||
value: "session.half.page.down",
|
||||
keybind: "messages_half_page_down",
|
||||
category: "Session",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
scroll.scrollBy(scroll.height / 4)
|
||||
dialog.clear()
|
||||
|
|
@ -596,7 +650,7 @@ export function Session() {
|
|||
value: "session.first",
|
||||
keybind: "messages_first",
|
||||
category: "Session",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
scroll.scrollTo(0)
|
||||
dialog.clear()
|
||||
|
|
@ -607,7 +661,7 @@ export function Session() {
|
|||
value: "session.last",
|
||||
keybind: "messages_last",
|
||||
category: "Session",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
dialog.clear()
|
||||
|
|
@ -618,6 +672,7 @@ export function Session() {
|
|||
value: "session.messages_last_user",
|
||||
keybind: "messages_last_user",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
const messages = sync.data.message[route.sessionID]
|
||||
if (!messages || !messages.length) return
|
||||
|
|
@ -649,7 +704,7 @@ export function Session() {
|
|||
value: "session.message.next",
|
||||
keybind: "messages_next",
|
||||
category: "Session",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: (dialog) => scrollToMessage("next", dialog),
|
||||
},
|
||||
{
|
||||
|
|
@ -657,7 +712,7 @@ export function Session() {
|
|||
value: "session.message.previous",
|
||||
keybind: "messages_previous",
|
||||
category: "Session",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: (dialog) => scrollToMessage("prev", dialog),
|
||||
},
|
||||
{
|
||||
|
|
@ -697,11 +752,6 @@ export function Session() {
|
|||
return
|
||||
}
|
||||
|
||||
const base64 = Buffer.from(text).toString("base64")
|
||||
const osc52 = `\x1b]52;c;${base64}\x07`
|
||||
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
|
||||
/* @ts-expect-error */
|
||||
renderer.writeOut(finalOsc52)
|
||||
Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
|
||||
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
|
||||
|
|
@ -711,8 +761,10 @@ export function Session() {
|
|||
{
|
||||
title: "Copy session transcript",
|
||||
value: "session.copy",
|
||||
keybind: "session_copy",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "copy",
|
||||
},
|
||||
onSelect: async (dialog) => {
|
||||
try {
|
||||
const sessionData = session()
|
||||
|
|
@ -740,6 +792,9 @@ export function Session() {
|
|||
value: "session.export",
|
||||
keybind: "session_export",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "export",
|
||||
},
|
||||
onSelect: async (dialog) => {
|
||||
try {
|
||||
const sessionData = session()
|
||||
|
|
@ -798,7 +853,7 @@ export function Session() {
|
|||
value: "session.child.next",
|
||||
keybind: "session_child_cycle",
|
||||
category: "Session",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
moveChild(1)
|
||||
dialog.clear()
|
||||
|
|
@ -809,7 +864,7 @@ export function Session() {
|
|||
value: "session.child.previous",
|
||||
keybind: "session_child_cycle_reverse",
|
||||
category: "Session",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
moveChild(-1)
|
||||
dialog.clear()
|
||||
|
|
@ -820,7 +875,7 @@ export function Session() {
|
|||
value: "session.parent",
|
||||
keybind: "session_parent",
|
||||
category: "Session",
|
||||
disabled: true,
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
const parentID = session()?.parentID
|
||||
if (parentID) {
|
||||
|
|
@ -1390,8 +1445,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
|
|||
<Match when={props.part.tool === "task"}>
|
||||
<Task {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "patch"}>
|
||||
<Patch {...toolprops} />
|
||||
<Match when={props.part.tool === "apply_patch"}>
|
||||
<ApplyPatch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "todowrite"}>
|
||||
<TodoWrite {...toolprops} />
|
||||
|
|
@ -1840,20 +1895,74 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
|||
)
|
||||
}
|
||||
|
||||
function Patch(props: ToolProps<typeof PatchTool>) {
|
||||
const { theme } = useTheme()
|
||||
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||
const ctx = use()
|
||||
const { theme, syntax } = useTheme()
|
||||
|
||||
const files = createMemo(() => props.metadata.files ?? [])
|
||||
|
||||
const view = createMemo(() => {
|
||||
const diffStyle = ctx.sync.data.config.tui?.diff_style
|
||||
if (diffStyle === "stacked") return "unified"
|
||||
return ctx.width > 120 ? "split" : "unified"
|
||||
})
|
||||
|
||||
function Diff(p: { diff: string; filePath: string }) {
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
diff={p.diff}
|
||||
view={view()}
|
||||
filetype={filetype(p.filePath)}
|
||||
syntaxStyle={syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode={ctx.diffWrapMode()}
|
||||
fg={theme.text}
|
||||
addedBg={theme.diffAddedBg}
|
||||
removedBg={theme.diffRemovedBg}
|
||||
contextBg={theme.diffContextBg}
|
||||
addedSignColor={theme.diffHighlightAdded}
|
||||
removedSignColor={theme.diffHighlightRemoved}
|
||||
lineNumberFg={theme.diffLineNumber}
|
||||
lineNumberBg={theme.diffContextBg}
|
||||
addedLineNumberBg={theme.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
|
||||
if (file.type === "delete") return "# Deleted " + file.relativePath
|
||||
if (file.type === "add") return "# Created " + file.relativePath
|
||||
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
|
||||
return "← Patched " + file.relativePath
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.output !== undefined}>
|
||||
<BlockTool title="# Patch" part={props.part}>
|
||||
<box>
|
||||
<text fg={theme.text}>{props.output?.trim()}</text>
|
||||
</box>
|
||||
</BlockTool>
|
||||
<Match when={files().length > 0}>
|
||||
<For each={files()}>
|
||||
{(file) => (
|
||||
<BlockTool title={title(file)} part={props.part}>
|
||||
<Show
|
||||
when={file.type !== "delete"}
|
||||
fallback={
|
||||
<text fg={theme.diffRemoved}>
|
||||
-{file.deletions} line{file.deletions !== 1 ? "s" : ""}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Diff diff={file.diff} filePath={file.filePath} />
|
||||
</Show>
|
||||
</BlockTool>
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
|
||||
Patch
|
||||
<InlineTool icon="%" pending="Preparing apply_patch..." complete={false} part={props.part}>
|
||||
apply_patch
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
|
|||
|
|
@ -280,6 +280,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||
reply: "reject",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
|
|
@ -456,6 +457,11 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
|
||||
onMouseOver={() => setStore("selected", option)}
|
||||
onMouseUp={() => {
|
||||
setStore("selected", option)
|
||||
props.onSelect(option)
|
||||
}}
|
||||
>
|
||||
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
|
||||
{props.options[option]}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export interface DialogSelectOption<T = any> {
|
|||
disabled?: boolean
|
||||
bg?: RGBA
|
||||
gutter?: JSX.Element
|
||||
onSelect?: (ctx: DialogContext, trigger?: "prompt") => void
|
||||
onSelect?: (ctx: DialogContext) => void
|
||||
}
|
||||
|
||||
export type DialogSelectRef<T> = {
|
||||
|
|
@ -157,26 +157,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
|
||||
const keybind = useKeybind()
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
move(-1)
|
||||
}
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
move(1)
|
||||
}
|
||||
if (evt.name === "pageup") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
move(-10)
|
||||
}
|
||||
if (evt.name === "pagedown") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
move(10)
|
||||
}
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
|
||||
if (evt.name === "pageup") move(-10)
|
||||
if (evt.name === "pagedown") move(10)
|
||||
if (evt.name === "home") moveTo(0)
|
||||
if (evt.name === "end") moveTo(flat().length - 1)
|
||||
if (evt.name === "return") {
|
||||
const option = selected()
|
||||
if (option) {
|
||||
|
|
|
|||
|
|
@ -141,11 +141,6 @@ export function DialogProvider(props: ParentProps) {
|
|||
onMouseUp={async () => {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (text && text.length > 0) {
|
||||
const base64 = Buffer.from(text).toString("base64")
|
||||
const osc52 = `\x1b]52;c;${base64}\x07`
|
||||
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
|
||||
/* @ts-expect-error */
|
||||
renderer.writeOut(finalOsc52)
|
||||
await Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,21 @@ import { lazy } from "../../../../util/lazy.js"
|
|||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
|
||||
/**
|
||||
* Writes text to clipboard via OSC 52 escape sequence.
|
||||
* This allows clipboard operations to work over SSH by having
|
||||
* the terminal emulator handle the clipboard locally.
|
||||
*/
|
||||
function writeOsc52(text: string): void {
|
||||
if (!process.stdout.isTTY) return
|
||||
const base64 = Buffer.from(text).toString("base64")
|
||||
const osc52 = `\x1b]52;c;${base64}\x07`
|
||||
// tmux and screen require DCS passthrough wrapping
|
||||
const passthrough = process.env["TMUX"] || process.env["STY"]
|
||||
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
|
||||
process.stdout.write(sequence)
|
||||
}
|
||||
|
||||
export namespace Clipboard {
|
||||
export interface Content {
|
||||
data: string
|
||||
|
|
@ -110,9 +125,25 @@ export namespace Clipboard {
|
|||
if (os === "win32") {
|
||||
console.log("clipboard: using powershell")
|
||||
return async (text: string) => {
|
||||
// need to escape backticks because powershell uses them as escape code
|
||||
const escaped = text.replace(/"/g, '""').replace(/`/g, "``")
|
||||
await $`powershell -NonInteractive -NoProfile -Command "Set-Clipboard -Value \"${escaped}\""`.nothrow().quiet()
|
||||
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
||||
],
|
||||
{
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +154,7 @@ export namespace Clipboard {
|
|||
})
|
||||
|
||||
export async function copy(text: string): Promise<void> {
|
||||
writeOsc52(text)
|
||||
await getCopyMethod()(text)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,8 @@ async function showRemovalSummary(targets: RemovalTargets, method: Installation.
|
|||
bun: "bun remove -g opencode-ai",
|
||||
yarn: "yarn global remove opencode-ai",
|
||||
brew: "brew uninstall opencode",
|
||||
choco: "choco uninstall opencode",
|
||||
scoop: "scoop uninstall opencode",
|
||||
}
|
||||
prompts.log.info(` ✓ Package: ${cmds[method] || method}`)
|
||||
}
|
||||
|
|
@ -182,16 +184,27 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
|
|||
bun: ["bun", "remove", "-g", "opencode-ai"],
|
||||
yarn: ["yarn", "global", "remove", "opencode-ai"],
|
||||
brew: ["brew", "uninstall", "opencode"],
|
||||
choco: ["choco", "uninstall", "opencode"],
|
||||
scoop: ["scoop", "uninstall", "opencode"],
|
||||
}
|
||||
|
||||
const cmd = cmds[method]
|
||||
if (cmd) {
|
||||
spinner.start(`Running ${cmd.join(" ")}...`)
|
||||
const result = await $`${cmd}`.quiet().nothrow()
|
||||
const result =
|
||||
method === "choco"
|
||||
? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow()
|
||||
: await $`${cmd}`.quiet().nothrow()
|
||||
if (result.exitCode !== 0) {
|
||||
spinner.stop(`Package manager uninstall failed`, 1)
|
||||
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)
|
||||
errors.push(`Package manager: exit code ${result.exitCode}`)
|
||||
spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1)
|
||||
if (
|
||||
method === "choco" &&
|
||||
result.stdout.toString("utf8").includes("not running from an elevated command shell")
|
||||
) {
|
||||
prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`)
|
||||
} else {
|
||||
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)
|
||||
}
|
||||
} else {
|
||||
spinner.stop("Package removed")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,11 @@ export const WebCommand = cmd({
|
|||
}
|
||||
|
||||
if (opts.mdns) {
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local")
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`opencode.local:${server.port}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Open localhost in browser
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ Based on the input provided, determine which type of review to perform:
|
|||
1. **No arguments (default)**: Review all uncommitted changes
|
||||
- Run: `git diff` for unstaged changes
|
||||
- Run: `git diff --cached` for staged changes
|
||||
- Run: `git status --short` to identify untracked (net new) files
|
||||
|
||||
2. **Commit hash** (40-char SHA or short hash): Review that specific commit
|
||||
- Run: `git show $ARGUMENTS`
|
||||
|
|
@ -33,6 +34,7 @@ Use best judgement when processing input.
|
|||
**Diffs alone are not enough.** After getting the diff, read the entire file(s) being modified to understand the full context. Code that looks wrong in isolation may be correct given surrounding logic—and vice versa.
|
||||
|
||||
- Use the diff to identify which files changed
|
||||
- Use `git status --short` to identify untracked files, then read their full contents
|
||||
- Read the full file to understand existing patterns, control flow, and error handling
|
||||
- Check for existing style guide or conventions files (CONVENTIONS.md, AGENTS.md, .editorconfig, etc.)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { Installation } from "@/installation"
|
|||
import { ConfigMarkdown } from "./markdown"
|
||||
import { existsSync } from "fs"
|
||||
import { Bus } from "@/bus"
|
||||
import { Session } from "@/session"
|
||||
|
||||
export namespace Config {
|
||||
const log = Log.create({ service: "config" })
|
||||
|
|
@ -233,10 +232,11 @@ export namespace Config {
|
|||
dot: true,
|
||||
cwd: dir,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item).catch((err) => {
|
||||
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse command ${item}`
|
||||
const { Session } = await import("@/session")
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load command", { command: item, err })
|
||||
return undefined
|
||||
|
|
@ -272,10 +272,11 @@ export namespace Config {
|
|||
dot: true,
|
||||
cwd: dir,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item).catch((err) => {
|
||||
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse agent ${item}`
|
||||
const { Session } = await import("@/session")
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load agent", { agent: item, err })
|
||||
return undefined
|
||||
|
|
@ -310,10 +311,11 @@ export namespace Config {
|
|||
dot: true,
|
||||
cwd: dir,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item).catch((err) => {
|
||||
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse mode ${item}`
|
||||
const { Session } = await import("@/session")
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load mode", { mode: item, err })
|
||||
return undefined
|
||||
|
|
@ -433,10 +435,6 @@ export namespace Config {
|
|||
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
|
||||
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
|
||||
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
|
||||
redirectUri: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
|
|
@ -653,8 +651,14 @@ export namespace Config {
|
|||
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
|
||||
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
|
||||
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
|
||||
messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"),
|
||||
messages_page_down: z.string().optional().default("pagedown").describe("Scroll messages down by one page"),
|
||||
messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"),
|
||||
messages_page_down: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("pagedown,ctrl+alt+f")
|
||||
.describe("Scroll messages down by one page"),
|
||||
messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"),
|
||||
messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"),
|
||||
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
|
||||
messages_half_page_down: z
|
||||
.string()
|
||||
|
|
@ -942,7 +946,7 @@ export namespace Config {
|
|||
})
|
||||
.catchall(Agent)
|
||||
.optional()
|
||||
.describe("Agent configuration, see https://opencode.ai/docs/agent"),
|
||||
.describe("Agent configuration, see https://opencode.ai/docs/agents"),
|
||||
provider: z
|
||||
.record(z.string(), Provider)
|
||||
.optional()
|
||||
|
|
@ -1117,6 +1121,7 @@ export namespace Config {
|
|||
}
|
||||
|
||||
async function load(text: string, configFilepath: string) {
|
||||
const original = text
|
||||
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
})
|
||||
|
|
@ -1186,7 +1191,9 @@ export namespace Config {
|
|||
if (parsed.success) {
|
||||
if (!parsed.data.$schema) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)).catch(() => {})
|
||||
// Write the $schema to the original text to preserve variables like {env:VAR}
|
||||
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
||||
await Bun.write(configFilepath, updated).catch(() => {})
|
||||
}
|
||||
const data = parsed.data
|
||||
if (data.plugin) {
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ export namespace Installation {
|
|||
cmd = $`echo Y | choco upgrade opencode --version=${target}`
|
||||
break
|
||||
case "scoop":
|
||||
cmd = $`scoop install extras/opencode@${target}`
|
||||
cmd = $`scoop install opencode@${target}`
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`)
|
||||
|
|
@ -226,7 +226,7 @@ export namespace Installation {
|
|||
}
|
||||
|
||||
if (detectedMethod === "scoop") {
|
||||
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Extras/master/bucket/opencode.json", {
|
||||
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", {
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
.then((res) => {
|
||||
|
|
|
|||
|
|
@ -1157,10 +1157,24 @@ export namespace LSPServer {
|
|||
await fs.mkdir(distPath, { recursive: true })
|
||||
const releaseURL =
|
||||
"https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz"
|
||||
const archivePath = path.join(distPath, "release.tar.gz")
|
||||
await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow()
|
||||
await $`tar -xzf ${archivePath}`.cwd(distPath).quiet().nothrow()
|
||||
await fs.rm(archivePath, { force: true })
|
||||
const archiveName = "release.tar.gz"
|
||||
|
||||
log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath })
|
||||
const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow()
|
||||
if (curlResult.exitCode !== 0) {
|
||||
log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() })
|
||||
return
|
||||
}
|
||||
|
||||
log.info("Extracting JDTLS archive")
|
||||
const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow()
|
||||
if (tarResult.exitCode !== 0) {
|
||||
log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() })
|
||||
return
|
||||
}
|
||||
|
||||
await fs.rm(path.join(distPath, archiveName), { force: true })
|
||||
log.info("JDTLS download and extraction completed")
|
||||
}
|
||||
const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar`
|
||||
.cwd(launcherDir)
|
||||
|
|
|
|||
|
|
@ -308,8 +308,6 @@ export namespace MCP {
|
|||
let authProvider: McpOAuthProvider | undefined
|
||||
|
||||
if (!oauthDisabled) {
|
||||
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
|
||||
|
||||
authProvider = new McpOAuthProvider(
|
||||
key,
|
||||
mcp.url,
|
||||
|
|
@ -317,7 +315,6 @@ export namespace MCP {
|
|||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async (url) => {
|
||||
|
|
@ -347,7 +344,6 @@ export namespace MCP {
|
|||
|
||||
let lastError: Error | undefined
|
||||
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
||||
|
||||
for (const { name, transport } of transports) {
|
||||
try {
|
||||
const client = new Client({
|
||||
|
|
@ -574,8 +570,7 @@ export namespace MCP {
|
|||
|
||||
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
|
||||
// Only include tools from connected MCPs (skip disabled ones)
|
||||
const clientStatus = s.status[clientName]?.status
|
||||
if (clientStatus !== "connected") {
|
||||
if (s.status[clientName]?.status !== "connected") {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -725,10 +720,8 @@ export namespace MCP {
|
|||
throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
||||
}
|
||||
|
||||
// OAuth config is optional - if not provided, we'll use auto-discovery
|
||||
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
||||
|
||||
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
|
||||
// Start the callback server
|
||||
await McpOAuthCallback.ensureRunning()
|
||||
|
||||
// Generate and store a cryptographically secure state parameter BEFORE creating the provider
|
||||
// The SDK will call provider.state() to read this value
|
||||
|
|
@ -738,6 +731,8 @@ export namespace MCP {
|
|||
await McpAuth.updateOAuthState(mcpName, oauthState)
|
||||
|
||||
// Create a new auth provider for this flow
|
||||
// OAuth config is optional - if not provided, we'll use auto-discovery
|
||||
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
||||
let capturedUrl: URL | undefined
|
||||
const authProvider = new McpOAuthProvider(
|
||||
mcpName,
|
||||
|
|
@ -746,7 +741,6 @@ export namespace MCP {
|
|||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async (url) => {
|
||||
|
|
@ -775,7 +769,6 @@ export namespace MCP {
|
|||
pendingOAuthTransports.set(mcpName, transport)
|
||||
return { authorizationUrl: capturedUrl.toString() }
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
@ -785,9 +778,9 @@ export namespace MCP {
|
|||
* Opens the browser and waits for callback.
|
||||
*/
|
||||
export async function authenticate(mcpName: string): Promise<Status> {
|
||||
const result = await startAuth(mcpName)
|
||||
const { authorizationUrl } = await startAuth(mcpName)
|
||||
|
||||
if (!result.authorizationUrl) {
|
||||
if (!authorizationUrl) {
|
||||
// Already authenticated
|
||||
const s = await state()
|
||||
return s.status[mcpName] ?? { status: "connected" }
|
||||
|
|
@ -801,9 +794,9 @@ export namespace MCP {
|
|||
|
||||
// The SDK has already added the state parameter to the authorization URL
|
||||
// We just need to open the browser
|
||||
log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: oauthState })
|
||||
log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
|
||||
try {
|
||||
const subprocess = await open(result.authorizationUrl)
|
||||
const subprocess = await open(authorizationUrl)
|
||||
// The open package spawns a detached process and returns immediately.
|
||||
// We need to listen for errors which fire asynchronously:
|
||||
// - "error" event: command not found (ENOENT)
|
||||
|
|
@ -826,7 +819,7 @@ export namespace MCP {
|
|||
// Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers)
|
||||
// Emit event so CLI can display the URL for manual opening
|
||||
log.warn("failed to open browser, user must open URL manually", { mcpName, error })
|
||||
Bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl })
|
||||
Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })
|
||||
}
|
||||
|
||||
// Wait for callback using the OAuth state parameter
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
import { Log } from "../util/log"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
|
||||
const log = Log.create({ service: "mcp.oauth-callback" })
|
||||
|
||||
// Current callback server configuration (may differ from defaults if custom redirectUri is used)
|
||||
let currentPort = OAUTH_CALLBACK_PORT
|
||||
let currentPath = OAUTH_CALLBACK_PATH
|
||||
|
||||
const HTML_SUCCESS = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
|
|
@ -60,33 +56,21 @@ export namespace McpOAuthCallback {
|
|||
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export async function ensureRunning(redirectUri?: string): Promise<void> {
|
||||
// Parse the redirect URI to get port and path (uses defaults if not provided)
|
||||
const { port, path } = parseRedirectUri(redirectUri)
|
||||
|
||||
// If server is running on a different port/path, stop it first
|
||||
if (server && (currentPort !== port || currentPath !== path)) {
|
||||
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
|
||||
await stop()
|
||||
}
|
||||
|
||||
export async function ensureRunning(): Promise<void> {
|
||||
if (server) return
|
||||
|
||||
const running = await isPortInUse(port)
|
||||
const running = await isPortInUse()
|
||||
if (running) {
|
||||
log.info("oauth callback server already running on another instance", { port })
|
||||
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
|
||||
return
|
||||
}
|
||||
|
||||
currentPort = port
|
||||
currentPath = path
|
||||
|
||||
server = Bun.serve({
|
||||
port: currentPort,
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname !== currentPath) {
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +133,7 @@ export namespace McpOAuthCallback {
|
|||
},
|
||||
})
|
||||
|
||||
log.info("oauth callback server started", { port: currentPort, path: currentPath })
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
}
|
||||
|
||||
export function waitForCallback(oauthState: string): Promise<string> {
|
||||
|
|
@ -174,11 +158,11 @@ export namespace McpOAuthCallback {
|
|||
}
|
||||
}
|
||||
|
||||
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
|
||||
export async function isPortInUse(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
Bun.connect({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
socket: {
|
||||
open(socket) {
|
||||
socket.end()
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export interface McpOAuthConfig {
|
|||
clientId?: string
|
||||
clientSecret?: string
|
||||
scope?: string
|
||||
redirectUri?: string
|
||||
}
|
||||
|
||||
export interface McpOAuthCallbacks {
|
||||
|
|
@ -33,10 +32,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||
) {}
|
||||
|
||||
get redirectUrl(): string {
|
||||
// Use configured redirectUri if provided, otherwise use OpenCode defaults
|
||||
if (this.config.redirectUri) {
|
||||
return this.config.redirectUri
|
||||
}
|
||||
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
|
||||
}
|
||||
|
||||
|
|
@ -157,22 +152,3 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||
}
|
||||
|
||||
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
|
||||
|
||||
/**
|
||||
* Parse a redirect URI to extract port and path for the callback server.
|
||||
* Returns defaults if the URI can't be parsed.
|
||||
*/
|
||||
export function parseRedirectUri(redirectUri?: string): { port: number; path: string } {
|
||||
if (!redirectUri) {
|
||||
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(redirectUri)
|
||||
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80
|
||||
const path = url.pathname || OAUTH_CALLBACK_PATH
|
||||
return { port, path }
|
||||
} catch {
|
||||
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { readFileSync } from "fs"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace Patch {
|
||||
|
|
@ -177,8 +178,18 @@ export namespace Patch {
|
|||
return { content, nextIdx: i }
|
||||
}
|
||||
|
||||
function stripHeredoc(input: string): string {
|
||||
// Match heredoc patterns like: cat <<'EOF'\n...\nEOF or <<EOF\n...\nEOF
|
||||
const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
|
||||
if (heredocMatch) {
|
||||
return heredocMatch[2]
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
export function parsePatch(patchText: string): { hunks: Hunk[] } {
|
||||
const lines = patchText.split("\n")
|
||||
const cleaned = stripHeredoc(patchText.trim())
|
||||
const lines = cleaned.split("\n")
|
||||
const hunks: Hunk[] = []
|
||||
let i = 0
|
||||
|
||||
|
|
@ -301,7 +312,7 @@ export namespace Patch {
|
|||
// Read original file content
|
||||
let originalContent: string
|
||||
try {
|
||||
originalContent = require("fs").readFileSync(filePath, "utf-8")
|
||||
originalContent = readFileSync(filePath, "utf-8")
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read file ${filePath}: ${error}`)
|
||||
}
|
||||
|
|
@ -363,7 +374,7 @@ export namespace Patch {
|
|||
// Try to match old lines in the file
|
||||
let pattern = chunk.old_lines
|
||||
let newSlice = chunk.new_lines
|
||||
let found = seekSequence(originalLines, pattern, lineIndex)
|
||||
let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
|
||||
|
||||
// Retry without trailing empty line if not found
|
||||
if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
|
||||
|
|
@ -371,7 +382,7 @@ export namespace Patch {
|
|||
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
|
||||
newSlice = newSlice.slice(0, -1)
|
||||
}
|
||||
found = seekSequence(originalLines, pattern, lineIndex)
|
||||
found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
|
||||
}
|
||||
|
||||
if (found !== -1) {
|
||||
|
|
@ -407,28 +418,75 @@ export namespace Patch {
|
|||
return result
|
||||
}
|
||||
|
||||
function seekSequence(lines: string[], pattern: string[], startIndex: number): number {
|
||||
if (pattern.length === 0) return -1
|
||||
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
|
||||
function normalizeUnicode(str: string): string {
|
||||
return str
|
||||
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
|
||||
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
|
||||
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
|
||||
.replace(/\u2026/g, "...") // ellipsis
|
||||
.replace(/\u00A0/g, " ") // non-breaking space
|
||||
}
|
||||
|
||||
// Simple substring search implementation
|
||||
type Comparator = (a: string, b: string) => boolean
|
||||
|
||||
function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number {
|
||||
// If EOF anchor, try matching from end of file first
|
||||
if (eof) {
|
||||
const fromEnd = lines.length - pattern.length
|
||||
if (fromEnd >= startIndex) {
|
||||
let matches = true
|
||||
for (let j = 0; j < pattern.length; j++) {
|
||||
if (!compare(lines[fromEnd + j], pattern[j])) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (matches) return fromEnd
|
||||
}
|
||||
}
|
||||
|
||||
// Forward search from startIndex
|
||||
for (let i = startIndex; i <= lines.length - pattern.length; i++) {
|
||||
let matches = true
|
||||
|
||||
for (let j = 0; j < pattern.length; j++) {
|
||||
if (lines[i + j] !== pattern[j]) {
|
||||
if (!compare(lines[i + j], pattern[j])) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
return i
|
||||
}
|
||||
if (matches) return i
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number {
|
||||
if (pattern.length === 0) return -1
|
||||
|
||||
// Pass 1: exact match
|
||||
const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof)
|
||||
if (exact !== -1) return exact
|
||||
|
||||
// Pass 2: rstrip (trim trailing whitespace)
|
||||
const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof)
|
||||
if (rstrip !== -1) return rstrip
|
||||
|
||||
// Pass 3: trim (both ends)
|
||||
const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof)
|
||||
if (trim !== -1) return trim
|
||||
|
||||
// Pass 4: normalized (Unicode punctuation to ASCII)
|
||||
const normalized = tryMatch(
|
||||
lines,
|
||||
pattern,
|
||||
startIndex,
|
||||
(a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
|
||||
eof,
|
||||
)
|
||||
return normalized
|
||||
}
|
||||
|
||||
function generateUnifiedDiff(oldContent: string, newContent: string): string {
|
||||
const oldLines = oldContent.split("\n")
|
||||
const newLines = newContent.split("\n")
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { Installation } from "@/installation"
|
|||
import { iife } from "@/util/iife"
|
||||
|
||||
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
|
||||
// Add a small safety buffer when polling to avoid hitting the server
|
||||
// slightly too early due to clock skew / timer drift.
|
||||
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 // 3 seconds
|
||||
|
||||
function normalizeDomain(url: string) {
|
||||
return url.replace(/^https?:\/\//, "").replace(/\/$/, "")
|
||||
|
|
@ -204,6 +207,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||
const data = (await response.json()) as {
|
||||
access_token?: string
|
||||
error?: string
|
||||
interval?: number
|
||||
}
|
||||
|
||||
if (data.access_token) {
|
||||
|
|
@ -230,13 +234,29 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||
}
|
||||
|
||||
if (data.error === "authorization_pending") {
|
||||
await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000))
|
||||
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
continue
|
||||
}
|
||||
|
||||
if (data.error === "slow_down") {
|
||||
// Based on the RFC spec, we must add 5 seconds to our current polling interval.
|
||||
// (See https://www.rfc-editor.org/rfc/rfc8628#section-3.5)
|
||||
let newInterval = (deviceData.interval + 5) * 1000
|
||||
|
||||
// GitHub OAuth API may return the new interval in seconds in the response.
|
||||
// We should try to use that if provided with safety margin.
|
||||
const serverInterval = data.interval
|
||||
if (serverInterval && typeof serverInterval === "number" && serverInterval > 0) {
|
||||
newInterval = serverInterval * 1000
|
||||
}
|
||||
|
||||
await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
continue
|
||||
}
|
||||
|
||||
if (data.error) return { type: "failed" as const }
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000))
|
||||
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
continue
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import { Instance } from "./instance"
|
|||
import { Vcs } from "./vcs"
|
||||
import { Log } from "@/util/log"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Truncate } from "../tool/truncation"
|
||||
|
||||
export async function InstanceBootstrap() {
|
||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||
|
|
@ -22,6 +24,8 @@ export async function InstanceBootstrap() {
|
|||
FileWatcher.init()
|
||||
File.init()
|
||||
Vcs.init()
|
||||
Snapshot.init()
|
||||
Truncate.init()
|
||||
|
||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
|
|
|
|||
|
|
@ -317,4 +317,19 @@ export namespace Project {
|
|||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
export async function removeSandbox(projectID: string, directory: string) {
|
||||
const result = await Storage.update<Info>(["project", projectID], (draft) => {
|
||||
const sandboxes = draft.sandboxes ?? []
|
||||
draft.sandboxes = sandboxes.filter((sandbox) => sandbox !== directory)
|
||||
draft.time.updated = Date.now()
|
||||
})
|
||||
GlobalBus.emit("event", {
|
||||
payload: {
|
||||
type: Event.Updated.type,
|
||||
properties: result,
|
||||
},
|
||||
})
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,18 @@ import { ProviderTransform } from "./transform"
|
|||
export namespace Provider {
|
||||
const log = Log.create({ service: "provider" })
|
||||
|
||||
function isGpt5OrLater(modelID: string): boolean {
|
||||
const match = /^gpt-(\d+)/.exec(modelID)
|
||||
if (!match) {
|
||||
return false
|
||||
}
|
||||
return Number(match[1]) >= 5
|
||||
}
|
||||
|
||||
function shouldUseCopilotResponsesApi(modelID: string): boolean {
|
||||
return isGpt5OrLater(modelID) && !modelID.startsWith("gpt-5-mini")
|
||||
}
|
||||
|
||||
const BUNDLED_PROVIDERS: Record<string, (options: any) => SDK> = {
|
||||
"@ai-sdk/amazon-bedrock": createAmazonBedrock,
|
||||
"@ai-sdk/anthropic": createAnthropic,
|
||||
|
|
@ -120,10 +132,7 @@ export namespace Provider {
|
|||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
if (modelID.includes("codex")) {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
return sdk.chat(modelID)
|
||||
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
|
|
@ -132,10 +141,7 @@ export namespace Provider {
|
|||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
if (modelID.includes("codex")) {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
return sdk.chat(modelID)
|
||||
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
|
|
@ -595,7 +601,10 @@ export namespace Provider {
|
|||
api: {
|
||||
id: model.id,
|
||||
url: provider.api!,
|
||||
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
|
||||
npm: iife(() => {
|
||||
if (provider.id.startsWith("github-copilot")) return "@ai-sdk/github-copilot"
|
||||
return model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible"
|
||||
}),
|
||||
},
|
||||
status: model.status ?? "active",
|
||||
headers: model.headers ?? {},
|
||||
|
|
@ -902,16 +911,6 @@ export namespace Provider {
|
|||
continue
|
||||
}
|
||||
|
||||
if (providerID === "github-copilot" || providerID === "github-copilot-enterprise") {
|
||||
provider.models = mapValues(provider.models, (model) => ({
|
||||
...model,
|
||||
api: {
|
||||
...model.api,
|
||||
npm: "@ai-sdk/github-copilot",
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const configProvider = config.provider?.[providerID]
|
||||
|
||||
for (const [modelID, model] of Object.entries(provider.models)) {
|
||||
|
|
@ -999,6 +998,24 @@ export namespace Provider {
|
|||
opts.signal = combined
|
||||
}
|
||||
|
||||
// Strip openai itemId metadata following what codex does
|
||||
// Codex uses #[serde(skip_serializing)] on id fields for all item types:
|
||||
// Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall
|
||||
// IDs are only re-attached for Azure with store=true
|
||||
if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
|
||||
const body = JSON.parse(opts.body as string)
|
||||
const isAzure = model.providerID.includes("azure")
|
||||
const keepIds = isAzure && body.store === true
|
||||
if (!keepIds && Array.isArray(body.input)) {
|
||||
for (const item of body.input) {
|
||||
if ("id" in item) {
|
||||
delete item.id
|
||||
}
|
||||
}
|
||||
opts.body = JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
return fetchFn(input, {
|
||||
...opts,
|
||||
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
|
||||
|
|
|
|||
|
|
@ -815,14 +815,20 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
|
|||
// flag that checks if there have been client-side tool calls (not executed by openai)
|
||||
let hasFunctionCall = false
|
||||
|
||||
// Track reasoning by output_index instead of item_id
|
||||
// GitHub Copilot rotates encrypted item IDs on every event
|
||||
const activeReasoning: Record<
|
||||
string,
|
||||
number,
|
||||
{
|
||||
canonicalId: string // the item.id from output_item.added
|
||||
encryptedContent?: string | null
|
||||
summaryParts: number[]
|
||||
}
|
||||
> = {}
|
||||
|
||||
// Track current active reasoning output_index for correlating summary events
|
||||
let currentReasoningOutputIndex: number | null = null
|
||||
|
||||
// Track a stable text part id for the current assistant message.
|
||||
// Copilot may change item_id across text deltas; normalize to one id.
|
||||
let currentTextId: string | null = null
|
||||
|
|
@ -933,10 +939,12 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
|
|||
},
|
||||
})
|
||||
} else if (isResponseOutputItemAddedReasoningChunk(value)) {
|
||||
activeReasoning[value.item.id] = {
|
||||
activeReasoning[value.output_index] = {
|
||||
canonicalId: value.item.id,
|
||||
encryptedContent: value.item.encrypted_content,
|
||||
summaryParts: [0],
|
||||
}
|
||||
currentReasoningOutputIndex = value.output_index
|
||||
|
||||
controller.enqueue({
|
||||
type: "reasoning-start",
|
||||
|
|
@ -1091,22 +1099,25 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
|
|||
currentTextId = null
|
||||
}
|
||||
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
|
||||
const activeReasoningPart = activeReasoning[value.item.id]
|
||||
const activeReasoningPart = activeReasoning[value.output_index]
|
||||
if (activeReasoningPart) {
|
||||
for (const summaryIndex of activeReasoningPart.summaryParts) {
|
||||
controller.enqueue({
|
||||
type: "reasoning-end",
|
||||
id: `${value.item.id}:${summaryIndex}`,
|
||||
id: `${activeReasoningPart.canonicalId}:${summaryIndex}`,
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
itemId: value.item.id,
|
||||
itemId: activeReasoningPart.canonicalId,
|
||||
reasoningEncryptedContent: value.item.encrypted_content ?? null,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
delete activeReasoning[value.output_index]
|
||||
if (currentReasoningOutputIndex === value.output_index) {
|
||||
currentReasoningOutputIndex = null
|
||||
}
|
||||
}
|
||||
delete activeReasoning[value.item.id]
|
||||
}
|
||||
} else if (isResponseFunctionCallArgumentsDeltaChunk(value)) {
|
||||
const toolCall = ongoingToolCalls[value.output_index]
|
||||
|
|
@ -1198,32 +1209,40 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
|
|||
logprobs.push(value.logprobs)
|
||||
}
|
||||
} else if (isResponseReasoningSummaryPartAddedChunk(value)) {
|
||||
const activeItem =
|
||||
currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null
|
||||
|
||||
// the first reasoning start is pushed in isResponseOutputItemAddedReasoningChunk.
|
||||
if (value.summary_index > 0) {
|
||||
activeReasoning[value.item_id]?.summaryParts.push(value.summary_index)
|
||||
if (activeItem && value.summary_index > 0) {
|
||||
activeItem.summaryParts.push(value.summary_index)
|
||||
|
||||
controller.enqueue({
|
||||
type: "reasoning-start",
|
||||
id: `${value.item_id}:${value.summary_index}`,
|
||||
id: `${activeItem.canonicalId}:${value.summary_index}`,
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
itemId: value.item_id,
|
||||
reasoningEncryptedContent: activeReasoning[value.item_id]?.encryptedContent ?? null,
|
||||
itemId: activeItem.canonicalId,
|
||||
reasoningEncryptedContent: activeItem.encryptedContent ?? null,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
} else if (isResponseReasoningSummaryTextDeltaChunk(value)) {
|
||||
controller.enqueue({
|
||||
type: "reasoning-delta",
|
||||
id: `${value.item_id}:${value.summary_index}`,
|
||||
delta: value.delta,
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
itemId: value.item_id,
|
||||
const activeItem =
|
||||
currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null
|
||||
|
||||
if (activeItem) {
|
||||
controller.enqueue({
|
||||
type: "reasoning-delta",
|
||||
id: `${activeItem.canonicalId}:${value.summary_index}`,
|
||||
delta: value.delta,
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
itemId: activeItem.canonicalId,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
} else if (isResponseFinishedChunk(value)) {
|
||||
finishReason = mapOpenAIResponseFinishReason({
|
||||
finishReason: value.response.incomplete_details?.reason,
|
||||
|
|
|
|||
|
|
@ -16,30 +16,33 @@ function mimeToModality(mime: string): Modality | undefined {
|
|||
}
|
||||
|
||||
export namespace ProviderTransform {
|
||||
// Maps npm package to the key the AI SDK expects for providerOptions
|
||||
function sdkKey(npm: string): string | undefined {
|
||||
switch (npm) {
|
||||
case "@ai-sdk/github-copilot":
|
||||
case "@ai-sdk/openai":
|
||||
case "@ai-sdk/azure":
|
||||
return "openai"
|
||||
case "@ai-sdk/amazon-bedrock":
|
||||
return "bedrock"
|
||||
case "@ai-sdk/anthropic":
|
||||
return "anthropic"
|
||||
case "@ai-sdk/google-vertex":
|
||||
case "@ai-sdk/google":
|
||||
return "google"
|
||||
case "@ai-sdk/gateway":
|
||||
return "gateway"
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return "openrouter"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeMessages(
|
||||
msgs: ModelMessage[],
|
||||
model: Provider.Model,
|
||||
options: Record<string, unknown>,
|
||||
): ModelMessage[] {
|
||||
// Strip openai itemId metadata following what codex does
|
||||
if (model.api.npm === "@ai-sdk/openai" || options.store === false) {
|
||||
msgs = msgs.map((msg) => {
|
||||
if (msg.providerOptions?.openai) {
|
||||
delete msg.providerOptions.openai["itemId"]
|
||||
}
|
||||
if (!Array.isArray(msg.content)) {
|
||||
return msg
|
||||
}
|
||||
const content = msg.content.map((part) => {
|
||||
if (part.providerOptions?.openai) {
|
||||
delete part.providerOptions.openai["itemId"]
|
||||
}
|
||||
return part
|
||||
})
|
||||
return { ...msg, content } as typeof msg
|
||||
})
|
||||
}
|
||||
|
||||
// Anthropic rejects messages with empty content - filter out empty string messages
|
||||
// and remove empty text/reasoning parts from array content
|
||||
if (model.api.npm === "@ai-sdk/anthropic") {
|
||||
|
|
@ -253,6 +256,28 @@ export namespace ProviderTransform {
|
|||
msgs = applyCaching(msgs, model.providerID)
|
||||
}
|
||||
|
||||
// Remap providerOptions keys from stored providerID to expected SDK key
|
||||
const key = sdkKey(model.api.npm)
|
||||
if (key && key !== model.providerID && model.api.npm !== "@ai-sdk/azure") {
|
||||
const remap = (opts: Record<string, any> | undefined) => {
|
||||
if (!opts) return opts
|
||||
if (!(model.providerID in opts)) return opts
|
||||
const result = { ...opts }
|
||||
result[key] = result[model.providerID]
|
||||
delete result[model.providerID]
|
||||
return result
|
||||
}
|
||||
|
||||
msgs = msgs.map((msg) => {
|
||||
if (!Array.isArray(msg.content)) return { ...msg, providerOptions: remap(msg.providerOptions) }
|
||||
return {
|
||||
...msg,
|
||||
providerOptions: remap(msg.providerOptions),
|
||||
content: msg.content.map((part) => ({ ...part, providerOptions: remap(part.providerOptions) })),
|
||||
} as typeof msg
|
||||
})
|
||||
}
|
||||
|
||||
return msgs
|
||||
}
|
||||
|
||||
|
|
@ -300,15 +325,42 @@ export namespace ProviderTransform {
|
|||
const id = model.id.toLowerCase()
|
||||
if (id.includes("deepseek") || id.includes("minimax") || id.includes("glm") || id.includes("mistral")) return {}
|
||||
|
||||
// see: https://docs.x.ai/docs/guides/reasoning#control-how-hard-the-model-thinks
|
||||
if (id.includes("grok") && id.includes("grok-3-mini")) {
|
||||
if (model.api.npm === "@openrouter/ai-sdk-provider") {
|
||||
return {
|
||||
low: { reasoning: { effort: "low" } },
|
||||
high: { reasoning: { effort: "high" } },
|
||||
}
|
||||
}
|
||||
return {
|
||||
low: { reasoningEffort: "low" },
|
||||
high: { reasoningEffort: "high" },
|
||||
}
|
||||
}
|
||||
if (id.includes("grok")) return {}
|
||||
|
||||
switch (model.api.npm) {
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("grok-4")) return {}
|
||||
if (!model.id.includes("gpt") && !model.id.includes("gemini-3")) return {}
|
||||
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }]))
|
||||
|
||||
// TODO: YOU CANNOT SET max_tokens if this is set!!!
|
||||
case "@ai-sdk/gateway":
|
||||
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
|
||||
|
||||
case "@ai-sdk/github-copilot":
|
||||
return Object.fromEntries(
|
||||
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
|
||||
effort,
|
||||
{
|
||||
reasoningEffort: effort,
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
case "@ai-sdk/cerebras":
|
||||
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/cerebras
|
||||
case "@ai-sdk/togetherai":
|
||||
|
|
@ -484,7 +536,11 @@ export namespace ProviderTransform {
|
|||
const result: Record<string, any> = {}
|
||||
|
||||
// openai and providers using openai package should set store to false by default.
|
||||
if (input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/openai") {
|
||||
if (
|
||||
input.model.providerID === "openai" ||
|
||||
input.model.api.npm === "@ai-sdk/openai" ||
|
||||
input.model.api.npm === "@ai-sdk/github-copilot"
|
||||
) {
|
||||
result["store"] = false
|
||||
}
|
||||
|
||||
|
|
@ -570,39 +626,8 @@ export namespace ProviderTransform {
|
|||
}
|
||||
|
||||
export function providerOptions(model: Provider.Model, options: { [x: string]: any }) {
|
||||
switch (model.api.npm) {
|
||||
case "@ai-sdk/github-copilot":
|
||||
case "@ai-sdk/openai":
|
||||
case "@ai-sdk/azure":
|
||||
return {
|
||||
["openai" as string]: options,
|
||||
}
|
||||
case "@ai-sdk/amazon-bedrock":
|
||||
return {
|
||||
["bedrock" as string]: options,
|
||||
}
|
||||
case "@ai-sdk/anthropic":
|
||||
return {
|
||||
["anthropic" as string]: options,
|
||||
}
|
||||
case "@ai-sdk/google-vertex":
|
||||
case "@ai-sdk/google":
|
||||
return {
|
||||
["google" as string]: options,
|
||||
}
|
||||
case "@ai-sdk/gateway":
|
||||
return {
|
||||
["gateway" as string]: options,
|
||||
}
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return {
|
||||
["openrouter" as string]: options,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
[model.providerID]: options,
|
||||
}
|
||||
}
|
||||
const key = sdkKey(model.api.npm) ?? model.providerID
|
||||
return { [key]: options }
|
||||
}
|
||||
|
||||
export function maxOutputTokens(
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export namespace Question {
|
|||
|
||||
export const Option = z
|
||||
.object({
|
||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||
label: z.string().max(30).describe("Display text (1-5 words, concise)"),
|
||||
description: z.string().describe("Explanation of choice"),
|
||||
})
|
||||
.meta({
|
||||
|
|
@ -21,7 +21,7 @@ export namespace Question {
|
|||
export const Info = z
|
||||
.object({
|
||||
question: z.string().describe("Complete question"),
|
||||
header: z.string().max(12).describe("Very short label (max 12 chars)"),
|
||||
header: z.string().max(30).describe("Very short label (max 30 chars)"),
|
||||
options: z.array(Option).describe("Available choices"),
|
||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||
|
|
|
|||
61
packages/opencode/src/scheduler/index.ts
Normal file
61
packages/opencode/src/scheduler/index.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Instance } from "../project/instance"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace Scheduler {
|
||||
const log = Log.create({ service: "scheduler" })
|
||||
|
||||
export type Task = {
|
||||
id: string
|
||||
interval: number
|
||||
run: () => Promise<void>
|
||||
scope?: "instance" | "global"
|
||||
}
|
||||
|
||||
type Timer = ReturnType<typeof setInterval>
|
||||
type Entry = {
|
||||
tasks: Map<string, Task>
|
||||
timers: Map<string, Timer>
|
||||
}
|
||||
|
||||
const create = (): Entry => {
|
||||
const tasks = new Map<string, Task>()
|
||||
const timers = new Map<string, Timer>()
|
||||
return { tasks, timers }
|
||||
}
|
||||
|
||||
const shared = create()
|
||||
|
||||
const state = Instance.state(
|
||||
() => create(),
|
||||
async (entry) => {
|
||||
for (const timer of entry.timers.values()) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
entry.tasks.clear()
|
||||
entry.timers.clear()
|
||||
},
|
||||
)
|
||||
|
||||
export function register(task: Task) {
|
||||
const scope = task.scope ?? "instance"
|
||||
const entry = scope === "global" ? shared : state()
|
||||
const current = entry.timers.get(task.id)
|
||||
if (current && scope === "global") return
|
||||
if (current) clearInterval(current)
|
||||
|
||||
entry.tasks.set(task.id, task)
|
||||
void run(task)
|
||||
const timer = setInterval(() => {
|
||||
void run(task)
|
||||
}, task.interval)
|
||||
timer.unref()
|
||||
entry.timers.set(task.id, timer)
|
||||
}
|
||||
|
||||
async function run(task: Task) {
|
||||
log.info("run", { id: task.id })
|
||||
await task.run().catch((error) => {
|
||||
log.error("run failed", { id: task.id, error })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -7,15 +7,17 @@ export namespace MDNS {
|
|||
let bonjour: Bonjour | undefined
|
||||
let currentPort: number | undefined
|
||||
|
||||
export function publish(port: number, name = "opencode") {
|
||||
export function publish(port: number) {
|
||||
if (currentPort === port) return
|
||||
if (bonjour) unpublish()
|
||||
|
||||
try {
|
||||
const name = `opencode-${port}`
|
||||
bonjour = new Bonjour()
|
||||
const service = bonjour.publish({
|
||||
name,
|
||||
type: "http",
|
||||
host: "opencode.local",
|
||||
port,
|
||||
txt: { path: "/" },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Project } from "../project/project"
|
||||
import z from "zod"
|
||||
import { errors } from "./error"
|
||||
|
||||
export const ProjectRoute = new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List all projects",
|
||||
description: "Get a list of projects that have been opened with OpenCode.",
|
||||
operationId: "project.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of projects",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const projects = await Project.list()
|
||||
return c.json(projects)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/current",
|
||||
describeRoute({
|
||||
summary: "Get current project",
|
||||
description: "Retrieve the currently active project that OpenCode is working with.",
|
||||
operationId: "project.current",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Current project information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(Instance.project)
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/:projectID",
|
||||
describeRoute({
|
||||
summary: "Update project",
|
||||
description: "Update project properties such as name, icon and color.",
|
||||
operationId: "project.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated project information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ projectID: z.string() })),
|
||||
validator("json", Project.update.schema.omit({ projectID: true })),
|
||||
async (c) => {
|
||||
const projectID = c.req.valid("param").projectID
|
||||
const body = c.req.valid("json")
|
||||
const project = await Project.update({ ...body, projectID })
|
||||
return c.json(project)
|
||||
},
|
||||
)
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { Question } from "../question"
|
||||
import z from "zod"
|
||||
import { errors } from "./error"
|
||||
|
||||
export const QuestionRoute = new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List pending questions",
|
||||
description: "Get all pending question requests across all sessions.",
|
||||
operationId: "question.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of pending questions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Question.Request.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const questions = await Question.list()
|
||||
return c.json(questions)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reply",
|
||||
describeRoute({
|
||||
summary: "Reply to question request",
|
||||
description: "Provide answers to a question request from the AI assistant.",
|
||||
operationId: "question.reply",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Question answered successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: z.string(),
|
||||
}),
|
||||
),
|
||||
validator("json", Question.Reply),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
await Question.reply({
|
||||
requestID: params.requestID,
|
||||
answers: json.answers,
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reject",
|
||||
describeRoute({
|
||||
summary: "Reject question request",
|
||||
description: "Reject a question request from the AI assistant.",
|
||||
operationId: "question.reject",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Question rejected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await Question.reject(params.requestID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
92
packages/opencode/src/server/routes/config.ts
Normal file
92
packages/opencode/src/server/routes/config.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Config } from "../../config/config"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { mapValues } from "remeda"
|
||||
import { errors } from "../error"
|
||||
import { Log } from "../../util/log"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const ConfigRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Get configuration",
|
||||
description: "Retrieve the current OpenCode configuration settings and preferences.",
|
||||
operationId: "config.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get config info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await Config.get())
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Update configuration",
|
||||
description: "Update OpenCode configuration settings and preferences.",
|
||||
operationId: "config.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated config",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Config.Info),
|
||||
async (c) => {
|
||||
const config = c.req.valid("json")
|
||||
await Config.update(config)
|
||||
return c.json(config)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/providers",
|
||||
describeRoute({
|
||||
summary: "List config providers",
|
||||
description: "Get a list of all configured AI providers and their default models.",
|
||||
operationId: "config.providers",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of providers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
providers: Provider.Info.array(),
|
||||
default: z.record(z.string(), z.string()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
using _ = log.time("providers")
|
||||
const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
|
||||
return c.json({
|
||||
providers: Object.values(providers),
|
||||
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
208
packages/opencode/src/server/routes/experimental.ts
Normal file
208
packages/opencode/src/server/routes/experimental.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { ToolRegistry } from "../../tool/registry"
|
||||
import { Worktree } from "../../worktree"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Project } from "../../project/project"
|
||||
import { MCP } from "../../mcp"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const ExperimentalRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/tool/ids",
|
||||
describeRoute({
|
||||
summary: "List tool IDs",
|
||||
description:
|
||||
"Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.",
|
||||
operationId: "tool.ids",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tool IDs",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await ToolRegistry.ids())
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/tool",
|
||||
describeRoute({
|
||||
summary: "List tools",
|
||||
description:
|
||||
"Get a list of available tools with their JSON schema parameters for a specific provider and model combination.",
|
||||
operationId: "tool.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tools",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
description: z.string(),
|
||||
parameters: z.any(),
|
||||
})
|
||||
.meta({ ref: "ToolListItem" }),
|
||||
)
|
||||
.meta({ ref: "ToolList" }),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { provider, model } = c.req.valid("query")
|
||||
const tools = await ToolRegistry.tools({ providerID: provider, modelID: model })
|
||||
return c.json(
|
||||
tools.map((t) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
// Handle both Zod schemas and plain JSON schemas
|
||||
parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
|
||||
})),
|
||||
)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/worktree",
|
||||
describeRoute({
|
||||
summary: "Create worktree",
|
||||
description: "Create a new git worktree for the current project.",
|
||||
operationId: "worktree.create",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Worktree created",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Worktree.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.create.schema),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const worktree = await Worktree.create(body)
|
||||
return c.json(worktree)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/worktree",
|
||||
describeRoute({
|
||||
summary: "List worktrees",
|
||||
description: "List all sandbox worktrees for the current project.",
|
||||
operationId: "worktree.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of worktree directories",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(z.string())),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const sandboxes = await Project.sandboxes(Instance.project.id)
|
||||
return c.json(sandboxes)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/worktree",
|
||||
describeRoute({
|
||||
summary: "Remove worktree",
|
||||
description: "Remove a git worktree and delete its branch.",
|
||||
operationId: "worktree.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Worktree removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.remove.schema),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Worktree.remove(body)
|
||||
await Project.removeSandbox(Instance.project.id, body.directory)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/worktree/reset",
|
||||
describeRoute({
|
||||
summary: "Reset worktree",
|
||||
description: "Reset a worktree branch to the primary default branch.",
|
||||
operationId: "worktree.reset",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Worktree reset",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.reset.schema),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Worktree.reset(body)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/resource",
|
||||
describeRoute({
|
||||
summary: "Get MCP resources",
|
||||
description: "Get all available MCP resources from connected servers. Optionally filter by name.",
|
||||
operationId: "experimental.resource.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP resources",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), MCP.Resource)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await MCP.resources())
|
||||
},
|
||||
),
|
||||
)
|
||||
197
packages/opencode/src/server/routes/file.ts
Normal file
197
packages/opencode/src/server/routes/file.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { File } from "../../file"
|
||||
import { Ripgrep } from "../../file/ripgrep"
|
||||
import { LSP } from "../../lsp"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const FileRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/find",
|
||||
describeRoute({
|
||||
summary: "Find text",
|
||||
description: "Search for text patterns across files in the project using ripgrep.",
|
||||
operationId: "find.text",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Matches",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Ripgrep.Match.shape.data.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
pattern: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const pattern = c.req.valid("query").pattern
|
||||
const result = await Ripgrep.search({
|
||||
cwd: Instance.directory,
|
||||
pattern,
|
||||
limit: 10,
|
||||
})
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/find/file",
|
||||
describeRoute({
|
||||
summary: "Find files",
|
||||
description: "Search for files or directories by name or pattern in the project directory.",
|
||||
operationId: "find.files",
|
||||
responses: {
|
||||
200: {
|
||||
description: "File paths",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.string().array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
query: z.string(),
|
||||
dirs: z.enum(["true", "false"]).optional(),
|
||||
type: z.enum(["file", "directory"]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query").query
|
||||
const dirs = c.req.valid("query").dirs
|
||||
const type = c.req.valid("query").type
|
||||
const limit = c.req.valid("query").limit
|
||||
const results = await File.search({
|
||||
query,
|
||||
limit: limit ?? 10,
|
||||
dirs: dirs !== "false",
|
||||
type,
|
||||
})
|
||||
return c.json(results)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/find/symbol",
|
||||
describeRoute({
|
||||
summary: "Find symbols",
|
||||
description: "Search for workspace symbols like functions, classes, and variables using LSP.",
|
||||
operationId: "find.symbols",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Symbols",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(LSP.Symbol.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
query: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
/*
|
||||
const query = c.req.valid("query").query
|
||||
const result = await LSP.workspaceSymbol(query)
|
||||
return c.json(result)
|
||||
*/
|
||||
return c.json([])
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/file",
|
||||
describeRoute({
|
||||
summary: "List files",
|
||||
description: "List files and directories in a specified path.",
|
||||
operationId: "file.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Files and directories",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(File.Node.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const path = c.req.valid("query").path
|
||||
const content = await File.list(path)
|
||||
return c.json(content)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/file/content",
|
||||
describeRoute({
|
||||
summary: "Read file",
|
||||
description: "Read the content of a specified file.",
|
||||
operationId: "file.read",
|
||||
responses: {
|
||||
200: {
|
||||
description: "File content",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(File.Content),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const path = c.req.valid("query").path
|
||||
const content = await File.read(path)
|
||||
return c.json(content)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/file/status",
|
||||
describeRoute({
|
||||
summary: "Get file status",
|
||||
description: "Get the git status of all files in the project.",
|
||||
operationId: "file.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "File status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(File.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const content = await File.status()
|
||||
return c.json(content)
|
||||
},
|
||||
),
|
||||
)
|
||||
135
packages/opencode/src/server/routes/global.ts
Normal file
135
packages/opencode/src/server/routes/global.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import z from "zod"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Installation } from "@/installation"
|
||||
import { Log } from "../../util/log"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
|
||||
|
||||
export const GlobalRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/health",
|
||||
describeRoute({
|
||||
summary: "Get health",
|
||||
description: "Get health information about the OpenCode server.",
|
||||
operationId: "global.health",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Health information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({ healthy: true, version: Installation.VERSION })
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/event",
|
||||
describeRoute({
|
||||
summary: "Get global events",
|
||||
description: "Subscribe to global events from the OpenCode system using server-sent events.",
|
||||
operationId: "global.event",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
directory: z.string(),
|
||||
payload: BusEvent.payloads(),
|
||||
})
|
||||
.meta({
|
||||
ref: "GlobalEvent",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("global event connected")
|
||||
return streamSSE(c, async (stream) => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
payload: {
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
})
|
||||
async function handler(event: any) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
})
|
||||
}
|
||||
GlobalBus.on("event", handler)
|
||||
|
||||
// Send heartbeat every 30s to prevent WKWebView timeout (60s default)
|
||||
const heartbeat = setInterval(() => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
payload: {
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => {
|
||||
clearInterval(heartbeat)
|
||||
GlobalBus.off("event", handler)
|
||||
resolve()
|
||||
log.info("global event disconnected")
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/dispose",
|
||||
describeRoute({
|
||||
summary: "Dispose instance",
|
||||
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
|
||||
operationId: "global.dispose",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Global disposed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Instance.disposeAll()
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: GlobalDisposedEvent.type,
|
||||
properties: {},
|
||||
},
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
),
|
||||
)
|
||||
225
packages/opencode/src/server/routes/mcp.ts
Normal file
225
packages/opencode/src/server/routes/mcp.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { MCP } from "../../mcp"
|
||||
import { Config } from "../../config/config"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const McpRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Get MCP status",
|
||||
description: "Get the status of all Model Context Protocol (MCP) servers.",
|
||||
operationId: "mcp.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), MCP.Status)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await MCP.status())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Add MCP server",
|
||||
description: "Dynamically add a new Model Context Protocol (MCP) server to the system.",
|
||||
operationId: "mcp.add",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server added successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), MCP.Status)),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
name: z.string(),
|
||||
config: Config.Mcp,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { name, config } = c.req.valid("json")
|
||||
const result = await MCP.add(name, config)
|
||||
return c.json(result.status)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:name/auth",
|
||||
describeRoute({
|
||||
summary: "Start MCP OAuth",
|
||||
description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.",
|
||||
operationId: "mcp.auth.start",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth flow started",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
authorizationUrl: z.string().describe("URL to open in browser for authorization"),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const supportsOAuth = await MCP.supportsOAuth(name)
|
||||
if (!supportsOAuth) {
|
||||
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
||||
}
|
||||
const result = await MCP.startAuth(name)
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:name/auth/callback",
|
||||
describeRoute({
|
||||
summary: "Complete MCP OAuth",
|
||||
description:
|
||||
"Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.",
|
||||
operationId: "mcp.auth.callback",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth authentication completed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MCP.Status),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
code: z.string().describe("Authorization code from OAuth callback"),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const { code } = c.req.valid("json")
|
||||
const status = await MCP.finishAuth(name, code)
|
||||
return c.json(status)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:name/auth/authenticate",
|
||||
describeRoute({
|
||||
summary: "Authenticate MCP OAuth",
|
||||
description: "Start OAuth flow and wait for callback (opens browser)",
|
||||
operationId: "mcp.auth.authenticate",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth authentication completed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MCP.Status),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const supportsOAuth = await MCP.supportsOAuth(name)
|
||||
if (!supportsOAuth) {
|
||||
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
||||
}
|
||||
const status = await MCP.authenticate(name)
|
||||
return c.json(status)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:name/auth",
|
||||
describeRoute({
|
||||
summary: "Remove MCP OAuth",
|
||||
description: "Remove OAuth credentials for an MCP server",
|
||||
operationId: "mcp.auth.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth credentials removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ success: z.literal(true) })),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
await MCP.removeAuth(name)
|
||||
return c.json({ success: true as const })
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:name/connect",
|
||||
describeRoute({
|
||||
description: "Connect an MCP server",
|
||||
operationId: "mcp.connect",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server connected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ name: z.string() })),
|
||||
async (c) => {
|
||||
const { name } = c.req.valid("param")
|
||||
await MCP.connect(name)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:name/disconnect",
|
||||
describeRoute({
|
||||
description: "Disconnect an MCP server",
|
||||
operationId: "mcp.disconnect",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server disconnected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ name: z.string() })),
|
||||
async (c) => {
|
||||
const { name } = c.req.valid("param")
|
||||
await MCP.disconnect(name)
|
||||
return c.json(true)
|
||||
},
|
||||
),
|
||||
)
|
||||
68
packages/opencode/src/server/routes/permission.ts
Normal file
68
packages/opencode/src/server/routes/permission.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const PermissionRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.post(
|
||||
"/:requestID/reply",
|
||||
describeRoute({
|
||||
summary: "Respond to permission request",
|
||||
description: "Approve or deny a permission request from the AI assistant.",
|
||||
operationId: "permission.reply",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Permission processed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: z.string(),
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
await PermissionNext.reply({
|
||||
requestID: params.requestID,
|
||||
reply: json.reply,
|
||||
message: json.message,
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List pending permissions",
|
||||
description: "Get all pending permission requests across all sessions.",
|
||||
operationId: "permission.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of pending permissions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(PermissionNext.Request.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const permissions = await PermissionNext.list()
|
||||
return c.json(permissions)
|
||||
},
|
||||
),
|
||||
)
|
||||
82
packages/opencode/src/server/routes/project.ts
Normal file
82
packages/opencode/src/server/routes/project.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Project } from "../../project/project"
|
||||
import z from "zod"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const ProjectRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List all projects",
|
||||
description: "Get a list of projects that have been opened with OpenCode.",
|
||||
operationId: "project.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of projects",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const projects = await Project.list()
|
||||
return c.json(projects)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/current",
|
||||
describeRoute({
|
||||
summary: "Get current project",
|
||||
description: "Retrieve the currently active project that OpenCode is working with.",
|
||||
operationId: "project.current",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Current project information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(Instance.project)
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/:projectID",
|
||||
describeRoute({
|
||||
summary: "Update project",
|
||||
description: "Update project properties such as name, icon and color.",
|
||||
operationId: "project.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated project information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ projectID: z.string() })),
|
||||
validator("json", Project.update.schema.omit({ projectID: true })),
|
||||
async (c) => {
|
||||
const projectID = c.req.valid("param").projectID
|
||||
const body = c.req.valid("json")
|
||||
const project = await Project.update({ ...body, projectID })
|
||||
return c.json(project)
|
||||
},
|
||||
),
|
||||
)
|
||||
165
packages/opencode/src/server/routes/provider.ts
Normal file
165
packages/opencode/src/server/routes/provider.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Config } from "../../config/config"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
import { ProviderAuth } from "../../provider/auth"
|
||||
import { mapValues } from "remeda"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const ProviderRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List providers",
|
||||
description: "Get a list of all available AI providers, including both available and connected ones.",
|
||||
operationId: "provider.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of providers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
all: ModelsDev.Provider.array(),
|
||||
default: z.record(z.string(), z.string()),
|
||||
connected: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const config = await Config.get()
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
|
||||
const allProviders = await ModelsDev.get()
|
||||
const filteredProviders: Record<string, (typeof allProviders)[string]> = {}
|
||||
for (const [key, value] of Object.entries(allProviders)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filteredProviders[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const connected = await Provider.list()
|
||||
const providers = Object.assign(
|
||||
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
|
||||
connected,
|
||||
)
|
||||
return c.json({
|
||||
all: Object.values(providers),
|
||||
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
||||
connected: Object.keys(connected),
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/auth",
|
||||
describeRoute({
|
||||
summary: "Get provider auth methods",
|
||||
description: "Retrieve available authentication methods for all AI providers.",
|
||||
operationId: "provider.auth",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Provider auth methods",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await ProviderAuth.methods())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:providerID/oauth/authorize",
|
||||
describeRoute({
|
||||
summary: "OAuth authorize",
|
||||
description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.",
|
||||
operationId: "provider.oauth.authorize",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Authorization URL and method",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ProviderAuth.Authorization.optional()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: z.string().meta({ description: "Provider ID" }),
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
method: z.number().meta({ description: "Auth method index" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method } = c.req.valid("json")
|
||||
const result = await ProviderAuth.authorize({
|
||||
providerID,
|
||||
method,
|
||||
})
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:providerID/oauth/callback",
|
||||
describeRoute({
|
||||
summary: "OAuth callback",
|
||||
description: "Handle the OAuth callback from a provider after user authorization.",
|
||||
operationId: "provider.oauth.callback",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth callback processed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: z.string().meta({ description: "Provider ID" }),
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
method: z.number().meta({ description: "Auth method index" }),
|
||||
code: z.string().optional().meta({ description: "OAuth authorization code" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method, code } = c.req.valid("json")
|
||||
await ProviderAuth.callback({
|
||||
providerID,
|
||||
method,
|
||||
code,
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
),
|
||||
)
|
||||
169
packages/opencode/src/server/routes/pty.ts
Normal file
169
packages/opencode/src/server/routes/pty.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { upgradeWebSocket } from "hono/bun"
|
||||
import z from "zod"
|
||||
import { Pty } from "@/pty"
|
||||
import { Storage } from "../../storage/storage"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const PtyRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List PTY sessions",
|
||||
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
|
||||
operationId: "pty.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(Pty.list())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Create PTY session",
|
||||
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
|
||||
operationId: "pty.create",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Created session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Pty.CreateInput),
|
||||
async (c) => {
|
||||
const info = await Pty.create(c.req.valid("json"))
|
||||
return c.json(info)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:ptyID",
|
||||
describeRoute({
|
||||
summary: "Get PTY session",
|
||||
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
|
||||
operationId: "pty.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: z.string() })),
|
||||
async (c) => {
|
||||
const info = Pty.get(c.req.valid("param").ptyID)
|
||||
if (!info) {
|
||||
throw new Storage.NotFoundError({ message: "Session not found" })
|
||||
}
|
||||
return c.json(info)
|
||||
},
|
||||
)
|
||||
.put(
|
||||
"/:ptyID",
|
||||
describeRoute({
|
||||
summary: "Update PTY session",
|
||||
description: "Update properties of an existing pseudo-terminal (PTY) session.",
|
||||
operationId: "pty.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Pty.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: z.string() })),
|
||||
validator("json", Pty.UpdateInput),
|
||||
async (c) => {
|
||||
const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
|
||||
return c.json(info)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:ptyID",
|
||||
describeRoute({
|
||||
summary: "Remove PTY session",
|
||||
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
|
||||
operationId: "pty.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: z.string() })),
|
||||
async (c) => {
|
||||
await Pty.remove(c.req.valid("param").ptyID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:ptyID/connect",
|
||||
describeRoute({
|
||||
summary: "Connect to PTY session",
|
||||
description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
|
||||
operationId: "pty.connect",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Connected session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: z.string() })),
|
||||
upgradeWebSocket((c) => {
|
||||
const id = c.req.param("ptyID")
|
||||
let handler: ReturnType<typeof Pty.connect>
|
||||
if (!Pty.get(id)) throw new Error("Session not found")
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
handler = Pty.connect(id, ws)
|
||||
},
|
||||
onMessage(event) {
|
||||
handler?.onMessage(String(event.data))
|
||||
},
|
||||
onClose() {
|
||||
handler?.onClose()
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
98
packages/opencode/src/server/routes/question.ts
Normal file
98
packages/opencode/src/server/routes/question.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { Question } from "../../question"
|
||||
import z from "zod"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const QuestionRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List pending questions",
|
||||
description: "Get all pending question requests across all sessions.",
|
||||
operationId: "question.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of pending questions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Question.Request.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const questions = await Question.list()
|
||||
return c.json(questions)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reply",
|
||||
describeRoute({
|
||||
summary: "Reply to question request",
|
||||
description: "Provide answers to a question request from the AI assistant.",
|
||||
operationId: "question.reply",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Question answered successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: z.string(),
|
||||
}),
|
||||
),
|
||||
validator("json", Question.Reply),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
await Question.reply({
|
||||
requestID: params.requestID,
|
||||
answers: json.answers,
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reject",
|
||||
describeRoute({
|
||||
summary: "Reject question request",
|
||||
description: "Reject a question request from the AI assistant.",
|
||||
operationId: "question.reject",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Question rejected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
requestID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await Question.reject(params.requestID)
|
||||
return c.json(true)
|
||||
},
|
||||
),
|
||||
)
|
||||
935
packages/opencode/src/server/routes/session.ts
Normal file
935
packages/opencode/src/server/routes/session.ts
Normal file
|
|
@ -0,0 +1,935 @@
|
|||
import { Hono } from "hono"
|
||||
import { stream } from "hono/streaming"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Session } from "../../session"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "../../session/prompt"
|
||||
import { SessionCompaction } from "../../session/compaction"
|
||||
import { SessionRevert } from "../../session/revert"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "../../session/todo"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Log } from "../../util/log"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const SessionRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List sessions",
|
||||
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
|
||||
operationId: "session.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
|
||||
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
|
||||
start: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
|
||||
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
|
||||
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const term = query.search?.toLowerCase()
|
||||
const sessions: Session.Info[] = []
|
||||
for await (const session of Session.list()) {
|
||||
if (query.directory !== undefined && session.directory !== query.directory) continue
|
||||
if (query.roots && session.parentID) continue
|
||||
if (query.start !== undefined && session.time.updated < query.start) continue
|
||||
if (term !== undefined && !session.title.toLowerCase().includes(term)) continue
|
||||
sessions.push(session)
|
||||
if (query.limit !== undefined && sessions.length >= query.limit) break
|
||||
}
|
||||
return c.json(sessions)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/status",
|
||||
describeRoute({
|
||||
summary: "Get session status",
|
||||
description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
|
||||
operationId: "session.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get session status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), SessionStatus.Info)),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const result = SessionStatus.list()
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:sessionID",
|
||||
describeRoute({
|
||||
summary: "Get session",
|
||||
description: "Retrieve detailed information about a specific OpenCode session.",
|
||||
tags: ["Session"],
|
||||
operationId: "session.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.get.schema,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
log.info("SEARCH", { url: c.req.url })
|
||||
const session = await Session.get(sessionID)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:sessionID/children",
|
||||
describeRoute({
|
||||
summary: "Get session children",
|
||||
tags: ["Session"],
|
||||
description: "Retrieve all child sessions that were forked from the specified parent session.",
|
||||
operationId: "session.children",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of children",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.children.schema,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const session = await Session.children(sessionID)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:sessionID/todo",
|
||||
describeRoute({
|
||||
summary: "Get session todos",
|
||||
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
|
||||
operationId: "session.todo",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Todo list",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Todo.Info.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().meta({ description: "Session ID" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const todos = await Todo.get(sessionID)
|
||||
return c.json(todos)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Create session",
|
||||
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
|
||||
operationId: "session.create",
|
||||
responses: {
|
||||
...errors(400),
|
||||
200: {
|
||||
description: "Successfully created session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", Session.create.schema.optional()),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json") ?? {}
|
||||
const session = await Session.create(body)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:sessionID",
|
||||
describeRoute({
|
||||
summary: "Delete session",
|
||||
description: "Delete a session and permanently remove all associated data, including messages and history.",
|
||||
operationId: "session.delete",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully deleted session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.remove.schema,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
await Session.remove(sessionID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/:sessionID",
|
||||
describeRoute({
|
||||
summary: "Update session",
|
||||
description: "Update properties of an existing session, such as title or other metadata.",
|
||||
operationId: "session.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
time: z
|
||||
.object({
|
||||
archived: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const updates = c.req.valid("json")
|
||||
|
||||
const updatedSession = await Session.update(sessionID, (session) => {
|
||||
if (updates.title !== undefined) {
|
||||
session.title = updates.title
|
||||
}
|
||||
if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived
|
||||
})
|
||||
|
||||
return c.json(updatedSession)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/init",
|
||||
describeRoute({
|
||||
summary: "Initialize session",
|
||||
description:
|
||||
"Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
|
||||
operationId: "session.init",
|
||||
responses: {
|
||||
200: {
|
||||
description: "200",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().meta({ description: "Session ID" }),
|
||||
}),
|
||||
),
|
||||
validator("json", Session.initialize.schema.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
await Session.initialize({ ...body, sessionID })
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/fork",
|
||||
describeRoute({
|
||||
summary: "Fork session",
|
||||
description: "Create a new session by forking an existing session at a specific message point.",
|
||||
operationId: "session.fork",
|
||||
responses: {
|
||||
200: {
|
||||
description: "200",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.fork.schema.shape.sessionID,
|
||||
}),
|
||||
),
|
||||
validator("json", Session.fork.schema.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const result = await Session.fork({ ...body, sessionID })
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/abort",
|
||||
describeRoute({
|
||||
summary: "Abort session",
|
||||
description: "Abort an active session and stop any ongoing AI processing or command execution.",
|
||||
operationId: "session.abort",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Aborted session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
SessionPrompt.cancel(c.req.valid("param").sessionID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/share",
|
||||
describeRoute({
|
||||
summary: "Share session",
|
||||
description: "Create a shareable link for a session, allowing others to view the conversation.",
|
||||
operationId: "session.share",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully shared session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
await Session.share(sessionID)
|
||||
const session = await Session.get(sessionID)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:sessionID/diff",
|
||||
describeRoute({
|
||||
summary: "Get message diff",
|
||||
description: "Get the file changes (diff) that resulted from a specific user message in the session.",
|
||||
operationId: "session.diff",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully retrieved diff",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Snapshot.FileDiff.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: SessionSummary.diff.schema.shape.sessionID,
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
messageID: SessionSummary.diff.schema.shape.messageID,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const params = c.req.valid("param")
|
||||
const result = await SessionSummary.diff({
|
||||
sessionID: params.sessionID,
|
||||
messageID: query.messageID,
|
||||
})
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:sessionID/share",
|
||||
describeRoute({
|
||||
summary: "Unshare session",
|
||||
description: "Remove the shareable link for a session, making it private again.",
|
||||
operationId: "session.unshare",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully unshared session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.unshare.schema,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
await Session.unshare(sessionID)
|
||||
const session = await Session.get(sessionID)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/summarize",
|
||||
describeRoute({
|
||||
summary: "Summarize session",
|
||||
description: "Generate a concise summary of the session using AI compaction to preserve key information.",
|
||||
operationId: "session.summarize",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Summarized session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().meta({ description: "Session ID" }),
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
auto: z.boolean().optional().default(false),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const session = await Session.get(sessionID)
|
||||
await SessionRevert.cleanup(session)
|
||||
const msgs = await Session.messages({ sessionID })
|
||||
let currentAgent = await Agent.defaultAgent()
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const info = msgs[i].info
|
||||
if (info.role === "user") {
|
||||
currentAgent = info.agent || (await Agent.defaultAgent())
|
||||
break
|
||||
}
|
||||
}
|
||||
await SessionCompaction.create({
|
||||
sessionID,
|
||||
agent: currentAgent,
|
||||
model: {
|
||||
providerID: body.providerID,
|
||||
modelID: body.modelID,
|
||||
},
|
||||
auto: body.auto,
|
||||
})
|
||||
await SessionPrompt.loop(sessionID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:sessionID/message",
|
||||
describeRoute({
|
||||
summary: "Get session messages",
|
||||
description: "Retrieve all messages in a session, including user prompts and AI responses.",
|
||||
operationId: "session.messages",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of messages",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MessageV2.WithParts.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().meta({ description: "Session ID" }),
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
limit: z.coerce.number().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const messages = await Session.messages({
|
||||
sessionID: c.req.valid("param").sessionID,
|
||||
limit: query.limit,
|
||||
})
|
||||
return c.json(messages)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:sessionID/message/:messageID",
|
||||
describeRoute({
|
||||
summary: "Get message",
|
||||
description: "Retrieve a specific message from a session by its message ID.",
|
||||
operationId: "session.message",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Message",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
info: MessageV2.Info,
|
||||
parts: MessageV2.Part.array(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().meta({ description: "Session ID" }),
|
||||
messageID: z.string().meta({ description: "Message ID" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const message = await MessageV2.get({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
})
|
||||
return c.json(message)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:sessionID/message/:messageID/part/:partID",
|
||||
describeRoute({
|
||||
description: "Delete a part from a message",
|
||||
operationId: "part.delete",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully deleted part",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().meta({ description: "Session ID" }),
|
||||
messageID: z.string().meta({ description: "Message ID" }),
|
||||
partID: z.string().meta({ description: "Part ID" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await Session.removePart({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
partID: params.partID,
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/:sessionID/message/:messageID/part/:partID",
|
||||
describeRoute({
|
||||
description: "Update a part in a message",
|
||||
operationId: "part.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated part",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MessageV2.Part),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().meta({ description: "Session ID" }),
|
||||
messageID: z.string().meta({ description: "Message ID" }),
|
||||
partID: z.string().meta({ description: "Part ID" }),
|
||||
}),
|
||||
),
|
||||
validator("json", MessageV2.Part),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const body = c.req.valid("json")
|
||||
if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) {
|
||||
throw new Error(
|
||||
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
|
||||
)
|
||||
}
|
||||
const part = await Session.updatePart(body)
|
||||
return c.json(part)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/message",
|
||||
describeRoute({
|
||||
summary: "Send message",
|
||||
description: "Create and send a new message to a session, streaming the AI response.",
|
||||
operationId: "session.prompt",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Created message",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
info: MessageV2.Assistant,
|
||||
parts: MessageV2.Part.array(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().meta({ description: "Session ID" }),
|
||||
}),
|
||||
),
|
||||
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
c.status(200)
|
||||
c.header("Content-Type", "application/json")
|
||||
return stream(c, async (stream) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await SessionPrompt.prompt({ ...body, sessionID })
|
||||
stream.write(JSON.stringify(msg))
|
||||
})
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/prompt_async",
|
||||
describeRoute({
|
||||
summary: "Send async message",
|
||||
description:
|
||||
"Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
|
||||
operationId: "session.prompt_async",
|
||||
responses: {
|
||||
204: {
|
||||
description: "Prompt accepted",
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().meta({ description: "Session ID" }),
|
||||
}),
|
||||
),
|
||||
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
c.status(204)
|
||||
c.header("Content-Type", "application/json")
|
||||
return stream(c, async () => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
SessionPrompt.prompt({ ...body, sessionID })
|
||||
})
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/command",
|
||||
describeRoute({
|
||||
summary: "Send command",
|
||||
description: "Send a new command to a session for execution by the AI assistant.",
|
||||
operationId: "session.command",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Created message",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
info: MessageV2.Assistant,
|
||||
parts: MessageV2.Part.array(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().meta({ description: "Session ID" }),
|
||||
}),
|
||||
),
|
||||
validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await SessionPrompt.command({ ...body, sessionID })
|
||||
return c.json(msg)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/shell",
|
||||
describeRoute({
|
||||
summary: "Run shell command",
|
||||
description: "Execute a shell command within the session context and return the AI's response.",
|
||||
operationId: "session.shell",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Created message",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MessageV2.Assistant),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string().meta({ description: "Session ID" }),
|
||||
}),
|
||||
),
|
||||
validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await SessionPrompt.shell({ ...body, sessionID })
|
||||
return c.json(msg)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/revert",
|
||||
describeRoute({
|
||||
summary: "Revert message",
|
||||
description: "Revert a specific message in a session, undoing its effects and restoring the previous state.",
|
||||
operationId: "session.revert",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
validator("json", SessionRevert.RevertInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
log.info("revert", c.req.valid("json"))
|
||||
const session = await SessionRevert.revert({
|
||||
sessionID,
|
||||
...c.req.valid("json"),
|
||||
})
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/unrevert",
|
||||
describeRoute({
|
||||
summary: "Restore reverted messages",
|
||||
description: "Restore all previously reverted messages in a session.",
|
||||
operationId: "session.unrevert",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const session = await SessionRevert.unrevert({ sessionID })
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/permissions/:permissionID",
|
||||
describeRoute({
|
||||
summary: "Respond to permission",
|
||||
deprecated: true,
|
||||
description: "Approve or deny a permission request from the AI assistant.",
|
||||
operationId: "permission.respond",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Permission processed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
permissionID: z.string(),
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ response: PermissionNext.Reply })),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
PermissionNext.reply({
|
||||
requestID: params.permissionID,
|
||||
reply: c.req.valid("json").response,
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
),
|
||||
)
|
||||
379
packages/opencode/src/server/routes/tui.ts
Normal file
379
packages/opencode/src/server/routes/tui.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
import { Hono, type Context } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Bus } from "../../bus"
|
||||
import { Session } from "../../session"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { AsyncQueue } from "../../util/queue"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
const TuiRequest = z.object({
|
||||
path: z.string(),
|
||||
body: z.any(),
|
||||
})
|
||||
|
||||
type TuiRequest = z.infer<typeof TuiRequest>
|
||||
|
||||
const request = new AsyncQueue<TuiRequest>()
|
||||
const response = new AsyncQueue<any>()
|
||||
|
||||
export async function callTui(ctx: Context) {
|
||||
const body = await ctx.req.json()
|
||||
request.push({
|
||||
path: ctx.req.path,
|
||||
body,
|
||||
})
|
||||
return response.next()
|
||||
}
|
||||
|
||||
const TuiControlRoutes = new Hono()
|
||||
.get(
|
||||
"/next",
|
||||
describeRoute({
|
||||
summary: "Get next TUI request",
|
||||
description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.",
|
||||
operationId: "tui.control.next",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Next TUI request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(TuiRequest),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const req = await request.next()
|
||||
return c.json(req)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/response",
|
||||
describeRoute({
|
||||
summary: "Submit TUI response",
|
||||
description: "Submit a response to the TUI request queue to complete a pending request.",
|
||||
operationId: "tui.control.response",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Response submitted successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", z.any()),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
response.push(body)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
||||
export const TuiRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.post(
|
||||
"/append-prompt",
|
||||
describeRoute({
|
||||
summary: "Append TUI prompt",
|
||||
description: "Append prompt to the TUI",
|
||||
operationId: "tui.appendPrompt",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Prompt processed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", TuiEvent.PromptAppend.properties),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json"))
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/open-help",
|
||||
describeRoute({
|
||||
summary: "Open help dialog",
|
||||
description: "Open the help dialog in the TUI to display user assistance information.",
|
||||
operationId: "tui.openHelp",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Help dialog opened successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "help.show",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/open-sessions",
|
||||
describeRoute({
|
||||
summary: "Open sessions dialog",
|
||||
description: "Open the session dialog",
|
||||
operationId: "tui.openSessions",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session dialog opened successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "session.list",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/open-themes",
|
||||
describeRoute({
|
||||
summary: "Open themes dialog",
|
||||
description: "Open the theme dialog",
|
||||
operationId: "tui.openThemes",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Theme dialog opened successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "session.list",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/open-models",
|
||||
describeRoute({
|
||||
summary: "Open models dialog",
|
||||
description: "Open the model dialog",
|
||||
operationId: "tui.openModels",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Model dialog opened successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "model.list",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/submit-prompt",
|
||||
describeRoute({
|
||||
summary: "Submit TUI prompt",
|
||||
description: "Submit the prompt",
|
||||
operationId: "tui.submitPrompt",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Prompt submitted successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "prompt.submit",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/clear-prompt",
|
||||
describeRoute({
|
||||
summary: "Clear TUI prompt",
|
||||
description: "Clear the prompt",
|
||||
operationId: "tui.clearPrompt",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Prompt cleared successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
command: "prompt.clear",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/execute-command",
|
||||
describeRoute({
|
||||
summary: "Execute TUI command",
|
||||
description: "Execute a TUI command (e.g. agent_cycle)",
|
||||
operationId: "tui.executeCommand",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Command executed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", z.object({ command: z.string() })),
|
||||
async (c) => {
|
||||
const command = c.req.valid("json").command
|
||||
await Bus.publish(TuiEvent.CommandExecute, {
|
||||
// @ts-expect-error
|
||||
command: {
|
||||
session_new: "session.new",
|
||||
session_share: "session.share",
|
||||
session_interrupt: "session.interrupt",
|
||||
session_compact: "session.compact",
|
||||
messages_page_up: "session.page.up",
|
||||
messages_page_down: "session.page.down",
|
||||
messages_line_up: "session.line.up",
|
||||
messages_line_down: "session.line.down",
|
||||
messages_half_page_up: "session.half.page.up",
|
||||
messages_half_page_down: "session.half.page.down",
|
||||
messages_first: "session.first",
|
||||
messages_last: "session.last",
|
||||
agent_cycle: "agent.cycle",
|
||||
}[command],
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/show-toast",
|
||||
describeRoute({
|
||||
summary: "Show TUI toast",
|
||||
description: "Show a toast notification in the TUI",
|
||||
operationId: "tui.showToast",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Toast notification shown successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", TuiEvent.ToastShow.properties),
|
||||
async (c) => {
|
||||
await Bus.publish(TuiEvent.ToastShow, c.req.valid("json"))
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/publish",
|
||||
describeRoute({
|
||||
summary: "Publish TUI event",
|
||||
description: "Publish a TUI event",
|
||||
operationId: "tui.publish",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event published successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.union(
|
||||
Object.values(TuiEvent).map((def) => {
|
||||
return z
|
||||
.object({
|
||||
type: z.literal(def.type),
|
||||
properties: def.properties,
|
||||
})
|
||||
.meta({
|
||||
ref: "Event" + "." + def.type,
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
async (c) => {
|
||||
const evt = c.req.valid("json")
|
||||
await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/select-session",
|
||||
describeRoute({
|
||||
summary: "Select session",
|
||||
description: "Navigate the TUI to display the specified session.",
|
||||
operationId: "tui.selectSession",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Session selected successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator("json", TuiEvent.SessionSelect.properties),
|
||||
async (c) => {
|
||||
const { sessionID } = c.req.valid("json")
|
||||
await Session.get(sessionID)
|
||||
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.route("/control", TuiControlRoutes),
|
||||
)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,71 +0,0 @@
|
|||
import { Hono, type Context } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { z } from "zod"
|
||||
import { AsyncQueue } from "../util/queue"
|
||||
|
||||
const TuiRequest = z.object({
|
||||
path: z.string(),
|
||||
body: z.any(),
|
||||
})
|
||||
|
||||
type TuiRequest = z.infer<typeof TuiRequest>
|
||||
|
||||
const request = new AsyncQueue<TuiRequest>()
|
||||
const response = new AsyncQueue<any>()
|
||||
|
||||
export async function callTui(ctx: Context) {
|
||||
const body = await ctx.req.json()
|
||||
request.push({
|
||||
path: ctx.req.path,
|
||||
body,
|
||||
})
|
||||
return response.next()
|
||||
}
|
||||
|
||||
export const TuiRoute = new Hono()
|
||||
.get(
|
||||
"/next",
|
||||
describeRoute({
|
||||
summary: "Get next TUI request",
|
||||
description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.",
|
||||
operationId: "tui.control.next",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Next TUI request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(TuiRequest),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const req = await request.next()
|
||||
return c.json(req)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/response",
|
||||
describeRoute({
|
||||
summary: "Submit TUI response",
|
||||
description: "Submit a response to the TUI request queue to complete a pending request.",
|
||||
operationId: "tui.control.response",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Response submitted successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", z.any()),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
response.push(body)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
|
@ -1,14 +1,7 @@
|
|||
import { BusEvent } from "@/bus/bus-event"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import {
|
||||
APICallError,
|
||||
convertToModelMessages,
|
||||
LoadAPIKeyError,
|
||||
type ModelMessage,
|
||||
type UIMessage,
|
||||
type ToolSet,
|
||||
} from "ai"
|
||||
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
|
||||
import { Identifier } from "../id/id"
|
||||
import { LSP } from "../lsp"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
|
|
@ -439,7 +432,7 @@ export namespace MessageV2 {
|
|||
})
|
||||
export type WithParts = z.infer<typeof WithParts>
|
||||
|
||||
export function toModelMessage(input: WithParts[], options?: { tools?: ToolSet }): ModelMessage[] {
|
||||
export function toModelMessage(input: WithParts[]): ModelMessage[] {
|
||||
const result: UIMessage[] = []
|
||||
|
||||
for (const msg of input) {
|
||||
|
|
@ -510,14 +503,30 @@ export namespace MessageV2 {
|
|||
})
|
||||
if (part.type === "tool") {
|
||||
if (part.state.status === "completed") {
|
||||
if (part.state.attachments?.length) {
|
||||
result.push({
|
||||
id: Identifier.ascending("message"),
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `The tool ${part.tool} returned the following attachments:`,
|
||||
},
|
||||
...part.state.attachments.map((attachment) => ({
|
||||
type: "file" as const,
|
||||
url: attachment.url,
|
||||
mediaType: attachment.mime,
|
||||
filename: attachment.filename,
|
||||
})),
|
||||
],
|
||||
})
|
||||
}
|
||||
assistantMessage.parts.push({
|
||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||
state: "output-available",
|
||||
toolCallId: part.callID,
|
||||
input: part.state.input,
|
||||
output: part.state.time.compacted
|
||||
? "[Old tool result content cleared]"
|
||||
: { output: part.state.output, attachments: part.state.attachments },
|
||||
output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output,
|
||||
callProviderMetadata: part.metadata,
|
||||
})
|
||||
}
|
||||
|
|
@ -556,12 +565,7 @@ export namespace MessageV2 {
|
|||
}
|
||||
}
|
||||
|
||||
return convertToModelMessages(
|
||||
result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")),
|
||||
{
|
||||
tools: options?.tools,
|
||||
},
|
||||
)
|
||||
return convertToModelMessages(result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")))
|
||||
}
|
||||
|
||||
export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
|
||||
|
|
|
|||
|
|
@ -597,7 +597,7 @@ export namespace SessionPrompt {
|
|||
sessionID,
|
||||
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
|
||||
messages: [
|
||||
...MessageV2.toModelMessage(sessionMessages, { tools }),
|
||||
...MessageV2.toModelMessage(sessionMessages),
|
||||
...(isLastStep
|
||||
? [
|
||||
{
|
||||
|
|
@ -685,7 +685,10 @@ export namespace SessionPrompt {
|
|||
},
|
||||
})
|
||||
|
||||
for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
|
||||
for (const item of await ToolRegistry.tools(
|
||||
{ modelID: input.model.api.id, providerID: input.model.providerID },
|
||||
input.agent,
|
||||
)) {
|
||||
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
|
||||
tools[item.id] = tool({
|
||||
id: item.id as any,
|
||||
|
|
@ -716,18 +719,10 @@ export namespace SessionPrompt {
|
|||
)
|
||||
return result
|
||||
},
|
||||
toModelOutput(result: { output: string; attachments?: MessageV2.FilePart[] }) {
|
||||
if (!result.attachments?.length) return { type: "text", value: result.output }
|
||||
toModelOutput(result) {
|
||||
return {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: result.output },
|
||||
...result.attachments.map((a) => ({
|
||||
type: "media" as const,
|
||||
data: a.url.slice(a.url.indexOf(",") + 1),
|
||||
mediaType: a.mime,
|
||||
})),
|
||||
],
|
||||
type: "text",
|
||||
value: result.output,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -814,18 +809,10 @@ export namespace SessionPrompt {
|
|||
content: result.content, // directly return content to preserve ordering when outputting to model
|
||||
}
|
||||
}
|
||||
item.toModelOutput = (result: { output: string; attachments?: MessageV2.FilePart[] }) => {
|
||||
if (!result.attachments?.length) return { type: "text", value: result.output }
|
||||
item.toModelOutput = (result) => {
|
||||
return {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: result.output },
|
||||
...result.attachments.map((a) => ({
|
||||
type: "media" as const,
|
||||
data: a.url.slice(a.url.indexOf(",") + 1),
|
||||
mediaType: a.mime,
|
||||
})),
|
||||
],
|
||||
type: "text",
|
||||
value: result.output,
|
||||
}
|
||||
}
|
||||
tools[key] = item
|
||||
|
|
@ -1715,6 +1702,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||
: await lastModel(input.sessionID)
|
||||
: taskModel
|
||||
|
||||
await Plugin.trigger(
|
||||
"command.execute.before",
|
||||
{
|
||||
command: input.command,
|
||||
sessionID: input.sessionID,
|
||||
arguments: input.arguments,
|
||||
},
|
||||
{ parts },
|
||||
)
|
||||
|
||||
const result = (await prompt({
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
|
|||
## Editing constraints
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
- Only add comments if they are necessary to make a non-obvious block easier to understand.
|
||||
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
|
||||
## Tool usage
|
||||
- Prefer specialized tools over shell for file operations:
|
||||
|
|
|
|||
|
|
@ -6,9 +6,46 @@ import { Global } from "../global"
|
|||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Scheduler } from "../scheduler"
|
||||
|
||||
export namespace Snapshot {
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const hour = 60 * 60 * 1000
|
||||
const prune = "7.days"
|
||||
|
||||
export function init() {
|
||||
Scheduler.register({
|
||||
id: "snapshot.cleanup",
|
||||
interval: hour,
|
||||
run: cleanup,
|
||||
scope: "instance",
|
||||
})
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
if (Instance.project.vcs !== "git") return
|
||||
const cfg = await Config.get()
|
||||
if (cfg.snapshot === false) return
|
||||
const git = gitdir()
|
||||
const exists = await fs
|
||||
.stat(git)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) return
|
||||
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
if (result.exitCode !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
}
|
||||
|
||||
export async function track() {
|
||||
if (Instance.project.vcs !== "git") return
|
||||
|
|
|
|||
278
packages/opencode/src/tool/apply_patch.ts
Normal file
278
packages/opencode/src/tool/apply_patch.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Tool } from "./tool"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Bus } from "../bus"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Patch } from "../patch"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { trimDiff } from "./edit"
|
||||
import { LSP } from "../lsp"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import DESCRIPTION from "./apply_patch.txt"
|
||||
|
||||
const PatchParams = z.object({
|
||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||
})
|
||||
|
||||
export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||
description: DESCRIPTION,
|
||||
parameters: PatchParams,
|
||||
async execute(params, ctx) {
|
||||
if (!params.patchText) {
|
||||
throw new Error("patchText is required")
|
||||
}
|
||||
|
||||
// Parse the patch to get hunks
|
||||
let hunks: Patch.Hunk[]
|
||||
try {
|
||||
const parseResult = Patch.parsePatch(params.patchText)
|
||||
hunks = parseResult.hunks
|
||||
} catch (error) {
|
||||
throw new Error(`apply_patch verification failed: ${error}`)
|
||||
}
|
||||
|
||||
if (hunks.length === 0) {
|
||||
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
|
||||
if (normalized === "*** Begin Patch\n*** End Patch") {
|
||||
throw new Error("patch rejected: empty patch")
|
||||
}
|
||||
throw new Error("apply_patch verification failed: no hunks found")
|
||||
}
|
||||
|
||||
// Validate file paths and check permissions
|
||||
const fileChanges: Array<{
|
||||
filePath: string
|
||||
oldContent: string
|
||||
newContent: string
|
||||
type: "add" | "update" | "delete" | "move"
|
||||
movePath?: string
|
||||
diff: string
|
||||
additions: number
|
||||
deletions: number
|
||||
}> = []
|
||||
|
||||
let totalDiff = ""
|
||||
|
||||
for (const hunk of hunks) {
|
||||
const filePath = path.resolve(Instance.directory, hunk.path)
|
||||
await assertExternalDirectory(ctx, filePath)
|
||||
|
||||
switch (hunk.type) {
|
||||
case "add": {
|
||||
const oldContent = ""
|
||||
const newContent =
|
||||
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
|
||||
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
|
||||
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const change of diffLines(oldContent, newContent)) {
|
||||
if (change.added) additions += change.count || 0
|
||||
if (change.removed) deletions += change.count || 0
|
||||
}
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
type: "add",
|
||||
diff,
|
||||
additions,
|
||||
deletions,
|
||||
})
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
break
|
||||
}
|
||||
|
||||
case "update": {
|
||||
// Check if file exists for update
|
||||
const stats = await fs.stat(filePath).catch(() => null)
|
||||
if (!stats || stats.isDirectory()) {
|
||||
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
|
||||
}
|
||||
|
||||
// Read file and update time tracking (like edit tool does)
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
const oldContent = await fs.readFile(filePath, "utf-8")
|
||||
let newContent = oldContent
|
||||
|
||||
// Apply the update chunks to get new content
|
||||
try {
|
||||
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
|
||||
newContent = fileUpdate.content
|
||||
} catch (error) {
|
||||
throw new Error(`apply_patch verification failed: ${error}`)
|
||||
}
|
||||
|
||||
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
|
||||
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const change of diffLines(oldContent, newContent)) {
|
||||
if (change.added) additions += change.count || 0
|
||||
if (change.removed) deletions += change.count || 0
|
||||
}
|
||||
|
||||
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
|
||||
await assertExternalDirectory(ctx, movePath)
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
type: hunk.move_path ? "move" : "update",
|
||||
movePath,
|
||||
diff,
|
||||
additions,
|
||||
deletions,
|
||||
})
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
break
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => {
|
||||
throw new Error(`apply_patch verification failed: ${error}`)
|
||||
})
|
||||
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
|
||||
|
||||
const deletions = contentToDelete.split("\n").length
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent: contentToDelete,
|
||||
newContent: "",
|
||||
type: "delete",
|
||||
diff: deleteDiff,
|
||||
additions: 0,
|
||||
deletions,
|
||||
})
|
||||
|
||||
totalDiff += deleteDiff + "\n"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check permissions if needed
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
diff: totalDiff,
|
||||
},
|
||||
})
|
||||
|
||||
// Apply the changes
|
||||
const changedFiles: string[] = []
|
||||
|
||||
for (const change of fileChanges) {
|
||||
switch (change.type) {
|
||||
case "add":
|
||||
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||
await fs.mkdir(path.dirname(change.filePath), { recursive: true })
|
||||
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
||||
changedFiles.push(change.filePath)
|
||||
break
|
||||
|
||||
case "update":
|
||||
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
||||
changedFiles.push(change.filePath)
|
||||
break
|
||||
|
||||
case "move":
|
||||
if (change.movePath) {
|
||||
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||
await fs.mkdir(path.dirname(change.movePath), { recursive: true })
|
||||
await fs.writeFile(change.movePath, change.newContent, "utf-8")
|
||||
await fs.unlink(change.filePath)
|
||||
changedFiles.push(change.movePath)
|
||||
}
|
||||
break
|
||||
|
||||
case "delete":
|
||||
await fs.unlink(change.filePath)
|
||||
changedFiles.push(change.filePath)
|
||||
break
|
||||
}
|
||||
|
||||
// Update file time tracking
|
||||
FileTime.read(ctx.sessionID, change.filePath)
|
||||
if (change.movePath) {
|
||||
FileTime.read(ctx.sessionID, change.movePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish file change events
|
||||
for (const filePath of changedFiles) {
|
||||
await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
|
||||
}
|
||||
|
||||
// Notify LSP of file changes and collect diagnostics
|
||||
for (const change of fileChanges) {
|
||||
if (change.type === "delete") continue
|
||||
const target = change.movePath ?? change.filePath
|
||||
await LSP.touchFile(target, true)
|
||||
}
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
|
||||
// Generate output summary
|
||||
const summaryLines = fileChanges.map((change) => {
|
||||
if (change.type === "add") {
|
||||
return `A ${path.relative(Instance.worktree, change.filePath)}`
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
return `D ${path.relative(Instance.worktree, change.filePath)}`
|
||||
}
|
||||
const target = change.movePath ?? change.filePath
|
||||
return `M ${path.relative(Instance.worktree, target)}`
|
||||
})
|
||||
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
|
||||
|
||||
// Report LSP errors for changed files
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
for (const change of fileChanges) {
|
||||
if (change.type === "delete") continue
|
||||
const target = change.movePath ?? change.filePath
|
||||
const normalized = Filesystem.normalizePath(target)
|
||||
const issues = diagnostics[normalized] ?? []
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length > 0) {
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target)}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
}
|
||||
|
||||
// Build per-file metadata for UI rendering
|
||||
const files = fileChanges.map((change) => ({
|
||||
filePath: change.filePath,
|
||||
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath),
|
||||
type: change.type,
|
||||
diff: change.diff,
|
||||
before: change.oldContent,
|
||||
after: change.newContent,
|
||||
additions: change.additions,
|
||||
deletions: change.deletions,
|
||||
movePath: change.movePath,
|
||||
}))
|
||||
|
||||
return {
|
||||
title: output,
|
||||
metadata: {
|
||||
diff: totalDiff,
|
||||
files,
|
||||
diagnostics,
|
||||
},
|
||||
output,
|
||||
}
|
||||
},
|
||||
})
|
||||
33
packages/opencode/src/tool/apply_patch.txt
Normal file
33
packages/opencode/src/tool/apply_patch.txt
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
|
||||
|
||||
*** Begin Patch
|
||||
[ one or more file sections ]
|
||||
*** End Patch
|
||||
|
||||
Within that envelope, you get a sequence of file operations.
|
||||
You MUST include a header to specify the action you are taking.
|
||||
Each operation starts with one of three headers:
|
||||
|
||||
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
|
||||
*** Delete File: <path> - remove an existing file. Nothing follows.
|
||||
*** Update File: <path> - patch an existing file in place (optionally with a rename).
|
||||
|
||||
Example patch:
|
||||
|
||||
```
|
||||
*** Begin Patch
|
||||
*** Add File: hello.txt
|
||||
+Hello world
|
||||
*** Update File: src/app.py
|
||||
*** Move to: src/main.py
|
||||
@@ def greet():
|
||||
-print("Hi")
|
||||
+print("Hello, world!")
|
||||
*** Delete File: obsolete.txt
|
||||
*** End Patch
|
||||
```
|
||||
|
||||
It is important to remember:
|
||||
|
||||
- You must include a header with your intended action (Add/Delete/Update)
|
||||
- You must prefix new lines with `+` even when creating a new file
|
||||
|
|
@ -37,7 +37,7 @@ export const BatchTool = Tool.define("batch", async () => {
|
|||
const discardedCalls = params.tool_calls.slice(10)
|
||||
|
||||
const { ToolRegistry } = await import("./registry")
|
||||
const availableTools = await ToolRegistry.tools("")
|
||||
const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" })
|
||||
const toolMap = new Map(availableTools.map((t) => [t.id, t]))
|
||||
|
||||
const executeCall = async (call: (typeof toolCalls)[0]) => {
|
||||
|
|
|
|||
|
|
@ -1,201 +0,0 @@
|
|||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Tool } from "./tool"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Bus } from "../bus"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Patch } from "../patch"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
|
||||
const PatchParams = z.object({
|
||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||
})
|
||||
|
||||
export const PatchTool = Tool.define("patch", {
|
||||
description:
|
||||
"Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.",
|
||||
parameters: PatchParams,
|
||||
async execute(params, ctx) {
|
||||
if (!params.patchText) {
|
||||
throw new Error("patchText is required")
|
||||
}
|
||||
|
||||
// Parse the patch to get hunks
|
||||
let hunks: Patch.Hunk[]
|
||||
try {
|
||||
const parseResult = Patch.parsePatch(params.patchText)
|
||||
hunks = parseResult.hunks
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse patch: ${error}`)
|
||||
}
|
||||
|
||||
if (hunks.length === 0) {
|
||||
throw new Error("No file changes found in patch")
|
||||
}
|
||||
|
||||
// Validate file paths and check permissions
|
||||
const fileChanges: Array<{
|
||||
filePath: string
|
||||
oldContent: string
|
||||
newContent: string
|
||||
type: "add" | "update" | "delete" | "move"
|
||||
movePath?: string
|
||||
}> = []
|
||||
|
||||
let totalDiff = ""
|
||||
|
||||
for (const hunk of hunks) {
|
||||
const filePath = path.resolve(Instance.directory, hunk.path)
|
||||
await assertExternalDirectory(ctx, filePath)
|
||||
|
||||
switch (hunk.type) {
|
||||
case "add":
|
||||
if (hunk.type === "add") {
|
||||
const oldContent = ""
|
||||
const newContent = hunk.contents
|
||||
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
type: "add",
|
||||
})
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
}
|
||||
break
|
||||
|
||||
case "update":
|
||||
// Check if file exists for update
|
||||
const stats = await fs.stat(filePath).catch(() => null)
|
||||
if (!stats || stats.isDirectory()) {
|
||||
throw new Error(`File not found or is directory: ${filePath}`)
|
||||
}
|
||||
|
||||
// Read file and update time tracking (like edit tool does)
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
const oldContent = await fs.readFile(filePath, "utf-8")
|
||||
let newContent = oldContent
|
||||
|
||||
// Apply the update chunks to get new content
|
||||
try {
|
||||
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
|
||||
newContent = fileUpdate.content
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to apply update to ${filePath}: ${error}`)
|
||||
}
|
||||
|
||||
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
|
||||
|
||||
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
|
||||
await assertExternalDirectory(ctx, movePath)
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
type: hunk.move_path ? "move" : "update",
|
||||
movePath,
|
||||
})
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
break
|
||||
|
||||
case "delete":
|
||||
// Check if file exists for deletion
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
const contentToDelete = await fs.readFile(filePath, "utf-8")
|
||||
const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "")
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent: contentToDelete,
|
||||
newContent: "",
|
||||
type: "delete",
|
||||
})
|
||||
|
||||
totalDiff += deleteDiff + "\n"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check permissions if needed
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
diff: totalDiff,
|
||||
},
|
||||
})
|
||||
|
||||
// Apply the changes
|
||||
const changedFiles: string[] = []
|
||||
|
||||
for (const change of fileChanges) {
|
||||
switch (change.type) {
|
||||
case "add":
|
||||
// Create parent directories
|
||||
const addDir = path.dirname(change.filePath)
|
||||
if (addDir !== "." && addDir !== "/") {
|
||||
await fs.mkdir(addDir, { recursive: true })
|
||||
}
|
||||
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
||||
changedFiles.push(change.filePath)
|
||||
break
|
||||
|
||||
case "update":
|
||||
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
||||
changedFiles.push(change.filePath)
|
||||
break
|
||||
|
||||
case "move":
|
||||
if (change.movePath) {
|
||||
// Create parent directories for destination
|
||||
const moveDir = path.dirname(change.movePath)
|
||||
if (moveDir !== "." && moveDir !== "/") {
|
||||
await fs.mkdir(moveDir, { recursive: true })
|
||||
}
|
||||
// Write to new location
|
||||
await fs.writeFile(change.movePath, change.newContent, "utf-8")
|
||||
// Remove original
|
||||
await fs.unlink(change.filePath)
|
||||
changedFiles.push(change.movePath)
|
||||
}
|
||||
break
|
||||
|
||||
case "delete":
|
||||
await fs.unlink(change.filePath)
|
||||
changedFiles.push(change.filePath)
|
||||
break
|
||||
}
|
||||
|
||||
// Update file time tracking
|
||||
FileTime.read(ctx.sessionID, change.filePath)
|
||||
if (change.movePath) {
|
||||
FileTime.read(ctx.sessionID, change.movePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish file change events
|
||||
for (const filePath of changedFiles) {
|
||||
await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
|
||||
}
|
||||
|
||||
// Generate output summary
|
||||
const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath))
|
||||
const summary = `${fileChanges.length} files changed`
|
||||
|
||||
return {
|
||||
title: summary,
|
||||
metadata: {
|
||||
diff: totalDiff,
|
||||
},
|
||||
output: `Patch applied successfully. ${summary}:\n${relativePaths.map((p) => ` ${p}`).join("\n")}`,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -1 +0,0 @@
|
|||
do not use
|
||||
|
|
@ -59,7 +59,9 @@ export const ReadTool = Tool.define("read", {
|
|||
throw new Error(`File not found: ${filepath}`)
|
||||
}
|
||||
|
||||
const isImage = file.type.startsWith("image/") && file.type !== "image/svg+xml"
|
||||
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
|
||||
const isImage =
|
||||
file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet"
|
||||
const isPdf = file.type === "application/pdf"
|
||||
if (isImage || isPdf) {
|
||||
const mime = file.type
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { Log } from "@/util/log"
|
|||
import { LspTool } from "./lsp"
|
||||
import { Truncate } from "./truncation"
|
||||
import { PlanExitTool, PlanEnterTool } from "./plan"
|
||||
import { ApplyPatchTool } from "./apply_patch"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
|
|
@ -108,6 +109,7 @@ export namespace ToolRegistry {
|
|||
WebSearchTool,
|
||||
CodeSearchTool,
|
||||
SkillTool,
|
||||
ApplyPatchTool,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
||||
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
|
||||
|
|
@ -119,15 +121,33 @@ export namespace ToolRegistry {
|
|||
return all().then((x) => x.map((t) => t.id))
|
||||
}
|
||||
|
||||
export async function tools(providerID: string, agent?: Agent.Info) {
|
||||
export async function tools(
|
||||
model: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
},
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
const tools = await all()
|
||||
const result = await Promise.all(
|
||||
tools
|
||||
.filter((t) => {
|
||||
// Enable websearch/codesearch for zen users OR via enable flag
|
||||
if (t.id === "codesearch" || t.id === "websearch") {
|
||||
return providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
|
||||
return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
|
||||
}
|
||||
|
||||
// use apply tool in same format as codex
|
||||
const usePatch =
|
||||
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
||||
if (t.id === "apply_patch") return usePatch
|
||||
if (t.id === "edit" || t.id === "write") return !usePatch
|
||||
|
||||
// omit todo tools for openai models
|
||||
if (t.id === "todoread" || t.id === "todowrite") {
|
||||
if (model.modelID.includes("gpt-")) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.map(async (t) => {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import fs from "fs/promises"
|
|||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import { Identifier } from "../id/id"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { PermissionNext } from "../permission/next"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { Scheduler } from "../scheduler"
|
||||
|
||||
export namespace Truncate {
|
||||
export const MAX_LINES = 2000
|
||||
|
|
@ -12,6 +12,7 @@ export namespace Truncate {
|
|||
export const DIR = path.join(Global.Path.data, "tool-output")
|
||||
export const GLOB = path.join(DIR, "*")
|
||||
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
|
||||
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
||||
|
||||
|
|
@ -21,6 +22,15 @@ export namespace Truncate {
|
|||
direction?: "head" | "tail"
|
||||
}
|
||||
|
||||
export function init() {
|
||||
Scheduler.register({
|
||||
id: "tool.truncation.cleanup",
|
||||
interval: HOUR_MS,
|
||||
run: cleanup,
|
||||
scope: "global",
|
||||
})
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
|
||||
const glob = new Bun.Glob("tool_*")
|
||||
|
|
@ -31,8 +41,6 @@ export namespace Truncate {
|
|||
}
|
||||
}
|
||||
|
||||
const init = lazy(cleanup)
|
||||
|
||||
function hasTaskTool(agent?: Agent.Info): boolean {
|
||||
if (!agent?.permission) return false
|
||||
const rule = PermissionNext.evaluate("task", "*", agent.permission)
|
||||
|
|
@ -81,7 +89,6 @@ export namespace Truncate {
|
|||
const unit = hitBytes ? "bytes" : "lines"
|
||||
const preview = out.join("\n")
|
||||
|
||||
await init()
|
||||
const id = Identifier.ascending("tool")
|
||||
const filepath = path.join(DIR, id)
|
||||
await Bun.write(Bun.file(filepath), text)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,26 @@ export namespace Worktree {
|
|||
|
||||
export type CreateInput = z.infer<typeof CreateInput>
|
||||
|
||||
export const RemoveInput = z
|
||||
.object({
|
||||
directory: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "WorktreeRemoveInput",
|
||||
})
|
||||
|
||||
export type RemoveInput = z.infer<typeof RemoveInput>
|
||||
|
||||
export const ResetInput = z
|
||||
.object({
|
||||
directory: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "WorktreeResetInput",
|
||||
})
|
||||
|
||||
export type ResetInput = z.infer<typeof ResetInput>
|
||||
|
||||
export const NotGitError = NamedError.create(
|
||||
"WorktreeNotGitError",
|
||||
z.object({
|
||||
|
|
@ -61,6 +81,20 @@ export namespace Worktree {
|
|||
}),
|
||||
)
|
||||
|
||||
export const RemoveFailedError = NamedError.create(
|
||||
"WorktreeRemoveFailedError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const ResetFailedError = NamedError.create(
|
||||
"WorktreeResetFailedError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
const ADJECTIVES = [
|
||||
"brave",
|
||||
"calm",
|
||||
|
|
@ -214,4 +248,177 @@ export namespace Worktree {
|
|||
|
||||
return info
|
||||
})
|
||||
|
||||
export const remove = fn(RemoveInput, async (input) => {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
|
||||
const directory = path.resolve(input.directory)
|
||||
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
|
||||
if (list.exitCode !== 0) {
|
||||
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
|
||||
}
|
||||
|
||||
const lines = outputText(list.stdout)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
|
||||
if (!line) return acc
|
||||
if (line.startsWith("worktree ")) {
|
||||
acc.push({ path: line.slice("worktree ".length).trim() })
|
||||
return acc
|
||||
}
|
||||
const current = acc[acc.length - 1]
|
||||
if (!current) return acc
|
||||
if (line.startsWith("branch ")) {
|
||||
current.branch = line.slice("branch ".length).trim()
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
|
||||
if (!entry?.path) {
|
||||
throw new RemoveFailedError({ message: "Worktree not found" })
|
||||
}
|
||||
|
||||
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
if (removed.exitCode !== 0) {
|
||||
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
|
||||
}
|
||||
|
||||
const branch = entry.branch?.replace(/^refs\/heads\//, "")
|
||||
if (branch) {
|
||||
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
if (deleted.exitCode !== 0) {
|
||||
throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export const reset = fn(ResetInput, async (input) => {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
|
||||
const directory = path.resolve(input.directory)
|
||||
if (directory === path.resolve(Instance.worktree)) {
|
||||
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
||||
}
|
||||
|
||||
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
|
||||
if (list.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
|
||||
}
|
||||
|
||||
const lines = outputText(list.stdout)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
|
||||
if (!line) return acc
|
||||
if (line.startsWith("worktree ")) {
|
||||
acc.push({ path: line.slice("worktree ".length).trim() })
|
||||
return acc
|
||||
}
|
||||
const current = acc[acc.length - 1]
|
||||
if (!current) return acc
|
||||
if (line.startsWith("branch ")) {
|
||||
current.branch = line.slice("branch ".length).trim()
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
|
||||
if (!entry?.path) {
|
||||
throw new ResetFailedError({ message: "Worktree not found" })
|
||||
}
|
||||
|
||||
const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
|
||||
if (remoteList.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
|
||||
}
|
||||
|
||||
const remotes = outputText(remoteList.stdout)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const remote = remotes.includes("origin")
|
||||
? "origin"
|
||||
: remotes.length === 1
|
||||
? remotes[0]
|
||||
: remotes.includes("upstream")
|
||||
? "upstream"
|
||||
: ""
|
||||
|
||||
const remoteHead = remote
|
||||
? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
|
||||
: { exitCode: 1, stdout: undefined, stderr: undefined }
|
||||
|
||||
const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
|
||||
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
|
||||
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
|
||||
|
||||
const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(Instance.worktree)
|
||||
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
|
||||
|
||||
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
|
||||
if (!target) {
|
||||
throw new ResetFailedError({ message: "Default branch not found" })
|
||||
}
|
||||
|
||||
if (remoteBranch) {
|
||||
const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
if (fetch.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry.path) {
|
||||
throw new ResetFailedError({ message: "Worktree path not found" })
|
||||
}
|
||||
|
||||
const worktreePath = entry.path
|
||||
|
||||
const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath)
|
||||
if (resetToTarget.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
|
||||
}
|
||||
|
||||
const clean = await $`git clean -fdx`.quiet().nothrow().cwd(worktreePath)
|
||||
if (clean.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
|
||||
}
|
||||
|
||||
const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath)
|
||||
if (update.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
|
||||
}
|
||||
|
||||
const subReset = await $`git submodule foreach --recursive git reset --hard`.quiet().nothrow().cwd(worktreePath)
|
||||
if (subReset.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
|
||||
}
|
||||
|
||||
const subClean = await $`git submodule foreach --recursive git clean -fdx`.quiet().nothrow().cwd(worktreePath)
|
||||
if (subClean.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
|
||||
}
|
||||
|
||||
const status = await $`git status --porcelain=v1`.quiet().nothrow().cwd(worktreePath)
|
||||
if (status.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
|
||||
}
|
||||
|
||||
const dirty = outputText(status.stdout)
|
||||
if (!dirty) return true
|
||||
|
||||
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue