mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 18:38:50 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60124ac57d | ||
|
|
3bb0d7fda0 | ||
|
|
8977881e09 | ||
|
|
e6c9b6bef7 |
@@ -6,17 +6,21 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Show } from "solid-js"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import {
|
||||
type ServerDomainController,
|
||||
type ServerFormController,
|
||||
useServerDomainController,
|
||||
useServerFormController,
|
||||
} from "@/components/server/server-management-controller"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
|
||||
interface ServerFormProps {
|
||||
value: string
|
||||
@@ -35,6 +39,76 @@ interface ServerFormProps {
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
const key = await platform.getDefaultServer?.()
|
||||
if (!key) return null
|
||||
return key
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultUrlActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
|
||||
}
|
||||
|
||||
function useServerPreview() {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return false
|
||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||
if (!host) return false
|
||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||
return host.includes(".") || host.includes(":")
|
||||
}
|
||||
|
||||
const previewStatus = async (
|
||||
value: string,
|
||||
username: string,
|
||||
password: string,
|
||||
setStatus: (value: boolean | undefined) => void,
|
||||
) => {
|
||||
setStatus(undefined)
|
||||
if (!looksComplete(value)) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (username) http.username = username
|
||||
if (password) http.password = password
|
||||
const result = await checkServerHealth(http)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
return { previewStatus }
|
||||
}
|
||||
|
||||
function ServerForm(props: ServerFormProps) {
|
||||
const language = useLanguage()
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
@@ -102,40 +176,385 @@ function ServerForm(props: ServerFormProps) {
|
||||
|
||||
export function DialogSelectServer() {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const domain = useServerDomainController({ onSelect: () => dialog.close() })
|
||||
const form = useServerFormController({ onSelect: () => dialog.close() })
|
||||
const title = () => {
|
||||
if (!form.state.open()) return language.t("dialog.server.title")
|
||||
return (
|
||||
<div class="flex items-center gap-2 -ml-2">
|
||||
<IconButton icon="arrow-left" variant="ghost" onClick={form.reset} aria-label={language.t("common.goBack")} />
|
||||
<span>
|
||||
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const controller = useServerManagementController({ onSelect: dialog.close })
|
||||
|
||||
return (
|
||||
<Dialog title={title()}>
|
||||
<Dialog title={controller.formTitle()}>
|
||||
<div class="flex flex-1 min-h-0 flex-col px-5">
|
||||
<Show
|
||||
when={form.state.open()}
|
||||
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
|
||||
>
|
||||
<ServerConnectionForm form={form} />
|
||||
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
|
||||
<ServerConnectionForm controller={controller} />
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerConnectionList(props: {
|
||||
domain: ServerDomainController
|
||||
onAdd: () => void
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
}) {
|
||||
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||
const { previewStatus } = useServerPreview()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [store, setStore] = createStore({
|
||||
addServer: {
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
editServer: {
|
||||
id: undefined as string | undefined,
|
||||
value: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const resetAdd = () => {
|
||||
setStore("addServer", {
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
const resetEdit = () => {
|
||||
setStore("editServer", {
|
||||
id: undefined,
|
||||
value: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const addMutation = useMutation(() => ({
|
||||
mutationFn: async (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
http: { url: normalized },
|
||||
}
|
||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
if (options.navigateOnAdd === false) {
|
||||
server.add(conn)
|
||||
options.onSelect?.()
|
||||
return
|
||||
}
|
||||
await select(conn, true)
|
||||
},
|
||||
}))
|
||||
|
||||
const editMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
||||
if (input.original.type !== "http") return
|
||||
const normalized = normalizeServerUrl(input.value)
|
||||
if (!normalized) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const name = store.editServer.name.trim() || undefined
|
||||
const username = store.editServer.username || undefined
|
||||
const password = store.editServer.password || undefined
|
||||
const existingName = input.original.displayName
|
||||
if (
|
||||
normalized === input.original.http.url &&
|
||||
name === existingName &&
|
||||
username === input.original.http.username &&
|
||||
password === input.original.http.password
|
||||
) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (normalized === input.original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
replaceServer(input.original, conn)
|
||||
}
|
||||
|
||||
resetEdit()
|
||||
},
|
||||
}))
|
||||
|
||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||
const originalKey = ServerConnection.key(original)
|
||||
const active = server.key
|
||||
tabs.removeServer(originalKey)
|
||||
const newConn = server.add(next)
|
||||
if (!newConn) return
|
||||
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
|
||||
if (nextActive) server.setActive(nextActive)
|
||||
server.remove(originalKey)
|
||||
}
|
||||
|
||||
const items = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
return [current, ...list.filter((x) => x !== current)]
|
||||
})
|
||||
|
||||
const settings = useSettings()
|
||||
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||
settings.general.newLayoutDesigns()
|
||||
? undefined
|
||||
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
|
||||
)
|
||||
|
||||
const sortedItems = createMemo(() => {
|
||||
const raw = items()
|
||||
const list = raw
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff =
|
||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
||||
options.onSelect?.()
|
||||
if (persist && conn.type === "http") {
|
||||
server.add(conn)
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||
}
|
||||
|
||||
const handleAddChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { url: value, error: "" })
|
||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddNameChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleAddUsernameChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { username: value, error: "" })
|
||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddPasswordChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { password: value, error: "" })
|
||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { value, error: "" })
|
||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditNameChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleEditUsernameChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { username: value, error: "" })
|
||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditPasswordChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { password: value, error: "" })
|
||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||
if (store.editServer.id) return "edit"
|
||||
if (store.addServer.showForm) return "add"
|
||||
return "list"
|
||||
})
|
||||
|
||||
const editing = createMemo(() => {
|
||||
if (!store.editServer.id) return
|
||||
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
resetAdd()
|
||||
resetEdit()
|
||||
}
|
||||
|
||||
const startAdd = () => {
|
||||
resetEdit()
|
||||
setStore("addServer", {
|
||||
showForm: true,
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const startEdit = (conn: ServerConnection.Http) => {
|
||||
resetAdd()
|
||||
setStore("editServer", {
|
||||
id: conn.http.url,
|
||||
value: conn.http.url,
|
||||
name: conn.displayName ?? "",
|
||||
username: conn.http.username ?? "",
|
||||
password: conn.http.password ?? "",
|
||||
error: "",
|
||||
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
|
||||
})
|
||||
}
|
||||
|
||||
const submitForm = () => {
|
||||
if (mode() === "add") {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { error: "" })
|
||||
addMutation.mutate(store.addServer.url)
|
||||
return
|
||||
}
|
||||
const original = editing()
|
||||
if (!original) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { error: "" })
|
||||
editMutation.mutate({ original, value: store.editServer.value })
|
||||
}
|
||||
|
||||
const isFormMode = createMemo(() => mode() !== "list")
|
||||
const isAddMode = createMemo(() => mode() === "add")
|
||||
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||
|
||||
const formTitle = createMemo(() => {
|
||||
if (!isFormMode()) return language.t("dialog.server.title")
|
||||
return (
|
||||
<div class="flex items-center gap-2 -ml-2">
|
||||
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
||||
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.editServer.id) return
|
||||
if (editing()) return
|
||||
resetEdit()
|
||||
})
|
||||
|
||||
async function handleRemove(key: ServerConnection.Key) {
|
||||
try {
|
||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||
tabs.removeServer(key)
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) {
|
||||
await setDefault(null)
|
||||
}
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defaultKey,
|
||||
canDefault,
|
||||
current,
|
||||
sortedItems,
|
||||
status: () => global.servers.health,
|
||||
isFormMode,
|
||||
isAddMode,
|
||||
formTitle,
|
||||
formBusy,
|
||||
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
|
||||
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
|
||||
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
|
||||
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
|
||||
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
|
||||
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
|
||||
select,
|
||||
setDefault,
|
||||
startAdd,
|
||||
startEdit,
|
||||
resetForm,
|
||||
submitForm,
|
||||
canRemove: server.canRemove,
|
||||
handleRemove,
|
||||
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
|
||||
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
|
||||
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
|
||||
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
|
||||
}
|
||||
}
|
||||
|
||||
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
|
||||
@@ -149,10 +568,10 @@ export function ServerConnectionList(props: {
|
||||
}}
|
||||
noInitialSelection
|
||||
emptyMessage={language.t("dialog.server.empty")}
|
||||
items={props.domain.collection.items}
|
||||
items={props.controller.sortedItems}
|
||||
key={(x) => x.http.url}
|
||||
onSelect={(x) => {
|
||||
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
|
||||
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
|
||||
}}
|
||||
divider={true}
|
||||
>
|
||||
@@ -161,15 +580,15 @@ export function ServerConnectionList(props: {
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||
<div class="flex flex-col h-full items-center w-5">
|
||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||
</div>
|
||||
<ServerRow
|
||||
conn={i}
|
||||
dimmed={props.domain.collection.health()[key]?.healthy === false}
|
||||
status={props.domain.collection.health()[key]}
|
||||
dimmed={props.controller.status()[key]?.healthy === false}
|
||||
status={props.controller.status()[key]}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
|
||||
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
@@ -178,12 +597,7 @@ export function ServerConnectionList(props: {
|
||||
showCredentials
|
||||
/>
|
||||
<div class="flex items-center justify-center gap-4 pl-4">
|
||||
<Show
|
||||
when={
|
||||
props.domain.collection.current() &&
|
||||
ServerConnection.key(props.domain.collection.current()!) === key
|
||||
}
|
||||
>
|
||||
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
|
||||
<Icon name="check" class="h-6" />
|
||||
</Show>
|
||||
|
||||
@@ -202,27 +616,27 @@ export function ServerConnectionList(props: {
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (i.type !== "http") return
|
||||
props.onEdit(i)
|
||||
props.controller.startEdit(i)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.connection.canRemove(key)}>
|
||||
<Show when={props.controller.canRemove(key)}>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => props.domain.connection.remove(key)}
|
||||
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
@@ -243,7 +657,7 @@ export function ServerConnectionList(props: {
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={props.onAdd}
|
||||
onClick={props.controller.startAdd}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{language.t("dialog.server.add.button")}
|
||||
@@ -253,38 +667,38 @@ export function ServerConnectionList(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerConnectionForm(props: { form: ServerFormController }) {
|
||||
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||
<ServerForm
|
||||
value={props.form.state.value()}
|
||||
name={props.form.state.name()}
|
||||
username={props.form.state.username()}
|
||||
password={props.form.state.password()}
|
||||
value={props.controller.formValue()}
|
||||
name={props.controller.formName()}
|
||||
username={props.controller.formUsername()}
|
||||
password={props.controller.formPassword()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={props.form.state.busy()}
|
||||
error={props.form.state.error()}
|
||||
status={props.form.state.status()}
|
||||
onChange={props.form.change.value}
|
||||
onNameChange={props.form.change.name}
|
||||
onUsernameChange={props.form.change.username}
|
||||
onPasswordChange={props.form.change.password}
|
||||
onSubmit={props.form.submit}
|
||||
onBack={props.form.reset}
|
||||
busy={props.controller.formBusy()}
|
||||
error={props.controller.formError()}
|
||||
status={props.controller.formStatus()}
|
||||
onChange={props.controller.handleFormChange()}
|
||||
onNameChange={props.controller.handleFormNameChange()}
|
||||
onUsernameChange={props.controller.handleFormUsernameChange()}
|
||||
onPasswordChange={props.controller.handleFormPasswordChange()}
|
||||
onSubmit={props.controller.submitForm}
|
||||
onBack={props.controller.resetForm}
|
||||
/>
|
||||
<div class="shrink-0 pb-5">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={props.form.submit}
|
||||
disabled={props.form.state.busy()}
|
||||
onClick={props.controller.submitForm}
|
||||
disabled={props.controller.formBusy()}
|
||||
class="px-3 py-1.5"
|
||||
>
|
||||
{props.form.state.busy()
|
||||
{props.controller.formBusy()
|
||||
? language.t("dialog.server.add.checking")
|
||||
: props.form.state.adding()
|
||||
: props.controller.isAddMode()
|
||||
? language.t("dialog.server.add.button")
|
||||
: language.t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
|
||||
type FormMode = "list" | "add" | "edit"
|
||||
|
||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultKeyActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
return (await platform.getDefaultServer?.()) ?? null
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const set = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultKeyActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: () => defaultKey.latest,
|
||||
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
|
||||
set,
|
||||
}
|
||||
}
|
||||
|
||||
function useServerMutations() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
|
||||
return {
|
||||
add: (connection: ServerConnection.Http) => server.add(connection),
|
||||
replace: (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
|
||||
replaceServerConnection(originalKey, next, {
|
||||
active: () => server.key,
|
||||
removeTabs: (key) => tabs.removeServer(key),
|
||||
add: (connection) => server.add(connection),
|
||||
setActive: (key) => server.setActive(key),
|
||||
remove: (key) => server.remove(key),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function useServerActionsController() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const defaults = useDefaultServer()
|
||||
|
||||
const remove = async (key: ServerConnection.Key) => {
|
||||
try {
|
||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||
tabs.removeServer(key)
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaults, connection: { canRemove: server.canRemove, remove } }
|
||||
}
|
||||
|
||||
export type ServerActionsController = ReturnType<typeof useServerActionsController>
|
||||
|
||||
export function useServerCollectionController() {
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const settings = useSettings()
|
||||
const actions = useServerActionsController()
|
||||
|
||||
const items = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
return [current, ...list.filter((item) => item !== current)]
|
||||
})
|
||||
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||
settings.general.newLayoutDesigns()
|
||||
? undefined
|
||||
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
|
||||
)
|
||||
const sorted = createMemo(() => {
|
||||
const raw = items()
|
||||
const list = raw
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((item, index) => [item, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff =
|
||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
collection: {
|
||||
items: sorted,
|
||||
current,
|
||||
health: () => global.servers.health,
|
||||
},
|
||||
...actions,
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
|
||||
|
||||
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const collection = useServerCollectionController()
|
||||
|
||||
const select = async (connection: ServerConnection.Any) => {
|
||||
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
|
||||
options.onSelect?.()
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
|
||||
}
|
||||
|
||||
return { ...collection, selection: { select } }
|
||||
}
|
||||
|
||||
export type ServerDomainController = ReturnType<typeof useServerDomainController>
|
||||
|
||||
export function useServerFormController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const mutations = useServerMutations()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const healthPreview = createServerHealthPreview(checkServerHealth)
|
||||
const [store, setStore] = createStore({
|
||||
mode: "list" as FormMode,
|
||||
originalUrl: undefined as string | undefined,
|
||||
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
onCleanup(healthPreview.cancel)
|
||||
|
||||
const reset = () => {
|
||||
healthPreview.cancel()
|
||||
setStore({
|
||||
mode: "list",
|
||||
originalUrl: undefined,
|
||||
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
const allServers = () => {
|
||||
if (!server.current || server.list.includes(server.current)) return server.list
|
||||
return [server.current, ...server.list]
|
||||
}
|
||||
const editing = createMemo(() =>
|
||||
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
|
||||
)
|
||||
|
||||
const request = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const normalized = normalizeServerUrl(store.values.url)
|
||||
if (!normalized) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const original = store.mode === "edit" ? editing() : undefined
|
||||
if (store.mode === "edit" && !original) return
|
||||
const name = store.values.name.trim() || undefined
|
||||
const username = store.values.username || undefined
|
||||
const password = store.values.password || undefined
|
||||
if (
|
||||
original?.type === "http" &&
|
||||
normalized === original.http.url &&
|
||||
name === original.displayName &&
|
||||
username === original.http.username &&
|
||||
password === original.http.password
|
||||
) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const connection: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: {
|
||||
url: normalized,
|
||||
username: store.mode === "add" && !password ? undefined : username,
|
||||
password,
|
||||
},
|
||||
}
|
||||
const result = await checkServerHealth(connection.http)
|
||||
if (!result.healthy) {
|
||||
setStore("error", language.t("dialog.server.add.error"))
|
||||
return
|
||||
}
|
||||
if (original?.type === "http") {
|
||||
if (normalized === original.http.url) mutations.add(connection)
|
||||
if (normalized !== original.http.url) mutations.replace(ServerConnection.key(original), connection)
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
reset()
|
||||
if (options.navigateOnAdd === false) {
|
||||
mutations.add(connection)
|
||||
options.onSelect?.()
|
||||
return
|
||||
}
|
||||
mutations.add(connection)
|
||||
options.onSelect?.()
|
||||
navigate("/")
|
||||
},
|
||||
}))
|
||||
|
||||
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
|
||||
const change = (field: keyof ServerFormValues, value: string) => {
|
||||
if (request.isPending) return
|
||||
setStore("values", field, value)
|
||||
setStore("error", "")
|
||||
if (field !== "name") preview()
|
||||
}
|
||||
const startAdd = () => {
|
||||
reset()
|
||||
setStore("mode", "add")
|
||||
}
|
||||
const startEdit = (connection: ServerConnection.Http) => {
|
||||
reset()
|
||||
setStore({
|
||||
mode: "edit",
|
||||
originalUrl: connection.http.url,
|
||||
values: {
|
||||
url: connection.http.url,
|
||||
name: connection.displayName ?? "",
|
||||
username: connection.http.username ?? "",
|
||||
password: connection.http.password ?? "",
|
||||
},
|
||||
error: "",
|
||||
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
|
||||
})
|
||||
}
|
||||
const submit = () => {
|
||||
if (store.mode === "list" || request.isPending) return
|
||||
setStore("error", "")
|
||||
request.mutate()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (store.mode !== "edit") return
|
||||
if (editing()) return
|
||||
reset()
|
||||
})
|
||||
|
||||
return {
|
||||
state: {
|
||||
mode: () => store.mode,
|
||||
open: () => store.mode !== "list",
|
||||
adding: () => store.mode === "add",
|
||||
busy: () => request.isPending,
|
||||
value: () => store.values.url,
|
||||
name: () => store.values.name,
|
||||
username: () => store.values.username,
|
||||
password: () => store.values.password,
|
||||
error: () => store.error,
|
||||
status: () => store.status,
|
||||
},
|
||||
change: {
|
||||
value: (value: string) => change("url", value),
|
||||
name: (value: string) => change("name", value),
|
||||
username: (value: string) => change("username", value),
|
||||
password: (value: string) => change("password", value),
|
||||
},
|
||||
start: { add: startAdd, edit: startEdit },
|
||||
reset,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerFormController = ReturnType<typeof useServerFormController>
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
|
||||
|
||||
describe("createServerHealthPreview", () => {
|
||||
test("ignores an older response that resolves after the latest response", async () => {
|
||||
const first = deferred<{ healthy: boolean }>()
|
||||
const second = deferred<{ healthy: boolean }>()
|
||||
const requests = [first, second]
|
||||
const status: Array<boolean | undefined> = []
|
||||
const preview = createServerHealthPreview(() => requests.shift()!.promise)
|
||||
|
||||
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
|
||||
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
|
||||
second.resolve({ healthy: true })
|
||||
await latest
|
||||
first.resolve({ healthy: false })
|
||||
await older
|
||||
|
||||
expect(status).toEqual([undefined, undefined, true])
|
||||
})
|
||||
|
||||
test("an incomplete value invalidates an in-flight response", async () => {
|
||||
const request = deferred<{ healthy: boolean }>()
|
||||
const status: Array<boolean | undefined> = []
|
||||
const preview = createServerHealthPreview(() => request.promise)
|
||||
|
||||
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
||||
await preview.preview(values("server"), (value) => status.push(value))
|
||||
request.resolve({ healthy: true })
|
||||
await pending
|
||||
|
||||
expect(status).toEqual([undefined, undefined])
|
||||
})
|
||||
|
||||
test("cancellation prevents an in-flight response from updating status", async () => {
|
||||
const request = deferred<{ healthy: boolean }>()
|
||||
const status: Array<boolean | undefined> = []
|
||||
const preview = createServerHealthPreview(() => request.promise)
|
||||
|
||||
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
||||
preview.cancel()
|
||||
request.resolve({ healthy: true })
|
||||
await pending
|
||||
|
||||
expect(status).toEqual([undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe("replaceServerConnection", () => {
|
||||
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
|
||||
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
|
||||
|
||||
test("moves active selection after adding the replacement and removes the original", () => {
|
||||
const calls: string[] = []
|
||||
|
||||
replaceServerConnection(ServerConnection.key(original), next, {
|
||||
active: () => ServerConnection.key(original),
|
||||
removeTabs: (key) => calls.push(`tabs:${key}`),
|
||||
add: (server) => {
|
||||
calls.push(`add:${ServerConnection.key(server)}`)
|
||||
return server
|
||||
},
|
||||
setActive: (key) => calls.push(`active:${key}`),
|
||||
remove: (key) => calls.push(`remove:${key}`),
|
||||
})
|
||||
|
||||
expect(calls).toEqual([
|
||||
"tabs:https://old.example.com",
|
||||
"add:https://new.example.com",
|
||||
"active:https://new.example.com",
|
||||
"remove:https://old.example.com",
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps the original when the replacement cannot be added", () => {
|
||||
const removed: ServerConnection.Key[] = []
|
||||
|
||||
replaceServerConnection(ServerConnection.key(original), next, {
|
||||
active: () => ServerConnection.key(original),
|
||||
removeTabs: () => {},
|
||||
add: () => undefined,
|
||||
setActive: () => {},
|
||||
remove: (key) => removed.push(key),
|
||||
})
|
||||
|
||||
expect(removed).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import { normalizeServerUrl, ServerConnection } from "@/context/server"
|
||||
import type { ServerHealth } from "@/utils/server-health"
|
||||
|
||||
export type ServerFormValues = {
|
||||
url: string
|
||||
name: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export function createServerHealthPreview(
|
||||
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
|
||||
) {
|
||||
let generation = 0
|
||||
|
||||
const cancel = () => {
|
||||
generation += 1
|
||||
}
|
||||
|
||||
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
|
||||
const current = ++generation
|
||||
setStatus(undefined)
|
||||
const normalized = normalizeServerUrl(values.url)
|
||||
if (!normalized) return
|
||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||
if (!host) return
|
||||
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
|
||||
return
|
||||
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (values.username) http.username = values.username
|
||||
if (values.password) http.password = values.password
|
||||
const result = await check(http)
|
||||
if (current !== generation) return
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
return { cancel, preview }
|
||||
}
|
||||
|
||||
export function replaceServerConnection(
|
||||
originalKey: ServerConnection.Key,
|
||||
next: ServerConnection.Http,
|
||||
operations: {
|
||||
active: () => ServerConnection.Key | undefined
|
||||
removeTabs: (key: ServerConnection.Key) => void
|
||||
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
|
||||
setActive: (key: ServerConnection.Key) => void
|
||||
remove: (key: ServerConnection.Key) => void
|
||||
},
|
||||
) {
|
||||
const active = operations.active()
|
||||
operations.removeTabs(originalKey)
|
||||
const added = operations.add(next)
|
||||
if (!added) return
|
||||
const nextActive = active === originalKey ? ServerConnection.key(added) : active
|
||||
if (nextActive) operations.setActive(nextActive)
|
||||
operations.remove(originalKey)
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { type Component, Show } from "solid-js"
|
||||
import type { ServerActionsController } from "@/components/server/server-management-controller"
|
||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
|
||||
export const ServerRowMenu: Component<{
|
||||
server: ServerConnection.Any
|
||||
domain: ServerActionsController
|
||||
controller: ReturnType<typeof useServerManagementController>
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
@@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(language)}
|
||||
canDefault={props.domain.defaults.available()}
|
||||
isDefault={props.domain.defaults.key() === key}
|
||||
canRemove={props.domain.connection.canRemove(key)}
|
||||
canDefault={props.controller.canDefault()}
|
||||
isDefault={props.controller.defaultKey() === key}
|
||||
canRemove={props.controller.canRemove(key)}
|
||||
onEdit={props.onEdit}
|
||||
onSetDefault={() => props.domain.defaults.set(key)}
|
||||
onRemoveDefault={() => props.domain.defaults.set(null)}
|
||||
onRemove={() => props.domain.connection.remove(key)}
|
||||
onSetDefault={() => props.controller.setDefault(key)}
|
||||
onRemoveDefault={() => props.controller.setDefault(null)}
|
||||
onRemove={() => props.controller.handleRemove(key)}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type ServerConnection } from "@/context/server"
|
||||
import { useServerFormController } from "../server/server-management-controller"
|
||||
import { useServerManagementController } from "../dialog-select-server"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const DialogServerV2: Component<{
|
||||
@@ -15,39 +15,39 @@ export const DialogServerV2: Component<{
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const form = useServerFormController({
|
||||
const controller = useServerManagementController({
|
||||
onSelect: () => dialog.close(),
|
||||
navigateOnAdd: false,
|
||||
})
|
||||
const [opened, setOpened] = createSignal(false)
|
||||
|
||||
onMount(() => {
|
||||
if (props.mode === "add") form.start.add()
|
||||
if (props.mode === "edit" && props.server) form.start.edit(props.server)
|
||||
if (props.mode === "add") controller.startAdd()
|
||||
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
|
||||
setOpened(true)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
form.reset()
|
||||
controller.resetForm()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!opened()) return
|
||||
if (form.state.open()) return
|
||||
if (controller.isFormMode()) return
|
||||
dialog.close()
|
||||
})
|
||||
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
form.submit()
|
||||
controller.submitForm()
|
||||
}
|
||||
|
||||
const title = () =>
|
||||
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
||||
|
||||
const submitLabel = () => {
|
||||
if (form.state.busy()) return language.t("dialog.server.add.checking")
|
||||
if (controller.formBusy()) return language.t("dialog.server.add.checking")
|
||||
if (props.mode === "add") return language.t("dialog.server.add.button")
|
||||
return language.t("common.save")
|
||||
}
|
||||
@@ -66,16 +66,16 @@ export const DialogServerV2: Component<{
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.value()}
|
||||
value={controller.formValue()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
invalid={!!form.state.error()}
|
||||
disabled={form.state.busy()}
|
||||
invalid={!!controller.formError()}
|
||||
disabled={controller.formBusy()}
|
||||
autofocus
|
||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<Show when={form.state.error()}>
|
||||
<span class="settings-v2-server-dialog-error">{form.state.error()}</span>
|
||||
<Show when={controller.formError()}>
|
||||
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
@@ -84,10 +84,10 @@ export const DialogServerV2: Component<{
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.name()}
|
||||
value={controller.formName()}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.name(event.currentTarget.value)}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.username()}
|
||||
value={controller.formUsername()}
|
||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.username(event.currentTarget.value)}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{
|
||||
type="password"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.password()}
|
||||
value={controller.formPassword()}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.password(event.currentTarget.value)}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
||||
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
||||
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
|
||||
{submitLabel()}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { useServerCollectionController } from "../server/server-management-controller"
|
||||
import { useServerManagementController } from "../dialog-select-server"
|
||||
import { DialogServerV2 } from "./dialog-server-v2"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
||||
@@ -19,16 +19,16 @@ import "./settings-v2.css"
|
||||
export const SettingsServersV2: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const domain = useServerCollectionController()
|
||||
const controller = useServerManagementController()
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const wslServers = useFilteredWslServers(() => store.filter)
|
||||
|
||||
const showSearch = createMemo(
|
||||
() => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||
)
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const items = domain.collection.items().filter((item) => !isWslServer(item))
|
||||
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
||||
const query = store.filter.trim()
|
||||
if (!query) return items
|
||||
return fuzzysort
|
||||
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
|
||||
})
|
||||
|
||||
const openAdd = () => {
|
||||
void dialog.push(() => <DialogServerV2 mode="add" />)
|
||||
dialog.push(() => <DialogServerV2 mode="add" />)
|
||||
}
|
||||
|
||||
const openEdit = (server: ServerConnection.Http) => {
|
||||
void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
|
||||
}
|
||||
>
|
||||
<SettingsListV2>
|
||||
<WslServerSettings domain={domain} servers={wslServers} />
|
||||
<WslServerSettings controller={controller} servers={wslServers} />
|
||||
<For each={filtered()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const health = () => domain.collection.health()[key]
|
||||
const isDefault = () => domain.defaults.key() === key
|
||||
const health = () => controller.status()[key]
|
||||
const isDefault = () => controller.defaultKey() === key
|
||||
return (
|
||||
<div class="settings-v2-servers-row">
|
||||
<div class="settings-v2-servers-lead">
|
||||
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-servers-actions">
|
||||
<Show when={domain.defaults.available() && isDefault()}>
|
||||
<Show when={controller.canDefault() && isDefault()}>
|
||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||
</Show>
|
||||
<ServerRowMenu server={item} domain={domain} onEdit={openEdit} />
|
||||
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -153,4 +153,88 @@ describe("v2 session reducer", () => {
|
||||
|
||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||
})
|
||||
|
||||
test("removes cancelled input from the pending promotion fold", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_cancelled",
|
||||
type: "session.input.cancelled",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ missing: "msg_user" })
|
||||
})
|
||||
|
||||
test("keeps steered input available to the promotion fold", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_steered",
|
||||
type: "session.input.steered",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_queued",
|
||||
type: "session.input.queued",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@ export function createV2SessionReducer() {
|
||||
case "session.input.admitted":
|
||||
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
||||
return result([...source])
|
||||
case "session.input.cancelled":
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
return
|
||||
case "session.input.promoted": {
|
||||
const input = pending.get(key(sessionID, event.data.inputID))
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useServerActionsController } from "@/components/server/server-management-controller"
|
||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
const language = useLanguage()
|
||||
const notification = useNotification()
|
||||
const openSettings = useSettingsCommand()
|
||||
const serverManagement = useServerActionsController()
|
||||
const serverManagement = useServerManagementController({ navigateOnAdd: false })
|
||||
const [_state, setState, _, ready] = persisted(
|
||||
Persist.global("home.servers", ["home.servers.v1"]),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
@@ -56,12 +56,12 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
const key = ServerConnection.key(conn)
|
||||
setState("collapsed", key, !state().collapsed[key])
|
||||
},
|
||||
canDefault: serverManagement.defaults.available,
|
||||
defaultKey: serverManagement.defaults.key,
|
||||
canDefault: serverManagement.canDefault,
|
||||
defaultKey: serverManagement.defaultKey,
|
||||
setDefault: (conn: ServerConnection.Any | undefined) =>
|
||||
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
|
||||
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
|
||||
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
|
||||
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
|
||||
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)),
|
||||
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
|
||||
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
||||
focus: home.selection.focusServer,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { type Accessor, For, Show, createMemo } from "solid-js"
|
||||
import type { ServerCollectionController } from "@/components/server/server-management-controller"
|
||||
import type { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -17,6 +17,8 @@ import { DialogAddWslServer } from "./dialog-add-server"
|
||||
import { useWslServers } from "./context"
|
||||
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
||||
|
||||
type Controller = ReturnType<typeof useServerManagementController>
|
||||
|
||||
export function isWslServer(server: ServerConnection.Any) {
|
||||
return server.type === "sidecar" && server.variant === "wsl"
|
||||
}
|
||||
@@ -26,7 +28,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const openAddWsl = () => {
|
||||
void dialog.push(() => <DialogAddWslServer />)
|
||||
dialog.push(() => <DialogAddWslServer />)
|
||||
}
|
||||
return (
|
||||
<Show
|
||||
@@ -65,7 +67,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
|
||||
}
|
||||
|
||||
export function WslServerSettings(props: {
|
||||
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
|
||||
controller: Controller
|
||||
servers: ReturnType<typeof useFilteredWslServers>
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
@@ -84,7 +86,7 @@ export function WslServerSettings(props: {
|
||||
}))
|
||||
|
||||
const remove = (key: ServerConnection.Key) => {
|
||||
request.mutate(() => props.domain.connection.remove(key))
|
||||
request.mutate(() => props.controller.handleRemove(key))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -98,7 +100,7 @@ export function WslServerSettings(props: {
|
||||
return (
|
||||
<div class="settings-v2-servers-row">
|
||||
<div class="settings-v2-servers-lead">
|
||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||
<div class="settings-v2-servers-copy">
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class="settings-v2-servers-name">{item.config.distro}</span>
|
||||
@@ -112,7 +114,7 @@ export function WslServerSettings(props: {
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-servers-actions">
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||
</Show>
|
||||
<Show when={opencodeAction()}>
|
||||
@@ -143,13 +145,13 @@ export function WslServerSettings(props: {
|
||||
{language.t("wsl.server.retryStart")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||
<MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
|
||||
@@ -263,38 +263,52 @@ export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
|
||||
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
export type Endpoint5_24Output = void
|
||||
export type SessionPendingCancelOperation<E = never> = (
|
||||
input: Endpoint5_24Input,
|
||||
) => Effect.Effect<Endpoint5_24Output, E>
|
||||
|
||||
export type Endpoint5_25Input = {
|
||||
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
export type Endpoint5_25Output = void
|
||||
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
|
||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
export type Endpoint5_26Output = void
|
||||
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
input: Endpoint5_27Input,
|
||||
) => Effect.Effect<Endpoint5_27Output, E>
|
||||
|
||||
export type Endpoint5_28Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly key: InstructionEntry.Key
|
||||
readonly value: Schema.Json
|
||||
}
|
||||
export type Endpoint5_25Output = void
|
||||
export type Endpoint5_28Output = void
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_25Input,
|
||||
) => Effect.Effect<Endpoint5_25Output, E>
|
||||
input: Endpoint5_28Input,
|
||||
) => Effect.Effect<Endpoint5_28Output, E>
|
||||
|
||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_26Output = void
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_29Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: Endpoint5_26Input,
|
||||
) => Effect.Effect<Endpoint5_26Output, E>
|
||||
input: Endpoint5_29Input,
|
||||
) => Effect.Effect<Endpoint5_29Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_27Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_30Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||
|
||||
export type Endpoint5_28Input = {
|
||||
export type Endpoint5_31Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly after?: Event.Seq | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_28Output =
|
||||
export type Endpoint5_31Output =
|
||||
| (
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -404,6 +418,33 @@ export type Endpoint5_28Output =
|
||||
readonly input: SessionPending.Message
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.input.cancelled"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.input.steered"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.input.queued"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
@@ -862,19 +903,19 @@ export type Endpoint5_28Output =
|
||||
}
|
||||
)
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
|
||||
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_29Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
||||
export type Endpoint5_32Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_32Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
|
||||
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_30Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_33Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
|
||||
|
||||
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_31Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
|
||||
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_34Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
@@ -902,7 +943,12 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly pending: { readonly list: SessionPendingListOperation<E> }
|
||||
readonly pending: {
|
||||
readonly list: SessionPendingListOperation<E>
|
||||
readonly cancel: SessionPendingCancelOperation<E>
|
||||
readonly steer: SessionPendingSteerOperation<E>
|
||||
readonly queue: SessionPendingQueueOperation<E>
|
||||
}
|
||||
readonly instructions: {
|
||||
readonly entry: {
|
||||
readonly list: SessionInstructionsEntryListOperation<E>
|
||||
|
||||
@@ -80,6 +80,12 @@ import type {
|
||||
Endpoint5_30Output,
|
||||
Endpoint5_31Input,
|
||||
Endpoint5_31Output,
|
||||
Endpoint5_32Input,
|
||||
Endpoint5_32Output,
|
||||
Endpoint5_33Input,
|
||||
Endpoint5_33Output,
|
||||
Endpoint5_34Input,
|
||||
Endpoint5_34Output,
|
||||
Endpoint6_0Input,
|
||||
Endpoint6_0Output,
|
||||
Endpoint7_0Input,
|
||||
@@ -523,37 +529,58 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
|
||||
|
||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||
preserveEffect<Endpoint5_24Output>()(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||
preserveEffect<Endpoint5_25Output>()(
|
||||
raw["session.instructions.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
preserveEffect<Endpoint5_26Output>()(
|
||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveEffect<Endpoint5_27Output>()(
|
||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveStream<Endpoint5_28Output>()(
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
raw["session.instructions.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveStream<Endpoint5_31Output>()(
|
||||
Stream.unwrap(
|
||||
raw["session.log"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
@@ -565,18 +592,18 @@ const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveEffect<Endpoint5_32Output>()(
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
preserveEffect<Endpoint5_33Output>()(
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveEffect<Endpoint5_31Output>()(
|
||||
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||
preserveEffect<Endpoint5_34Output>()(
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -605,13 +632,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
wait: Endpoint5_18(raw),
|
||||
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
||||
context: Endpoint5_22(raw),
|
||||
pending: { list: Endpoint5_23(raw) },
|
||||
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
|
||||
generate: Endpoint5_27(raw),
|
||||
log: Endpoint5_28(raw),
|
||||
interrupt: Endpoint5_29(raw),
|
||||
background: Endpoint5_30(raw),
|
||||
message: Endpoint5_31(raw),
|
||||
pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
|
||||
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
|
||||
generate: Endpoint5_30(raw),
|
||||
log: Endpoint5_31(raw),
|
||||
interrupt: Endpoint5_32(raw),
|
||||
background: Endpoint5_33(raw),
|
||||
message: Endpoint5_34(raw),
|
||||
})
|
||||
|
||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||
|
||||
@@ -58,6 +58,12 @@ import type {
|
||||
SessionContextOutput,
|
||||
SessionPendingListInput,
|
||||
SessionPendingListOutput,
|
||||
SessionPendingCancelInput,
|
||||
SessionPendingCancelOutput,
|
||||
SessionPendingSteerInput,
|
||||
SessionPendingSteerOutput,
|
||||
SessionPendingQueueInput,
|
||||
SessionPendingQueueOutput,
|
||||
SessionInstructionsEntryListInput,
|
||||
SessionInstructionsEntryListOutput,
|
||||
SessionInstructionsEntryPutInput,
|
||||
@@ -766,6 +772,39 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionPendingCancelOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionPendingSteerOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionPendingQueueOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
instructions: {
|
||||
entry: {
|
||||
|
||||
@@ -502,6 +502,36 @@ export type SessionInputPromoted = {
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionInputCancelled = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.input.cancelled"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionInputSteered = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.input.steered"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionInputQueued = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.input.queued"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; inputID: string }
|
||||
}
|
||||
|
||||
export type SessionExecutionStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1970,6 +2000,9 @@ export type SessionEventDurable =
|
||||
| SessionForked
|
||||
| SessionInputPromoted
|
||||
| SessionInputAdmitted
|
||||
| SessionInputCancelled
|
||||
| SessionInputSteered
|
||||
| SessionInputQueued
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
@@ -2024,6 +2057,9 @@ export type V2Event =
|
||||
| SessionForked
|
||||
| SessionInputPromoted
|
||||
| SessionInputAdmitted
|
||||
| SessionInputCancelled
|
||||
| SessionInputSteered
|
||||
| SessionInputQueued
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
@@ -3689,6 +3725,27 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID
|
||||
|
||||
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
|
||||
|
||||
export type SessionPendingCancelInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||
}
|
||||
|
||||
export type SessionPendingCancelOutput = void
|
||||
|
||||
export type SessionPendingSteerInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||
}
|
||||
|
||||
export type SessionPendingSteerOutput = void
|
||||
|
||||
export type SessionPendingQueueInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
|
||||
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
|
||||
}
|
||||
|
||||
export type SessionPendingQueueOutput = void
|
||||
|
||||
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
|
||||
|
||||
@@ -19,7 +19,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
test("generated Effect API names canonical and composed outputs", async () => {
|
||||
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
|
||||
|
||||
expect(source).toContain("export type Endpoint5_3Output = Session.Info")
|
||||
expect(source).toContain("export type Endpoint5_5Output = Session.Info")
|
||||
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
|
||||
expect(source).not.toContain("HttpApiClient.ForApi")
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"projectCopy",
|
||||
"vcs",
|
||||
"debug",
|
||||
"migration",
|
||||
"websearch",
|
||||
"config",
|
||||
])
|
||||
@@ -356,6 +357,28 @@ test("session.pending.list uses the public HTTP contract", async () => {
|
||||
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
|
||||
})
|
||||
|
||||
test("session.pending mutations use the public HTTP contract", async () => {
|
||||
const requests: Array<{ method: string; url: string }> = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push({ method: request.method, url: request.url })
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
|
||||
await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
|
||||
await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
|
||||
await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
|
||||
|
||||
expect(requests).toEqual([
|
||||
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
|
||||
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
|
||||
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
|
||||
])
|
||||
})
|
||||
|
||||
test("event.subscribe exposes the Promise event stream wire projection", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"exports": {
|
||||
"./environment": "./src/environment/index.ts",
|
||||
"./session/runner": "./src/session/runner/index.ts",
|
||||
"./instructions": "./src/instructions/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { FilesImpl } from "./files"
|
||||
|
||||
export interface Driver {
|
||||
readonly spawner: ChildProcessSpawner["Service"]
|
||||
readonly overrides?: Partial<FilesImpl>
|
||||
}
|
||||
|
||||
export * as EnvironmentDriver from "./driver"
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { collectStream } from "@opencode-ai/util/process"
|
||||
import { Failed, NotFound, WrongKind, type FileInfo, type FileType, type FilesImpl } from "./files"
|
||||
|
||||
/**
|
||||
* Files derived from spawning processes: one process per intent, "$1" is
|
||||
* always the target path. Scripts report classification through an exit-code
|
||||
* protocol (44/45/46) so failures never require parsing localized error text;
|
||||
* LC_ALL=C pins the one stderr match that remains. Requires GNU coreutils and
|
||||
* findutils in the target image — BSD and busybox userlands will not work.
|
||||
* Malformed output from these scripts is our own bug and dies as a defect.
|
||||
*/
|
||||
|
||||
const MAX_DATA_BYTES = 64 * 1024 * 1024
|
||||
const MAX_ERROR_BYTES = 64 * 1024
|
||||
const NOT_FOUND = 44
|
||||
const WRONG_KIND = 45
|
||||
const FAILED = 46
|
||||
const TAB = "\t"
|
||||
|
||||
const loadMetadata = (flags = "") => `
|
||||
metadata=$(stat ${flags} -c '%F${TAB}%s${TAB}%Y' -- "$1" 2>&1) || {
|
||||
case "$metadata" in
|
||||
*'No such file or directory'*|*'Not a directory'*) exit ${NOT_FOUND} ;;
|
||||
*) printf '%s' "$metadata" >&2; exit ${FAILED} ;;
|
||||
esac
|
||||
}
|
||||
`
|
||||
|
||||
const statScript = `
|
||||
${loadMetadata()}
|
||||
printf '%s\n' "$metadata"
|
||||
`
|
||||
|
||||
const readScript = `
|
||||
${loadMetadata("-L")}
|
||||
kind=\${metadata%%${TAB}*}
|
||||
if [ "$kind" != 'regular file' ] && [ "$kind" != 'regular empty file' ]; then
|
||||
printf '%s' "$kind" >&2
|
||||
exit ${WRONG_KIND}
|
||||
fi
|
||||
printf '%s\n' "$metadata"
|
||||
if [ "$2" = range ]; then
|
||||
dd if="$1" iflag=skip_bytes,count_bytes skip="$3" count="$4" status=none
|
||||
else
|
||||
cat -- "$1"
|
||||
fi
|
||||
`
|
||||
|
||||
const listScript = `
|
||||
${loadMetadata()}
|
||||
kind=\${metadata%%${TAB}*}
|
||||
if [ "$kind" != directory ]; then
|
||||
printf '%s' "$kind" >&2
|
||||
exit ${WRONG_KIND}
|
||||
fi
|
||||
find "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
|
||||
`
|
||||
|
||||
const moveScript = `
|
||||
${loadMetadata()}
|
||||
mv -- "$1" "$2"
|
||||
`
|
||||
|
||||
interface Result {
|
||||
readonly exitCode: number
|
||||
readonly stdout: Uint8Array
|
||||
readonly stderr: Uint8Array
|
||||
}
|
||||
|
||||
export const execDefaults = (spawner: ChildProcessSpawner["Service"]): FilesImpl => {
|
||||
const run = (
|
||||
path: string,
|
||||
script: string,
|
||||
args: ReadonlyArray<string> = [],
|
||||
stdin?: Uint8Array,
|
||||
): Effect.Effect<Result, Failed> =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const command = ChildProcess.make("sh", ["-c", script, "sh", path, ...args], {
|
||||
env: { LC_ALL: "C" },
|
||||
extendEnv: true,
|
||||
stdin: stdin === undefined ? undefined : Stream.make(stdin),
|
||||
})
|
||||
const handle = yield* spawner.spawn(command).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
|
||||
const [stdout, stderr, exitCode] = yield* Effect.all(
|
||||
[
|
||||
collectStream(handle.stdout, MAX_DATA_BYTES),
|
||||
collectStream(handle.stderr, MAX_ERROR_BYTES),
|
||||
handle.exitCode,
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
|
||||
if (stdout.truncated || stderr.truncated) {
|
||||
return yield* new Failed({ path, cause: new Error("Process output exceeded its collection limit") })
|
||||
}
|
||||
return { exitCode, stdout: stdout.buffer, stderr: stderr.buffer }
|
||||
}),
|
||||
)
|
||||
|
||||
const classify = <A>(
|
||||
path: string,
|
||||
result: Result,
|
||||
success: (stdout: Uint8Array) => A,
|
||||
): Effect.Effect<A, NotFound | WrongKind | Failed> => {
|
||||
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
|
||||
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||
if (result.exitCode === WRONG_KIND) {
|
||||
return Effect.fail(new WrongKind({ path, actual: parseType(new TextDecoder().decode(result.stderr)) }))
|
||||
}
|
||||
return Effect.fail(processFailure(path, result))
|
||||
}
|
||||
|
||||
const complete = (path: string, result: Result) =>
|
||||
result.exitCode === 0 ? Effect.void : Effect.fail(processFailure(path, result))
|
||||
|
||||
return {
|
||||
stat: (path) => run(path, statScript).pipe(Effect.flatMap((result) => classifyPlain(path, result, parseInfo))),
|
||||
read: (path, range) =>
|
||||
run(
|
||||
path,
|
||||
readScript,
|
||||
range === undefined ? ["whole"] : ["range", String(range.offset), String(range.length)],
|
||||
).pipe(
|
||||
Effect.flatMap((result) =>
|
||||
classify(path, result, (stdout) => {
|
||||
const newline = stdout.indexOf(10)
|
||||
if (newline < 0) throw new Error("Missing read metadata header")
|
||||
return {
|
||||
info: parseInfo(stdout.slice(0, newline)),
|
||||
bytes: stdout.slice(newline + 1),
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
write: (path, bytes) =>
|
||||
run(path, `mkdir -p "$(dirname "$1")" && cat > "$1"`, [], bytes).pipe(
|
||||
Effect.flatMap((result) => complete(path, result)),
|
||||
),
|
||||
list: (path) => run(path, listScript).pipe(Effect.flatMap((result) => classify(path, result, parseList))),
|
||||
remove: (path) => run(path, `rm -rf -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
|
||||
move: (from, to) =>
|
||||
run(from, moveScript, [to]).pipe(Effect.flatMap((result) => classifyPlain(from, result, () => undefined))),
|
||||
mkdir: (path) => run(path, `mkdir -p -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
|
||||
}
|
||||
}
|
||||
|
||||
/** `classify` for scripts whose protocol never reports WrongKind. */
|
||||
const classifyPlain = <A>(
|
||||
path: string,
|
||||
result: Result,
|
||||
success: (stdout: Uint8Array) => A,
|
||||
): Effect.Effect<A, NotFound | Failed> => {
|
||||
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
|
||||
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||
return Effect.fail(processFailure(path, result))
|
||||
}
|
||||
|
||||
const processFailure = (path: string, result: Result) =>
|
||||
new Failed({
|
||||
path,
|
||||
cause: new Error(new TextDecoder().decode(result.stderr).trim() || `Process exited with code ${result.exitCode}`),
|
||||
})
|
||||
|
||||
const parseInfo = (bytes: Uint8Array): FileInfo => {
|
||||
const [rawType, rawSize, rawMtime] = new TextDecoder().decode(bytes).trim().split(TAB)
|
||||
const size = Number(rawSize)
|
||||
const mtimeMs = Number(rawMtime) * 1_000
|
||||
if (!rawType || !Number.isFinite(size) || !Number.isFinite(mtimeMs)) throw new Error("Invalid stat output")
|
||||
return { type: parseType(rawType), size, mtimeMs }
|
||||
}
|
||||
|
||||
const parseType = (value: string): FileType => {
|
||||
if (value === "regular file" || value === "regular empty file" || value === "f") return "file"
|
||||
if (value === "directory" || value === "d") return "directory"
|
||||
if (value === "symbolic link" || value === "l") return "symlink"
|
||||
return "other"
|
||||
}
|
||||
|
||||
const parseList = (bytes: Uint8Array) => {
|
||||
const fields = new TextDecoder().decode(bytes).split("\0")
|
||||
fields.pop()
|
||||
if (fields.length % 2 !== 0) throw new Error("Invalid find output")
|
||||
return Array.from({ length: fields.length / 2 }, (_, index) => ({
|
||||
name: fields[index * 2 + 1],
|
||||
type: parseType(fields[index * 2]),
|
||||
}))
|
||||
}
|
||||
|
||||
export * as EnvironmentExecDefaults from "./exec-defaults"
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
export const FileType = Schema.Literals(["file", "directory", "symlink", "other"])
|
||||
export type FileType = typeof FileType.Type
|
||||
|
||||
export interface FileInfo {
|
||||
readonly type: FileType
|
||||
readonly size: number
|
||||
readonly mtimeMs: number
|
||||
}
|
||||
|
||||
export interface DirEntry {
|
||||
readonly name: string
|
||||
readonly type: FileType
|
||||
}
|
||||
|
||||
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("Environment.NotFound", {
|
||||
path: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class WrongKind extends Schema.TaggedErrorClass<WrongKind>()("Environment.WrongKind", {
|
||||
path: Schema.String,
|
||||
actual: FileType,
|
||||
}) {}
|
||||
|
||||
export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Failed", {
|
||||
path: Schema.String,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export interface FilesImpl {
|
||||
/**
|
||||
* Reads a file, following a final symlink so `info` describes the target whose bytes are returned.
|
||||
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
|
||||
* `Failed`, so callers must use ranges for larger files.
|
||||
*/
|
||||
readonly read: (
|
||||
path: string,
|
||||
range?: { readonly offset: number; readonly length: number },
|
||||
) => Effect.Effect<{ readonly info: FileInfo; readonly bytes: Uint8Array }, NotFound | WrongKind | Failed>
|
||||
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
|
||||
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
|
||||
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
|
||||
/** Lists a directory entry without following a final symlink; intermediate symlinks are traversed. */
|
||||
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
|
||||
readonly remove: (path: string) => Effect.Effect<void, Failed>
|
||||
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
|
||||
readonly mkdir: (path: string) => Effect.Effect<void, Failed>
|
||||
}
|
||||
|
||||
export interface Files extends FilesImpl {}
|
||||
|
||||
export * as EnvironmentFiles from "./files"
|
||||
@@ -0,0 +1,24 @@
|
||||
export * as Environment from "./index"
|
||||
|
||||
export { type Driver } from "./driver"
|
||||
export {
|
||||
type DirEntry,
|
||||
Failed,
|
||||
type FileInfo,
|
||||
type Files,
|
||||
type FilesImpl,
|
||||
type FileType,
|
||||
NotFound,
|
||||
WrongKind,
|
||||
} from "./files"
|
||||
export { execDefaults } from "./exec-defaults"
|
||||
export { makeMemoryDriver, type MemoryDriver } from "./memory"
|
||||
|
||||
import type { Driver } from "./driver"
|
||||
import { execDefaults } from "./exec-defaults"
|
||||
import type { Files } from "./files"
|
||||
|
||||
export const makeFiles = (driver: Driver): Files => ({
|
||||
...execDefaults(driver.spawner),
|
||||
...driver.overrides,
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import path from "node:path"
|
||||
import { Effect, PlatformError } from "effect"
|
||||
import { make } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { Driver } from "./driver"
|
||||
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
|
||||
|
||||
type Node =
|
||||
| { readonly type: "file"; readonly bytes: Uint8Array; readonly mtimeMs: number }
|
||||
| { readonly type: "directory"; readonly mtimeMs: number }
|
||||
| { readonly type: "symlink"; readonly target: string; readonly mtimeMs: number }
|
||||
|
||||
export interface MemoryDriver extends Driver {
|
||||
readonly symlink: (target: string, path: string) => Effect.Effect<void, Failed>
|
||||
}
|
||||
|
||||
export const makeMemoryDriver = (): MemoryDriver => {
|
||||
const nodes = new Map<string, Node>([["/", { type: "directory", mtimeMs: Date.now() }]])
|
||||
const key = (value: string) => path.posix.resolve("/", value)
|
||||
const info = (node: Node): FileInfo => ({
|
||||
type: node.type,
|
||||
size:
|
||||
node.type === "file"
|
||||
? node.bytes.length
|
||||
: node.type === "symlink"
|
||||
? new TextEncoder().encode(node.target).length
|
||||
: 0,
|
||||
mtimeMs: node.mtimeMs,
|
||||
})
|
||||
const resolveKey = (value: string, followFinal: boolean, seen = new Set<string>()): string | undefined => {
|
||||
const normalized = key(value)
|
||||
const parts = normalized.split("/").filter(Boolean)
|
||||
const base = "/"
|
||||
const walk = (current: string, index: number): string | undefined => {
|
||||
if (index === parts.length) return current
|
||||
const part = parts[index]
|
||||
const candidate = path.posix.join(current, part)
|
||||
const node = nodes.get(candidate)
|
||||
if (node?.type !== "symlink" || (!followFinal && index === parts.length - 1)) return walk(candidate, index + 1)
|
||||
if (seen.has(candidate)) return undefined
|
||||
seen.add(candidate)
|
||||
const target = path.posix.resolve(path.posix.dirname(candidate), node.target)
|
||||
return resolveKey(path.posix.join(target, ...parts.slice(index + 1)), followFinal, seen)
|
||||
}
|
||||
return walk(base, 0)
|
||||
}
|
||||
const lookup = (value: string) => nodes.get(resolveKey(value, false) ?? key(value))
|
||||
const requireParent = (value: string) => {
|
||||
const parentPath = path.posix.dirname(key(value))
|
||||
const parent = nodes.get(resolveKey(parentPath, true) ?? parentPath)
|
||||
if (!parent) throw new Error(`Parent directory does not exist: ${path.posix.dirname(value)}`)
|
||||
if (parent.type !== "directory") throw new Error(`Parent is not a directory: ${path.posix.dirname(value)}`)
|
||||
}
|
||||
const mkdirSync = (value: string) => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
const existing = nodes.get(target)
|
||||
if (existing?.type === "directory") return
|
||||
if (existing) throw new Error(`Path is not a directory: ${value}`)
|
||||
const parent = path.posix.dirname(target)
|
||||
if (parent !== target) mkdirSync(parent)
|
||||
nodes.set(target, { type: "directory", mtimeMs: Date.now() })
|
||||
}
|
||||
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
|
||||
const overrides: FilesImpl = {
|
||||
stat: (value) => {
|
||||
const node = lookup(value)
|
||||
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
|
||||
},
|
||||
read: (value, range) => {
|
||||
const original = lookup(value)
|
||||
if (!original) return Effect.fail(new NotFound({ path: value }))
|
||||
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
|
||||
const resolved = resolveKey(value, true)
|
||||
const node = resolved === undefined ? undefined : nodes.get(resolved)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
|
||||
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
|
||||
},
|
||||
write: (value, bytes) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
mkdirSync(path.posix.dirname(key(value)))
|
||||
const existing = lookup(value)
|
||||
if (existing?.type === "directory") throw new Error(`Path is a directory: ${value}`)
|
||||
const target = existing?.type === "symlink" ? resolveKey(value, true) : resolveKey(value, false)
|
||||
if (!target) throw new Error(`Cannot resolve symlink: ${value}`)
|
||||
requireParent(target)
|
||||
nodes.set(target, { type: "file", bytes: bytes.slice(), mtimeMs: Date.now() })
|
||||
},
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
list: (value) => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
const node = nodes.get(target)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const entries = [...nodes.entries()]
|
||||
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
|
||||
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return Effect.succeed(entries)
|
||||
},
|
||||
remove: (value) =>
|
||||
Effect.sync(() => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
for (const entry of nodes.keys()) {
|
||||
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
|
||||
}
|
||||
}),
|
||||
move: (from, to) => {
|
||||
const source = resolveKey(from, false) ?? key(from)
|
||||
const node = nodes.get(source)
|
||||
if (!node) return Effect.fail(new NotFound({ path: from }))
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const requested = resolveKey(to, false) ?? key(to)
|
||||
const destination =
|
||||
nodes.get(requested)?.type === "directory"
|
||||
? path.posix.join(requested, path.posix.basename(source))
|
||||
: requested
|
||||
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
|
||||
throw new Error(`Cannot move a directory into itself: ${from}`)
|
||||
}
|
||||
const existing = nodes.get(destination)
|
||||
if (node.type === "directory" && existing && existing.type !== "directory") {
|
||||
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
|
||||
}
|
||||
requireParent(destination)
|
||||
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
|
||||
for (const [entry] of moved) nodes.delete(entry)
|
||||
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
|
||||
},
|
||||
catch: (cause) => failed(from, cause),
|
||||
})
|
||||
},
|
||||
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
|
||||
}
|
||||
|
||||
const spawner = make((command) =>
|
||||
Effect.suspend(() => {
|
||||
const description = command._tag === "StandardCommand" ? command.command : "pipeline"
|
||||
return Effect.fail(
|
||||
PlatformError.systemError({
|
||||
_tag: "Unknown",
|
||||
module: "EnvironmentMemory",
|
||||
method: "spawn",
|
||||
pathOrDescriptor: description,
|
||||
cause: failed(description, new Error("The memory driver cannot spawn processes")),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
spawner,
|
||||
overrides,
|
||||
symlink: (target, value) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
requireParent(value)
|
||||
nodes.set(resolveKey(value, false) ?? key(value), { type: "symlink", target, mtimeMs: Date.now() })
|
||||
},
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export * as EnvironmentMemory from "./memory"
|
||||
@@ -0,0 +1,84 @@
|
||||
export * as WebSearchFirecrawl from "./firecrawl"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Option, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { App } from "../../app"
|
||||
import { WebSearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://mcp.firecrawl.dev/v2/mcp"
|
||||
|
||||
const McpInput = Schema.Struct({
|
||||
query: Schema.String,
|
||||
limit: Schema.Number.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const McpOutput = Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
|
||||
})
|
||||
|
||||
const SearchResponse = Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
success: Schema.Boolean,
|
||||
data: Schema.Struct({
|
||||
web: Schema.Array(
|
||||
Schema.Struct({
|
||||
url: Schema.String,
|
||||
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
description: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.websearch.firecrawl",
|
||||
effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
|
||||
draft.method.update({
|
||||
integrationID: "firecrawl",
|
||||
method: { type: "key", label: "API key (optional)" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "firecrawl",
|
||||
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: "firecrawl",
|
||||
name: "Firecrawl",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("firecrawl")
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
const result = yield* WebSearchMcp.call(
|
||||
http,
|
||||
endpoint,
|
||||
"firecrawl_search",
|
||||
{ input: McpInput, output: McpOutput },
|
||||
{ query: input.query, limit: 8 },
|
||||
{
|
||||
"User-Agent": App.useragent(ctx.app),
|
||||
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
|
||||
},
|
||||
)
|
||||
const content = result?.content.find((item) => item.text)
|
||||
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
|
||||
return (
|
||||
response?.data.web.map((item) => ({
|
||||
url: item.url,
|
||||
...(item.title ? { title: item.title } : {}),
|
||||
...(item.description ? { content: item.description } : {}),
|
||||
time: {},
|
||||
})) ?? []
|
||||
)
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebSearchExa } from "./exa"
|
||||
import { WebSearchFirecrawl } from "./firecrawl"
|
||||
import { WebSearchParallel } from "./parallel"
|
||||
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const
|
||||
|
||||
@@ -133,6 +133,14 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
|
||||
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
|
||||
"Session.PendingInputConflictError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
inputID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
|
||||
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||
skill: Skill.ID,
|
||||
}) {}
|
||||
@@ -181,6 +189,9 @@ export interface Interface {
|
||||
* unhandled compaction barriers.
|
||||
*/
|
||||
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
||||
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||
readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||
readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||
/**
|
||||
* Durable, ordered session log read. Replays durable session bus after
|
||||
* the exclusive `after` cursor, emits a `Synced` marker at the captured
|
||||
@@ -318,6 +329,31 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* new PendingInputConflictError(input)
|
||||
})
|
||||
const mutatePending = (
|
||||
input: PendingInputRef,
|
||||
mutation: (
|
||||
bus: Bus.Interface,
|
||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
|
||||
) => Effect.Effect<unknown>,
|
||||
wake = false,
|
||||
) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionPending.LifecycleConflict
|
||||
? pendingConflict(input)
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
if (wake) yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
)
|
||||
|
||||
const result = Service.of({
|
||||
create: Effect.fn("Session.create")(function* (input) {
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
@@ -507,6 +543,9 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* SessionPending.list(db, sessionID)
|
||||
}),
|
||||
cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
|
||||
steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
|
||||
queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
|
||||
log: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
|
||||
@@ -90,6 +90,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.forked": () => Effect.void,
|
||||
"session.input.promoted": () => Effect.void,
|
||||
"session.input.admitted": () => Effect.void,
|
||||
"session.input.cancelled": () => Effect.void,
|
||||
"session.input.steered": () => Effect.void,
|
||||
"session.input.queued": () => Effect.void,
|
||||
"session.execution.started": () => Effect.void,
|
||||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
|
||||
@@ -37,6 +37,7 @@ const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
|
||||
|
||||
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
||||
"SessionPending.LifecycleConflict",
|
||||
@@ -294,10 +295,7 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
|
||||
*/
|
||||
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
},
|
||||
input: PendingRef,
|
||||
) {
|
||||
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
const deleted = yield* db
|
||||
@@ -312,6 +310,55 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
|
||||
return stored
|
||||
})
|
||||
|
||||
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
|
||||
db: DatabaseService,
|
||||
input: PendingRef,
|
||||
) {
|
||||
const deleted = yield* db
|
||||
.delete(SessionPendingTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionPendingTable.id, input.id),
|
||||
eq(SessionPendingTable.session_id, input.sessionID),
|
||||
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
|
||||
),
|
||||
)
|
||||
.returning({ id: SessionPendingTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
|
||||
db: DatabaseService,
|
||||
input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionPendingTable)
|
||||
.set({ delivery: input.to })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionPendingTable.id, input.id),
|
||||
eq(SessionPendingTable.session_id, input.sessionID),
|
||||
eq(SessionPendingTable.delivery, input.from),
|
||||
),
|
||||
)
|
||||
.returning({ id: SessionPendingTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
export const projectSteered = Effect.fn("SessionPending.projectSteered")(
|
||||
(db: DatabaseService, input: PendingRef) =>
|
||||
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
|
||||
)
|
||||
|
||||
export const projectQueued = Effect.fn("SessionPending.projectQueued")(
|
||||
(db: DatabaseService, input: PendingRef) =>
|
||||
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
|
||||
)
|
||||
|
||||
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
|
||||
db: DatabaseService,
|
||||
input: { readonly sessionID: SessionSchema.ID },
|
||||
@@ -389,6 +436,39 @@ export const equivalent = (
|
||||
return false
|
||||
}
|
||||
|
||||
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
|
||||
inboxLocks.withLock(input.sessionID)(effect).pipe(Effect.asVoid)
|
||||
|
||||
export const cancel = Effect.fn("SessionPending.cancel")((bus: Bus.Interface, input: PendingRef) =>
|
||||
publishMutation(
|
||||
input,
|
||||
bus.publish(SessionEvent.InputCancelled, {
|
||||
sessionID: input.sessionID,
|
||||
inputID: input.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const steer = Effect.fn("SessionPending.steer")((bus: Bus.Interface, input: PendingRef) =>
|
||||
publishMutation(
|
||||
input,
|
||||
bus.publish(SessionEvent.InputSteered, {
|
||||
sessionID: input.sessionID,
|
||||
inputID: input.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const queue = Effect.fn("SessionPending.queue")((bus: Bus.Interface, input: PendingRef) =>
|
||||
publishMutation(
|
||||
input,
|
||||
bus.publish(SessionEvent.InputQueued, {
|
||||
sessionID: input.sessionID,
|
||||
inputID: input.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const publish = Effect.fn("SessionPending.publish")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
|
||||
@@ -485,6 +485,24 @@ const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputCancelled, (event) =>
|
||||
SessionPending.projectCancelled(db, {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputSteered, (event) =>
|
||||
SessionPending.projectSteered(db, {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputQueued, (event) =>
|
||||
SessionPending.projectQueued(db, {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.durable === undefined)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from "node:fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { execDefaults, Failed, makeFiles, makeMemoryDriver } from "../src/environment/index"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { environmentConformance } from "./lib/environment-conformance"
|
||||
|
||||
environmentConformance("memory environment", () =>
|
||||
Effect.sync(() => {
|
||||
const driver = makeMemoryDriver()
|
||||
return {
|
||||
files: makeFiles(driver),
|
||||
root: `/workspace-${crypto.randomUUID()}`,
|
||||
symlink: driver.symlink,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
environmentConformance(
|
||||
"GNU exec environment",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const tmp = yield* Effect.promise(() => tmpdir("opencode-environment-"))
|
||||
return {
|
||||
files: execDefaults(spawner),
|
||||
root: tmp.path,
|
||||
symlink: (target: string, link: string) =>
|
||||
Effect.tryPromise({
|
||||
try: () => fs.symlink(target, link),
|
||||
catch: (cause) => new Failed({ path: link, cause }),
|
||||
}),
|
||||
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
}
|
||||
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
|
||||
process.platform !== "linux",
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
|
||||
import { it } from "./effect"
|
||||
|
||||
export interface EnvironmentHarness {
|
||||
readonly files: Files
|
||||
readonly root: string
|
||||
readonly symlink?: (target: string, path: string) => Effect.Effect<void, Failed>
|
||||
readonly dispose?: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const environmentConformance = <E>(
|
||||
name: string,
|
||||
makeHarness: () => Effect.Effect<EnvironmentHarness, E>,
|
||||
skip = false,
|
||||
) => {
|
||||
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
|
||||
it.live(title, () =>
|
||||
Effect.gen(function* () {
|
||||
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.ignore(harness.files.remove(harness.root))
|
||||
if (harness.dispose) yield* harness.dispose
|
||||
}),
|
||||
)
|
||||
yield* harness.files.mkdir(harness.root)
|
||||
return yield* body(harness)
|
||||
}),
|
||||
)
|
||||
|
||||
const bytes = (value: string) => new TextEncoder().encode(value)
|
||||
const text = (value: Uint8Array) => new TextDecoder().decode(value)
|
||||
const suite = skip ? describe.skip : describe
|
||||
|
||||
suite(name, () => {
|
||||
check("writes, stats, and reads a file with its info", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/hello.txt`
|
||||
yield* harness.files.write(target, bytes("hello"))
|
||||
const result = yield* harness.files.read(target)
|
||||
expect(text(result.bytes)).toBe("hello")
|
||||
expect(result.info.type).toBe("file")
|
||||
expect(result.info.size).toBe(5)
|
||||
expect(yield* harness.files.stat(target)).toEqual(result.info)
|
||||
}),
|
||||
)
|
||||
|
||||
check("reports missing paths", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/missing`
|
||||
expect(yield* Effect.flip(harness.files.read(target))).toBeInstanceOf(NotFound)
|
||||
expect(yield* Effect.flip(harness.files.stat(target))).toBeInstanceOf(NotFound)
|
||||
expect(yield* Effect.flip(harness.files.list(target))).toBeInstanceOf(NotFound)
|
||||
expect(yield* Effect.flip(harness.files.move(target, `${harness.root}/other`))).toBeInstanceOf(NotFound)
|
||||
}),
|
||||
)
|
||||
|
||||
check("reports the actual kind", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = `${harness.root}/directory`
|
||||
const file = `${harness.root}/file`
|
||||
yield* harness.files.mkdir(directory)
|
||||
yield* harness.files.write(file, bytes("data"))
|
||||
const readError = yield* Effect.flip(harness.files.read(directory))
|
||||
const listError = yield* Effect.flip(harness.files.list(file))
|
||||
expect(readError).toBeInstanceOf(WrongKind)
|
||||
expect((readError as WrongKind).actual).toBe("directory")
|
||||
expect(listError).toBeInstanceOf(WrongKind)
|
||||
expect((listError as WrongKind).actual).toBe("file")
|
||||
}),
|
||||
)
|
||||
|
||||
check("write creates parent directories", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/one/two/file`
|
||||
yield* harness.files.write(target, bytes("nested"))
|
||||
yield* harness.files.write(`${harness.root}/empty`, new Uint8Array())
|
||||
expect((yield* harness.files.stat(`${harness.root}/one/two`)).type).toBe("directory")
|
||||
expect(yield* harness.files.stat(`${harness.root}/empty`)).toMatchObject({ type: "file", size: 0 })
|
||||
expect(text((yield* harness.files.read(target)).bytes)).toBe("nested")
|
||||
}),
|
||||
)
|
||||
|
||||
check("reads byte ranges", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/range`
|
||||
yield* harness.files.write(target, bytes("0123456789"))
|
||||
expect(text((yield* harness.files.read(target, { offset: 2, length: 4 })).bytes)).toBe("2345")
|
||||
expect(text((yield* harness.files.read(target, { offset: 8, length: 8 })).bytes)).toBe("89")
|
||||
expect(text((yield* harness.files.read(target, { offset: 20, length: 4 })).bytes)).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
check("lists immediate entries with their kinds", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
yield* harness.files.write(`${harness.root}/file name`, bytes("data"))
|
||||
yield* harness.files.mkdir(`${harness.root}/directory`)
|
||||
yield* harness.files.write(`${harness.root}/directory/nested`, bytes("nested"))
|
||||
const entries = yield* harness.files.list(harness.root)
|
||||
expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([
|
||||
{ name: "directory", type: "directory" },
|
||||
{ name: "file name", type: "file" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
check("reports symlinks without resolving them", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
if (!harness.symlink) return
|
||||
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
|
||||
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
|
||||
yield* harness.symlink("target", `${harness.root}/link`)
|
||||
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
|
||||
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
|
||||
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
|
||||
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
|
||||
const listError = yield* Effect.flip(harness.files.list(`${harness.root}/link-dir`))
|
||||
expect(listError).toBeInstanceOf(WrongKind)
|
||||
expect((listError as WrongKind).actual).toBe("symlink")
|
||||
}),
|
||||
)
|
||||
|
||||
check("follows symlinks when reading", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
if (!harness.symlink) return
|
||||
yield* harness.files.write(`${harness.root}/target`, bytes("target content"))
|
||||
yield* harness.files.mkdir(`${harness.root}/directory`)
|
||||
yield* harness.symlink("target", `${harness.root}/file-link`)
|
||||
yield* harness.symlink("directory", `${harness.root}/directory-link`)
|
||||
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
|
||||
|
||||
const result = yield* harness.files.read(`${harness.root}/file-link`)
|
||||
expect(text(result.bytes)).toBe("target content")
|
||||
expect(result.info.type).toBe("file")
|
||||
expect(result.info.size).toBe(bytes("target content").length)
|
||||
|
||||
const directoryError = yield* Effect.flip(harness.files.read(`${harness.root}/directory-link`))
|
||||
expect(directoryError).toBeInstanceOf(WrongKind)
|
||||
expect((directoryError as WrongKind).actual).toBe("directory")
|
||||
expect(yield* Effect.flip(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
|
||||
}),
|
||||
)
|
||||
|
||||
check("moves files and removes trees idempotently", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const source = `${harness.root}/source/file`
|
||||
const destination = `${harness.root}/destination`
|
||||
yield* harness.files.write(source, bytes("moved"))
|
||||
yield* harness.files.move(source, destination)
|
||||
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
|
||||
expect(yield* Effect.flip(harness.files.stat(source))).toBeInstanceOf(NotFound)
|
||||
yield* harness.files.remove(`${harness.root}/source`)
|
||||
yield* harness.files.remove(`${harness.root}/source`)
|
||||
expect(yield* Effect.flip(harness.files.stat(`${harness.root}/source`))).toBeInstanceOf(NotFound)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1086,4 +1086,78 @@ describe("Session.pending", () => {
|
||||
expect(yield* session.pending(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("cancels pending input and allows its ID to be admitted again", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const inputID = SessionMessage.ID.make("msg_cancelled_queue")
|
||||
yield* session.prompt({
|
||||
id: inputID,
|
||||
sessionID,
|
||||
text: "Queue this",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* session.cancelPending({ sessionID, inputID })
|
||||
|
||||
expect(yield* session.pending(sessionID)).toEqual([])
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
|
||||
expect(
|
||||
yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID })
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
|
||||
|
||||
const retried = yield* session.prompt({
|
||||
id: inputID,
|
||||
sessionID,
|
||||
text: "Queue this",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
expect(retried).toMatchObject({ id: inputID, delivery: "queue" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("moves pending input between steer and queue delivery", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const queued = yield* session.synthetic({
|
||||
sessionID,
|
||||
text: "Steer this",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.steerPending({ sessionID, inputID: queued.id })
|
||||
|
||||
expect(yield* session.pending(sessionID)).toMatchObject([
|
||||
{ id: queued.id, delivery: "steer" },
|
||||
{ id: alreadySteered.id, delivery: "steer" },
|
||||
])
|
||||
expect(wakeCalls).toEqual([sessionID])
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
|
||||
|
||||
wakeCalls.length = 0
|
||||
yield* session.queuePending({ sessionID, inputID: queued.id })
|
||||
expect(yield* session.pending(sessionID)).toMatchObject([
|
||||
{ id: queued.id, delivery: "queue" },
|
||||
{ id: alreadySteered.id, delivery: "steer" },
|
||||
])
|
||||
expect(wakeCalls).toEqual([])
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
|
||||
|
||||
expect(
|
||||
yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID: alreadySteered.id })
|
||||
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
|
||||
expect(wakeCalls).toEqual([])
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
|
||||
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -522,6 +522,45 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("session.pending.cancel", "/api/session/:sessionID/pending/:inputID", {
|
||||
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [ConflictError, SessionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.pending.cancel",
|
||||
summary: "Cancel pending input",
|
||||
description: "Cancel an input that has not yet been promoted into session history.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.pending.steer", "/api/session/:sessionID/pending/:inputID/steer", {
|
||||
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [ConflictError, SessionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.pending.steer",
|
||||
summary: "Steer queued input",
|
||||
description: "Change a queued input to steer delivery and wake session execution.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.pending.queue", "/api/session/:sessionID/pending/:inputID/queue", {
|
||||
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [ConflictError, SessionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.pending.queue",
|
||||
summary: "Queue pending steer",
|
||||
description: "Change a pending steer to queued delivery.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -152,13 +152,15 @@ export const Forked = Event.durable({
|
||||
})
|
||||
export type Forked = typeof Forked.Type
|
||||
|
||||
const InputRef = {
|
||||
...Base,
|
||||
inputID: SessionMessage.ID,
|
||||
}
|
||||
|
||||
export const InputPromoted = Event.durable({
|
||||
type: "session.input.promoted",
|
||||
...options,
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
inputID: SessionMessage.ID,
|
||||
},
|
||||
schema: InputRef,
|
||||
})
|
||||
export type InputPromoted = typeof InputPromoted.Type
|
||||
|
||||
@@ -166,13 +168,33 @@ export const InputAdmitted = Event.durable({
|
||||
type: "session.input.admitted",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
inputID: SessionMessage.ID,
|
||||
...InputRef,
|
||||
input: SessionPending.Message,
|
||||
},
|
||||
})
|
||||
export type InputAdmitted = typeof InputAdmitted.Type
|
||||
|
||||
export const InputCancelled = Event.durable({
|
||||
type: "session.input.cancelled",
|
||||
...options,
|
||||
schema: InputRef,
|
||||
})
|
||||
export type InputCancelled = typeof InputCancelled.Type
|
||||
|
||||
export const InputSteered = Event.durable({
|
||||
type: "session.input.steered",
|
||||
...options,
|
||||
schema: InputRef,
|
||||
})
|
||||
export type InputSteered = typeof InputSteered.Type
|
||||
|
||||
export const InputQueued = Event.durable({
|
||||
type: "session.input.queued",
|
||||
...options,
|
||||
schema: InputRef,
|
||||
})
|
||||
export type InputQueued = typeof InputQueued.Type
|
||||
|
||||
export namespace Execution {
|
||||
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
|
||||
export type Started = typeof Started.Type
|
||||
@@ -580,6 +602,9 @@ export const Definitions = Event.inventory(
|
||||
Forked,
|
||||
InputPromoted,
|
||||
InputAdmitted,
|
||||
InputCancelled,
|
||||
InputSteered,
|
||||
InputQueued,
|
||||
Execution.Started,
|
||||
Execution.Succeeded,
|
||||
Execution.Failed,
|
||||
@@ -621,13 +646,16 @@ export const DurableDefinitions = Event.inventory(
|
||||
...Definitions.filter((definition) => definition.durability === "durable"),
|
||||
UsageRecorded,
|
||||
)
|
||||
export const EphemeralDefinitions = Event.inventory(
|
||||
...Definitions.filter((definition) => definition.durability === "ephemeral"),
|
||||
)
|
||||
|
||||
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Session.Event.Durable" })
|
||||
export type DurableEvent = typeof Durable.Type
|
||||
|
||||
export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe(
|
||||
export const All = Schema.Union([Durable, ...EphemeralDefinitions], { mode: "oneOf" }).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type Event = typeof All.Type
|
||||
|
||||
@@ -84,6 +84,9 @@ describe("public event manifest", () => {
|
||||
"session.forked.2",
|
||||
"session.input.promoted.1",
|
||||
"session.input.admitted.1",
|
||||
"session.input.cancelled.1",
|
||||
"session.input.steered.1",
|
||||
"session.input.queued.1",
|
||||
"session.execution.started.1",
|
||||
"session.execution.succeeded.1",
|
||||
"session.execution.failed.1",
|
||||
|
||||
@@ -26,6 +26,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const transfer = yield* SessionTransfer.Service
|
||||
const pendingMutation = (effect: ReturnType<typeof session.cancelPending>, conflict: string) =>
|
||||
effect.pipe(
|
||||
Effect.catchTag(
|
||||
"Session.NotFoundError",
|
||||
(error) =>
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag(
|
||||
"Session.PendingInputConflictError",
|
||||
(error) => new ConflictError({ resource: error.inputID, message: `${conflict}: ${error.inputID}` }),
|
||||
),
|
||||
Effect.as(HttpApiSchema.NoContent.make()),
|
||||
)
|
||||
|
||||
return handlers
|
||||
.handle(
|
||||
@@ -661,6 +677,33 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.pending.cancel",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* pendingMutation(
|
||||
session.cancelPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
|
||||
"Pending input can no longer be cancelled",
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.pending.steer",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* pendingMutation(
|
||||
session.steerPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
|
||||
"Pending input is no longer queued",
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.pending.queue",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* pendingMutation(
|
||||
session.queuePending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
|
||||
"Pending input is no longer a steer",
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.instructions.entry.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -462,6 +462,7 @@ function newLayout() {
|
||||
function webSearchProviderLabel(provider: unknown) {
|
||||
if (provider === "parallel") return "Parallel Web Search"
|
||||
if (provider === "exa") return "Exa Web Search"
|
||||
if (provider === "firecrawl") return "Firecrawl Web Search"
|
||||
return "Web Search"
|
||||
}
|
||||
|
||||
|
||||
@@ -53,12 +53,14 @@ import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
onSubmit?: () => void
|
||||
onEmptySubmit?: () => boolean | Promise<boolean>
|
||||
ref?: (ref: PromptRef | undefined) => void
|
||||
hint?: JSX.Element
|
||||
right?: JSX.Element
|
||||
@@ -357,6 +359,20 @@ export function Prompt(props: PromptProps) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Queue prompt",
|
||||
name: "prompt.queue",
|
||||
category: "Prompt",
|
||||
palette: undefined,
|
||||
run: async (_input: string | undefined, event?: KeyEvent) => {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
if (!input.focused) return
|
||||
const handled = await submit("queue")
|
||||
if (!handled) return
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Remove editor context",
|
||||
name: "prompt.editor_context.clear",
|
||||
@@ -515,6 +531,11 @@ export function Prompt(props: PromptProps) {
|
||||
commands: promptCommands(),
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
bindings: ["prompt.queue"],
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
bindings: [
|
||||
"prompt.submit",
|
||||
@@ -900,7 +921,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
let submitting = false
|
||||
async function submit() {
|
||||
async function submit(delivery: SessionPending.Delivery = "steer") {
|
||||
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||
// input's native onSubmit racing another dispatch). Without this guard,
|
||||
// a second call slips past the empty-input check before the first call
|
||||
@@ -910,13 +931,13 @@ export function Prompt(props: PromptProps) {
|
||||
if (submitting) return false
|
||||
submitting = true
|
||||
try {
|
||||
return await submitInner()
|
||||
return await submitInner(delivery)
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitInner() {
|
||||
async function submitInner(delivery: SessionPending.Delivery) {
|
||||
// IME: double-defer may fire before onContentChange flushes the last
|
||||
// composed character (e.g. Korean hangul) to the store, so read
|
||||
// plainText directly and sync before any downstream reads.
|
||||
@@ -927,14 +948,25 @@ export function Prompt(props: PromptProps) {
|
||||
if (props.disabled) return false
|
||||
if (move.creating()) return false
|
||||
if (auto()?.visible) return false
|
||||
if (!store.prompt.text) return false
|
||||
const trimmed = store.prompt.text.trim()
|
||||
if (!trimmed) return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
|
||||
if (
|
||||
delivery === "queue" &&
|
||||
(store.mode === "shell" || trimmed === "exit" || trimmed === "quit" || trimmed === ":q")
|
||||
) {
|
||||
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
|
||||
return false
|
||||
}
|
||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||
void exit()
|
||||
return true
|
||||
}
|
||||
const slash = argumentSlash(store.prompt.text, keymapCommands())
|
||||
if (slash) {
|
||||
if (delivery === "queue") {
|
||||
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
|
||||
return false
|
||||
}
|
||||
clearPrompt()
|
||||
await slash.command.run(slash.input)
|
||||
return true
|
||||
@@ -958,6 +990,16 @@ export function Prompt(props: PromptProps) {
|
||||
const isCommand =
|
||||
slashHead !== undefined &&
|
||||
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
|
||||
if (delivery === "queue" && isSkill) {
|
||||
toast.show({ message: "Skills cannot be queued", variant: "warning" })
|
||||
return false
|
||||
}
|
||||
const editorSelection = editorContext()
|
||||
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
|
||||
if (delivery === "queue" && pendingEditorSelection) {
|
||||
toast.show({ message: "Editor context cannot be queued", variant: "warning" })
|
||||
return false
|
||||
}
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return false
|
||||
const selection = local.model.selection()
|
||||
@@ -1016,8 +1058,6 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
// Capture mode before it gets reset
|
||||
const currentMode = store.mode
|
||||
const editorSelection = editorContext()
|
||||
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
|
||||
|
||||
if (store.mode === "shell") {
|
||||
move.startSubmit()
|
||||
@@ -1040,6 +1080,7 @@ export function Prompt(props: PromptProps) {
|
||||
model,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
delivery,
|
||||
})
|
||||
.catch((error) => {
|
||||
cancelCommit()
|
||||
@@ -1049,7 +1090,7 @@ export function Prompt(props: PromptProps) {
|
||||
move.startSubmit()
|
||||
void client.api.session.skill({
|
||||
sessionID,
|
||||
skill: slashHead!.name,
|
||||
skill: slashHead.name,
|
||||
})
|
||||
} else {
|
||||
move.startSubmit()
|
||||
@@ -1105,6 +1146,7 @@ export function Prompt(props: PromptProps) {
|
||||
text: inputText,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
delivery,
|
||||
})
|
||||
.then(
|
||||
() => undefined,
|
||||
|
||||
@@ -103,7 +103,8 @@ export const Definitions = {
|
||||
session_interrupt: keybind("escape", "Interrupt current session"),
|
||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||
session_compact: keybind("<leader>c", "Compact the session"),
|
||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
||||
session_queued_prompts: keybind("<leader>q", "View queued prompts"),
|
||||
queued_prompt_delete: keybind("ctrl+d", "Delete queued prompt"),
|
||||
session_child_first: keybind("down", "Toggle subagent picker"),
|
||||
session_parent: keybind("up", "Go to parent session"),
|
||||
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
||||
@@ -161,6 +162,7 @@ export const Definitions = {
|
||||
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||
|
||||
prompt_submit: keybind("none", "Submit prompt"),
|
||||
prompt_queue: keybind("alt+return", "Queue prompt"),
|
||||
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
||||
prompt_skills: keybind("none", "Open skill selector"),
|
||||
prompt_stash: keybind("none", "Stash prompt"),
|
||||
@@ -170,7 +172,7 @@ export const Definitions = {
|
||||
input_clear: keybind("ctrl+c", "Clear input field"),
|
||||
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
|
||||
input_submit: keybind("return", "Submit input"),
|
||||
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
|
||||
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
|
||||
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
|
||||
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
|
||||
input_move_up: keybind("up", "Move cursor up in input"),
|
||||
@@ -305,6 +307,7 @@ export const CommandMap = {
|
||||
session_background: "session.background",
|
||||
session_compact: "session.compact",
|
||||
session_queued_prompts: "session.queued_prompts",
|
||||
queued_prompt_delete: "queued_prompt.delete",
|
||||
session_child_first: "session.child.first",
|
||||
session_parent: "session.parent",
|
||||
session_pin_toggle: "session.pin.toggle",
|
||||
@@ -359,6 +362,7 @@ export const CommandMap = {
|
||||
messages_redo: "session.redo",
|
||||
display_thinking: "session.toggle.thinking",
|
||||
prompt_submit: "prompt.submit",
|
||||
prompt_queue: "prompt.queue",
|
||||
prompt_editor_context_clear: "prompt.editor_context.clear",
|
||||
prompt_skills: "prompt.skills",
|
||||
prompt_stash: "prompt.stash",
|
||||
|
||||
@@ -38,6 +38,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { nonEmptyToolContent } from "../util/tool-display"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
@@ -170,12 +171,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
|
||||
function removePending(sessionID: string, inputID?: string) {
|
||||
if (!inputID) return
|
||||
setStore(
|
||||
"session",
|
||||
"pending",
|
||||
sessionID,
|
||||
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
|
||||
)
|
||||
if (store.session.pending[sessionID]?.some((item) => item.id === inputID))
|
||||
setStore(
|
||||
"session",
|
||||
"pending",
|
||||
sessionID,
|
||||
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
|
||||
)
|
||||
if (store.session.input[sessionID]?.includes(inputID))
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
sessionID,
|
||||
(store.session.input[sessionID] ?? []).filter((id) => id !== inputID),
|
||||
)
|
||||
}
|
||||
|
||||
function removePermission(sessionID: string, requestID: string) {
|
||||
@@ -189,6 +198,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
)
|
||||
}
|
||||
|
||||
function updatePending(sessionID: string, inputID: string, delivery: SessionPending.Delivery) {
|
||||
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
|
||||
const item = store.session.pending[sessionID]?.[index]
|
||||
if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
|
||||
setStore("session", "pending", sessionID, index, { ...item, delivery })
|
||||
}
|
||||
|
||||
const message = {
|
||||
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
|
||||
setStore(
|
||||
@@ -235,6 +251,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||
)
|
||||
},
|
||||
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
|
||||
for (let position = start; position < messages.length; position++) {
|
||||
const item = messages[position]
|
||||
if (item) index.set(item.id, position)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function index(sessionID: string) {
|
||||
@@ -416,24 +438,36 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
}
|
||||
break
|
||||
case "session.input.promoted": {
|
||||
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
|
||||
removePending(event.data.sessionID, event.data.inputID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inputID)
|
||||
if (position === undefined) return
|
||||
const existing = draft[position]
|
||||
if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return
|
||||
if (!existing || !admitted) return
|
||||
existing.time.created = event.created
|
||||
draft.splice(position, 1)
|
||||
draft.push(existing)
|
||||
index.clear()
|
||||
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
|
||||
message.reindex(draft, index, position)
|
||||
})
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
event.data.sessionID,
|
||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "session.input.steered":
|
||||
updatePending(event.data.sessionID, event.data.inputID, "steer")
|
||||
break
|
||||
case "session.input.queued":
|
||||
updatePending(event.data.sessionID, event.data.inputID, "queue")
|
||||
break
|
||||
case "session.input.cancelled": {
|
||||
removePending(event.data.sessionID, event.data.inputID)
|
||||
if (messageIndex.get(event.data.sessionID)?.has(event.data.inputID))
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inputID)
|
||||
if (position === undefined) return
|
||||
draft.splice(position, 1)
|
||||
index.delete(event.data.inputID)
|
||||
message.reindex(draft, index, position)
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.input.admitted":
|
||||
|
||||
@@ -3,7 +3,9 @@ import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/co
|
||||
import { useKeyboard, type JSX } from "@opentui/solid"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import { monoShortcut } from "./mono"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type {
|
||||
FooterQueuedPrompt,
|
||||
@@ -56,6 +58,10 @@ type SkillEntry = PanelEntry & {
|
||||
name: string
|
||||
}
|
||||
|
||||
type QueuedPromptEntry = PanelEntry & {
|
||||
prompt: FooterQueuedPrompt
|
||||
}
|
||||
|
||||
type SubagentEntry = PanelEntry & {
|
||||
sessionID: string
|
||||
current: boolean
|
||||
@@ -442,7 +448,7 @@ export function RunCommandMenuBody(props: {
|
||||
{
|
||||
action: "queued" as const,
|
||||
category: "Agent",
|
||||
display: "View pending work",
|
||||
display: "View queued prompts",
|
||||
footer: `${props.queued().length} pending`,
|
||||
keywords: props
|
||||
.queued()
|
||||
@@ -837,28 +843,48 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
prompts: Accessor<FooterQueuedPrompt[]>
|
||||
onClose: () => void
|
||||
onSteer: (prompt: FooterQueuedPrompt) => void
|
||||
onDelete: (prompt: FooterQueuedPrompt) => void
|
||||
onRows?: (rows: number) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const entries = createMemo(() =>
|
||||
const entries = createMemo<QueuedPromptEntry[]>(() =>
|
||||
props.prompts().map((prompt) => ({
|
||||
category: "",
|
||||
display: prompt.prompt.text.replaceAll("\n", " "),
|
||||
footer: prompt.delivery,
|
||||
footer: "queued",
|
||||
keywords: prompt.prompt.text,
|
||||
prompt,
|
||||
})),
|
||||
)
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: SUBAGENT_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: props.onClose,
|
||||
onSelect: (item) => props.onSteer(item.prompt),
|
||||
onRows: props.onRows,
|
||||
})
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const deleteShortcut = () => monoShortcut(shortcuts.get("queued_prompt.delete") ?? "", props.mono ?? false)
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
id: "queued_prompt.delete",
|
||||
title: "Delete queued prompt",
|
||||
group: "Prompt",
|
||||
run() {
|
||||
const item = controller.items()[controller.menu.selected()]
|
||||
if (!item) return false
|
||||
props.onDelete(item.prompt)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Pending work"
|
||||
title="Queued prompts"
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
total={entries().length}
|
||||
@@ -866,6 +892,7 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
hint={["enter steer", deleteShortcut() ? `${deleteShortcut()} delete` : undefined].filter(Boolean).join(" · ")}
|
||||
mono={props.mono}
|
||||
>
|
||||
<RunFooterMenu
|
||||
@@ -875,7 +902,7 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
offset={controller.menu.offset}
|
||||
rows={controller.menu.rows}
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
empty="No pending work"
|
||||
empty="No queued prompts"
|
||||
border={false}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { decodePasteBytes, stripAnsiSequences, type TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, usePaste } from "@opentui/solid"
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import {
|
||||
createFormBodyState,
|
||||
@@ -149,6 +149,14 @@ export function RunFormBody(props: {
|
||||
if (formSingle(props.request)) submit(next)
|
||||
}
|
||||
|
||||
usePaste((event) => {
|
||||
const field = current()
|
||||
if (!field || textual() || !custom() || confirm()) return
|
||||
event.preventDefault()
|
||||
const next = formPick(formSetSelected(state(), rows().length), props.request)
|
||||
setState(formSetDraft(next, field, formInput(next, field) + stripAnsiSequences(decodePasteBytes(event.bytes))))
|
||||
})
|
||||
|
||||
const moveField = (direction: -1 | 1) => {
|
||||
const next = (state().field + direction + props.request.fields.length + 1) % (props.request.fields.length + 1)
|
||||
if (direction < 0 || confirm()) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
displayCharAt,
|
||||
displaySlice,
|
||||
isExitCommand,
|
||||
isCompactCommand,
|
||||
mentionTriggerIndex,
|
||||
isNewCommand,
|
||||
movePromptHistory,
|
||||
@@ -31,7 +32,16 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit
|
||||
import { monoTruncateMiddle } from "./mono"
|
||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types"
|
||||
import type {
|
||||
FooterQueuedPrompt,
|
||||
FooterState,
|
||||
RunAgent,
|
||||
RunCommand,
|
||||
RunDelivery,
|
||||
RunPrompt,
|
||||
RunPromptPart,
|
||||
RunReference,
|
||||
} from "./types"
|
||||
|
||||
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
|
||||
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
||||
@@ -72,6 +82,8 @@ type PromptInput = {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
mono: Accessor<boolean>
|
||||
history?: Accessor<RunPrompt[]>
|
||||
queuedPrompts: Accessor<FooterQueuedPrompt[]>
|
||||
onQueuedPromptSteer: (inputID: string) => Promise<boolean>
|
||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||
onCycle: () => void
|
||||
onInterrupt: () => boolean
|
||||
@@ -980,8 +992,18 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
enabled: input.prompt() && !visible(),
|
||||
commands: [
|
||||
{
|
||||
id: "prompt.queue",
|
||||
title: "Queue prompt",
|
||||
group: "Prompt",
|
||||
run() {
|
||||
syncDraft()
|
||||
submitPrompt(promptCopy(draft), "queue")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prompt.editor",
|
||||
title: "Open editor",
|
||||
@@ -1116,7 +1138,8 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
}
|
||||
|
||||
const submitPrompt = (next: RunPrompt) => {
|
||||
let submitting = false
|
||||
const submitPrompt = (next: RunPrompt, delivery: RunDelivery = "steer") => {
|
||||
if (!area || area.isDestroyed) {
|
||||
draft = promptCopy(next)
|
||||
}
|
||||
@@ -1130,12 +1153,34 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
hide()
|
||||
}
|
||||
|
||||
if (submitting) return
|
||||
|
||||
if (!next.text.trim()) {
|
||||
const queued = delivery === "steer" ? input.queuedPrompts()[0] : undefined
|
||||
if (queued) {
|
||||
submitting = true
|
||||
void input.onQueuedPromptSteer(queued.messageID).finally(() => {
|
||||
submitting = false
|
||||
})
|
||||
return
|
||||
}
|
||||
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
|
||||
return
|
||||
}
|
||||
|
||||
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
|
||||
if (
|
||||
delivery === "queue" &&
|
||||
(next.mode === "shell" ||
|
||||
command?.source === "skill" ||
|
||||
isNewCommand(next.text) ||
|
||||
isCompactCommand(next.text) ||
|
||||
isExitCommand(next.text) ||
|
||||
next.text.trim().toLowerCase() === "/settings")
|
||||
) {
|
||||
input.onStatus("this prompt cannot be queued")
|
||||
return
|
||||
}
|
||||
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
|
||||
input.onExit()
|
||||
return
|
||||
@@ -1157,24 +1202,28 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
const submit = command
|
||||
? { ...next, command }
|
||||
? { ...next, command, delivery }
|
||||
: parsed?.type === "command"
|
||||
? { ...next, command: parsed.command }
|
||||
: next
|
||||
? { ...next, command: parsed.command, delivery }
|
||||
: { ...next, delivery }
|
||||
const shellMode = next.mode === "shell"
|
||||
|
||||
submitting = true
|
||||
resetDraft()
|
||||
queueMicrotask(async () => {
|
||||
if (await input.onSubmit(submit)) {
|
||||
push(next)
|
||||
if (shellMode) {
|
||||
setShellMode(false)
|
||||
draft = emptyPrompt(false)
|
||||
try {
|
||||
if (await input.onSubmit(submit)) {
|
||||
push(next)
|
||||
if (shellMode) {
|
||||
setShellMode(false)
|
||||
draft = emptyPrompt(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
restore(next)
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
|
||||
restore(next)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import type {
|
||||
MiniSettingChange,
|
||||
MiniSettings,
|
||||
PermissionReply,
|
||||
QueuedPromptAction,
|
||||
RunAgent,
|
||||
RunCommand,
|
||||
RunInput,
|
||||
@@ -96,6 +97,7 @@ type RunFooterOptions = {
|
||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||
onInterrupt?: () => void
|
||||
onBackground?: () => void
|
||||
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
|
||||
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
onSubagentInterrupt?: (sessionID: string) => void
|
||||
@@ -343,6 +345,7 @@ export class RunFooter implements FooterApi {
|
||||
onCycle: footer.handleCycle,
|
||||
onInterrupt: footer.handleInterrupt,
|
||||
onBackground: options.onBackground,
|
||||
onQueuedPromptAction: options.onQueuedPromptAction,
|
||||
onEditorOpen: options.onEditorOpen,
|
||||
onInputClear: footer.handleInputClear,
|
||||
onExitRequest: footer.handleExit,
|
||||
|
||||
@@ -34,6 +34,8 @@ import { Keymap } from "../context/keymap"
|
||||
import { modelInfo } from "./variant.shared"
|
||||
import { monoShortcut } from "./mono"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { createSingleFlight } from "../util/single-flight"
|
||||
|
||||
import type {
|
||||
FooterPromptRoute,
|
||||
@@ -46,6 +48,7 @@ import type {
|
||||
MiniSettingChange,
|
||||
MiniSettings,
|
||||
PermissionReply,
|
||||
QueuedPromptAction,
|
||||
RunAgent,
|
||||
RunCommand,
|
||||
RunInput,
|
||||
@@ -92,13 +95,14 @@ type RunFooterViewProps = {
|
||||
mono: boolean
|
||||
miniSettings: () => MiniSettings
|
||||
history?: () => RunPrompt[]
|
||||
onSubmit: (input: RunPrompt) => boolean
|
||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onFormReply: (input: FormReply) => void | Promise<void>
|
||||
onFormCancel: (input: FormCancel) => void | Promise<void>
|
||||
onCycle: () => void
|
||||
onInterrupt: () => boolean
|
||||
onBackground?: () => void
|
||||
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
|
||||
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||
onInputClear: () => void
|
||||
onExitRequest?: () => boolean
|
||||
@@ -132,6 +136,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
|
||||
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
|
||||
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
|
||||
const queue = createMemo(() => queuedPrompts().filter((item) => item.delivery === "queue"))
|
||||
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
|
||||
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
|
||||
@@ -229,7 +234,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
|
||||
if (current) details.push(variant ? `${current} ${variant}` : current)
|
||||
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
|
||||
if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`)
|
||||
if (queue().length > 0) details.push(`${queue().length} queued`)
|
||||
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
|
||||
return details.join(props.mono ? " - " : " · ")
|
||||
})
|
||||
@@ -309,7 +314,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
}
|
||||
|
||||
const openQueuedMenu = () => {
|
||||
if (queuedPrompts().length === 0) return
|
||||
if (queue().length === 0) return
|
||||
setRoute({ type: "queued-menu" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
@@ -318,6 +323,22 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
setRoute({ type: "composer" })
|
||||
}
|
||||
|
||||
const runQueuedAction = createSingleFlight<string>()
|
||||
const queuedPromptAction = async (action: QueuedPromptAction, inputID: string) => {
|
||||
const run = props.onQueuedPromptAction
|
||||
if (!run) return false
|
||||
const result = await runQueuedAction(inputID, async () => {
|
||||
const error = await run(action, inputID).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (!error) return true
|
||||
props.onStatus(`failed to ${action === "cancel" ? "delete" : action} queued prompt: ${errorMessage(error)}`)
|
||||
return false
|
||||
})
|
||||
return result ?? false
|
||||
}
|
||||
|
||||
const openTab = (sessionID: string) => {
|
||||
setRoute({ type: "subagent", sessionID })
|
||||
props.onSubagentSelect?.(sessionID)
|
||||
@@ -357,6 +378,8 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
theme,
|
||||
mono: () => props.mono,
|
||||
history: props.history,
|
||||
queuedPrompts: queue,
|
||||
onQueuedPromptSteer: (inputID) => queuedPromptAction("steer", inputID),
|
||||
onSubmit: props.onSubmit,
|
||||
onCycle: props.onCycle,
|
||||
onInterrupt: props.onInterrupt,
|
||||
@@ -451,13 +474,12 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
if (foregroundSubagents() && backgroundShortcut()) {
|
||||
items.push({ key: backgroundShortcut(), label: "background" })
|
||||
}
|
||||
if (queuedPrompts().length > 0 && queuedShortcut()) {
|
||||
items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
|
||||
if (queue().length > 0 && queuedShortcut()) {
|
||||
items.push({ key: queuedShortcut(), label: `${queue().length} queued` })
|
||||
}
|
||||
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||
items.push({ key: subagentShortcut(), label: "subagents" })
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
const commandHint = createMemo(() => {
|
||||
@@ -568,11 +590,11 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
|
||||
enabled: active().type === "prompt" && route().type === "composer" && queue().length > 0,
|
||||
commands: [
|
||||
{
|
||||
id: "session.queued_prompts",
|
||||
title: "View pending work",
|
||||
title: "View queued prompts",
|
||||
group: "Session",
|
||||
run: openQueuedMenu,
|
||||
},
|
||||
@@ -630,7 +652,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (route().type !== "queued-menu" || queuedPrompts().length > 0) return
|
||||
if (route().type !== "queued-menu" || queue().length > 0) return
|
||||
closePanel()
|
||||
})
|
||||
|
||||
@@ -734,8 +756,16 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
<Match when={selectingQueued()}>
|
||||
<RunQueuedPromptSelectBody
|
||||
theme={theme}
|
||||
prompts={queuedPrompts}
|
||||
prompts={queue}
|
||||
onClose={closePanel}
|
||||
onSteer={(item) => {
|
||||
void queuedPromptAction("steer", item.messageID).then((steered) => {
|
||||
if (steered) closePanel()
|
||||
})
|
||||
}}
|
||||
onDelete={(item) => {
|
||||
void queuedPromptAction("cancel", item.messageID)
|
||||
}}
|
||||
onRows={setSubagentMenuRows}
|
||||
mono={props.mono}
|
||||
/>
|
||||
@@ -745,7 +775,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
theme={theme}
|
||||
commands={props.commands}
|
||||
subagents={tabs}
|
||||
queued={queuedPrompts}
|
||||
queued={queue}
|
||||
variants={props.variants}
|
||||
variantCycle={variantCycle()}
|
||||
onClose={closePanel}
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
MiniSettings,
|
||||
MiniHost,
|
||||
PermissionReply,
|
||||
QueuedPromptAction,
|
||||
RunAgent,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
@@ -70,6 +71,7 @@ export type LifecycleInput = {
|
||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
||||
onInterrupt?: () => void
|
||||
onBackground?: () => void
|
||||
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
onSubagentInterrupt?: (sessionID: string) => void
|
||||
}
|
||||
@@ -243,6 +245,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
onVariantSelect: input.onVariantSelect,
|
||||
onInterrupt: input.onInterrupt,
|
||||
onBackground: input.onBackground,
|
||||
onQueuedPromptAction: input.onQueuedPromptAction,
|
||||
onEditorOpen: async ({ value }) => {
|
||||
if (closed || renderer.isDestroyed) {
|
||||
return
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Locale } from "../util/locale"
|
||||
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
|
||||
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
|
||||
import type { FooterApi, FooterEvent, RunDelivery, RunPrompt } from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
@@ -21,11 +21,11 @@ export type QueueInput = {
|
||||
footer: FooterApi
|
||||
initialInput?: string
|
||||
trace?: Trace
|
||||
onSend?: (prompt: RunPrompt, delivery: "steer" | "queue") => void
|
||||
onSend?: (prompt: RunPrompt, delivery: RunDelivery) => void
|
||||
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
|
||||
onNewSession?: () => void | Promise<void>
|
||||
onCompact?: () => void | Promise<void>
|
||||
admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
||||
admit: (prompt: RunPrompt, delivery: RunDelivery, signal: AbortSignal) => Promise<void>
|
||||
settle: () => Promise<void>
|
||||
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
input.onSend?.(sent, "steer")
|
||||
input.onSend?.(sent, sent.delivery ?? "steer")
|
||||
|
||||
if (state.closed) {
|
||||
break
|
||||
@@ -276,10 +276,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
|
||||
const admission = state.admission
|
||||
admissionVersion += 1
|
||||
input.onSend?.(sent, "queue")
|
||||
const delivery = prompt.delivery ?? "queue"
|
||||
input.onSend?.(sent, delivery)
|
||||
admissions = admissions
|
||||
.then(() => admission)
|
||||
.then(() => input.admit(sent, admissionController.signal))
|
||||
.then(() => input.admit(sent, delivery, admissionController.signal))
|
||||
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -390,6 +390,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
log?.write("send.background", { sessionID: state.sessionID })
|
||||
void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
|
||||
},
|
||||
onQueuedPromptAction: async (action, inputID) => {
|
||||
if (!state.sessionID) return
|
||||
log?.write(`send.pending.${action}`, { sessionID: state.sessionID, inputID })
|
||||
if (action === "steer") {
|
||||
await state.sdk.session.pending.steer({ sessionID: state.sessionID, inputID })
|
||||
return
|
||||
}
|
||||
await state.sdk.session.pending.cancel({ sessionID: state.sessionID, inputID })
|
||||
},
|
||||
onSubagentInterrupt: (sessionID) => {
|
||||
log?.write("send.subagent.interrupt", { sessionID })
|
||||
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
|
||||
@@ -892,7 +901,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
trace: log,
|
||||
onSend: (prompt, delivery) => {
|
||||
state.shown = true
|
||||
state.history.push(prompt)
|
||||
state.history.push({ ...prompt, delivery: undefined })
|
||||
if (prompt.mode !== "shell" && delivery === "steer") {
|
||||
rememberLocal({
|
||||
kind: "user",
|
||||
@@ -903,18 +912,21 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
})
|
||||
}
|
||||
},
|
||||
admit: async (prompt, signal) => {
|
||||
admit: async (prompt, delivery, signal) => {
|
||||
await state.switching?.catch(() => {})
|
||||
const next = await ensureStream()
|
||||
await next.handle.queuePromptTurn({
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles: false,
|
||||
signal,
|
||||
})
|
||||
await next.handle.admitPromptTurn(
|
||||
{
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles: false,
|
||||
signal,
|
||||
},
|
||||
delivery,
|
||||
)
|
||||
},
|
||||
onAdmissionError: renderPromptError,
|
||||
onCompact: async () => {
|
||||
|
||||
@@ -653,6 +653,10 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.cancelled") {
|
||||
child.prompts.delete(event.data.inputID)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.started") {
|
||||
touch(child, event.created)
|
||||
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
FooterQueuedPrompt,
|
||||
RunFilePart,
|
||||
RunInput,
|
||||
RunDelivery,
|
||||
RunPrompt,
|
||||
RunPromptPart,
|
||||
StreamCommit,
|
||||
@@ -71,7 +72,7 @@ export type SessionResizeReplayInput = {
|
||||
|
||||
export type SessionTransport = {
|
||||
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
|
||||
queuePromptTurn(input: SessionTurnInput): Promise<void>
|
||||
admitPromptTurn(input: SessionTurnInput, delivery: RunDelivery): Promise<void>
|
||||
waitForIdle(): Promise<void>
|
||||
interruptActiveTurn(): Promise<void>
|
||||
selectSubagent(sessionID: string | undefined): void
|
||||
@@ -515,8 +516,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
)
|
||||
}
|
||||
|
||||
let syncedPending: string[] | undefined
|
||||
const syncPending = () => {
|
||||
const prompts = [...state.pending.values()]
|
||||
const prompts = [...state.pending.values()].filter((item) => item.delivery === "queue")
|
||||
const ids = prompts.map((item) => item.messageID)
|
||||
if (syncedPending?.length === ids.length && syncedPending.every((id, index) => id === ids[index])) return
|
||||
syncedPending = ids
|
||||
input.trace?.write("ui.patch", { pending: prompts.length })
|
||||
input.footer.event({ type: "queued.prompts", prompts })
|
||||
}
|
||||
@@ -934,6 +939,36 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
write([], { phase: "running", status: "waiting for assistant" })
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.steered") {
|
||||
const pending = state.pending.get(event.data.inputID)
|
||||
if (!pending) return
|
||||
state.pending.set(event.data.inputID, { ...pending, delivery: "steer" })
|
||||
syncPending()
|
||||
if (state.messageIDs.has(event.data.inputID)) return
|
||||
state.messageIDs.add(event.data.inputID)
|
||||
write([
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inputID,
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.queued") {
|
||||
const pending = state.pending.get(event.data.inputID)
|
||||
if (!pending) return
|
||||
state.pending.set(event.data.inputID, { ...pending, delivery: "queue" })
|
||||
syncPending()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.cancelled") {
|
||||
state.admitted.delete(event.data.inputID)
|
||||
if (state.pending.delete(event.data.inputID)) syncPending()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.step.started") {
|
||||
state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id }
|
||||
write([], { phase: "running", status: "assistant responding" })
|
||||
@@ -1577,7 +1612,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
let queuedResizeReplay: SessionResizeReplayInput | undefined
|
||||
let closing: Promise<void> | undefined
|
||||
|
||||
const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: "steer" | "queue") => {
|
||||
const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: RunDelivery) => {
|
||||
const messageID = next.prompt.messageID
|
||||
if (!messageID) throw new Error("Prompt message ID is required")
|
||||
const command = next.prompt.command
|
||||
@@ -1643,14 +1678,20 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
|
||||
return {
|
||||
async queuePromptTurn(next) {
|
||||
async admitPromptTurn(next, delivery) {
|
||||
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
|
||||
throw new Error("This prompt cannot be queued")
|
||||
if (!state.connected) throw new Error("Event stream is reconnecting")
|
||||
const client = sdk
|
||||
if (next.agent)
|
||||
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
|
||||
mergePending(await admitPrompt(next, client, "queue"))
|
||||
if (!next.prompt.command) {
|
||||
const selected = await resolveSelectedModel(input, client, next)
|
||||
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
if (selected)
|
||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||
}
|
||||
mergePending(await admitPrompt(next, client, delivery))
|
||||
settlementClient = client
|
||||
},
|
||||
async waitForIdle() {
|
||||
@@ -1688,7 +1729,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (command) {
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1700,7 +1741,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (selected)
|
||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
|
||||
},
|
||||
async interruptActiveTurn() {
|
||||
// A running shell holds no drain, so session.interrupt cannot reach it;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { Config } from "../config"
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
|
||||
export type RunFilePart = {
|
||||
type: "file"
|
||||
@@ -71,10 +72,13 @@ export type RunProvider = {
|
||||
models: Record<string, RunProviderModel>
|
||||
}
|
||||
|
||||
export type RunDelivery = SessionPending.Delivery
|
||||
|
||||
export type RunPrompt = {
|
||||
messageID?: string
|
||||
text: string
|
||||
parts: RunPromptPart[]
|
||||
delivery?: RunDelivery
|
||||
mode?: "shell"
|
||||
command?: {
|
||||
name: string
|
||||
@@ -87,9 +91,11 @@ export type RunPrompt = {
|
||||
export type FooterQueuedPrompt = {
|
||||
messageID: string
|
||||
prompt: RunPrompt
|
||||
delivery: "steer" | "queue"
|
||||
delivery: RunDelivery
|
||||
}
|
||||
|
||||
export type QueuedPromptAction = "steer" | "cancel"
|
||||
|
||||
export type RunAgent = {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
||||
import { usePaste, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { decodePasteBytes, stripAnsiSequences, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||
@@ -265,6 +265,19 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
pick(row.value)
|
||||
}
|
||||
|
||||
usePaste((event) => {
|
||||
if (keymap.mode.current() !== FORM_MODE) return
|
||||
const current = answerField()
|
||||
if (!current || textual() || !custom() || confirm()) return
|
||||
event.preventDefault()
|
||||
setStore("selected", rows().length)
|
||||
setStore("custom", {
|
||||
...store.custom,
|
||||
[current.key]: input() + stripAnsiSequences(decodePasteBytes(event.bytes)),
|
||||
})
|
||||
setStore("editing", true)
|
||||
})
|
||||
|
||||
function commitInput(text: string) {
|
||||
const current = answerField()
|
||||
if (!current) return false
|
||||
|
||||
@@ -52,6 +52,7 @@ import { useClient } from "../../context/client"
|
||||
import { useEditorContext } from "../../context/editor"
|
||||
import { openEditor } from "../../editor"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
@@ -97,6 +98,8 @@ import { stringWidth } from "../../util/string-width"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useSessionTabs } from "../../context/session-tabs"
|
||||
import { createSingleFlight } from "../../util/single-flight"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -109,6 +112,7 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||
const TRANSCRIPT_TAIL_ROWS = 40
|
||||
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
||||
const TRANSCRIPT_BACKFILL_DELAY = 120
|
||||
type PendingAction = "steer" | "queue" | "cancel"
|
||||
|
||||
const context = createContext<{
|
||||
width: number
|
||||
@@ -120,6 +124,8 @@ const context = createContext<{
|
||||
diffWrapMode: () => "word" | "none"
|
||||
models: () => ModelInfo[]
|
||||
config: ReturnType<typeof useConfig>["data"]
|
||||
mutatePending: (action: PendingAction, inputID: string) => Promise<boolean>
|
||||
pendingDelivery: (inputID: string) => SessionPending.Delivery | undefined
|
||||
}>()
|
||||
|
||||
function use() {
|
||||
@@ -175,6 +181,13 @@ export function Session() {
|
||||
.flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
|
||||
.concat(global)
|
||||
})
|
||||
const pendingUsers = createMemo(() =>
|
||||
data.session.pending.list(route.sessionID).flatMap((item) => (item.type === "user" ? [item] : [])),
|
||||
)
|
||||
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
|
||||
const queuedPrompts = createMemo(() =>
|
||||
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.data.text }] : [])),
|
||||
)
|
||||
const [composer, setComposer] = createStore({
|
||||
open: false,
|
||||
tab: undefined as string | undefined,
|
||||
@@ -369,6 +382,55 @@ export function Session() {
|
||||
})
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const runPendingAction = createSingleFlight<string>()
|
||||
const mutatePending = async (action: PendingAction, inputID: string) => {
|
||||
const result = await runPendingAction(inputID, async () => {
|
||||
const request =
|
||||
action === "steer"
|
||||
? client.api.session.pending.steer({ sessionID: route.sessionID, inputID })
|
||||
: action === "queue"
|
||||
? client.api.session.pending.queue({ sessionID: route.sessionID, inputID })
|
||||
: client.api.session.pending.cancel({ sessionID: route.sessionID, inputID })
|
||||
const error = await request.then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (!error) return true
|
||||
const label = action === "cancel" ? "delete" : action
|
||||
toast.show({ title: `Failed to ${label} pending prompt`, message: errorMessage(error), variant: "error" })
|
||||
return false
|
||||
})
|
||||
return result ?? false
|
||||
}
|
||||
const openQueuedPrompts = () =>
|
||||
dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Queued prompts"
|
||||
options={queuedPrompts().map((prompt, index) => ({
|
||||
title: prompt.text,
|
||||
value: prompt.id,
|
||||
footer: `${index + 1} of ${queuedPrompts().length}`,
|
||||
}))}
|
||||
onSelect={(option) => {
|
||||
void mutatePending("steer", option.value).then((steered) => {
|
||||
if (steered) dialog.clear()
|
||||
})
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
command: "queued_prompt.delete",
|
||||
title: "delete",
|
||||
onTrigger: (option) => {
|
||||
const last = queuedPrompts().length === 1
|
||||
void mutatePending("cancel", option.value).then((cancelled) => {
|
||||
if (cancelled && last) dialog.clear()
|
||||
})
|
||||
},
|
||||
},
|
||||
]}
|
||||
footerHints={[{ title: "steer", label: "enter" }]}
|
||||
/>
|
||||
))
|
||||
const unavailable = (feature: string) => {
|
||||
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
|
||||
dialog.clear()
|
||||
@@ -871,6 +933,13 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "View queued prompts",
|
||||
id: "session.queued_prompts",
|
||||
group: "Session",
|
||||
enabled: queuedPrompts().length > 0,
|
||||
run: openQueuedPrompts,
|
||||
},
|
||||
{
|
||||
title: "Go to parent session",
|
||||
id: "session.parent",
|
||||
@@ -942,6 +1011,8 @@ export function Session() {
|
||||
diffWrapMode,
|
||||
models,
|
||||
config,
|
||||
mutatePending,
|
||||
pendingDelivery: (inputID) => pendingDeliveries().get(inputID),
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||
@@ -997,6 +1068,9 @@ export function Session() {
|
||||
</Show>
|
||||
</scrollbox>
|
||||
<box flexShrink={0}>
|
||||
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
||||
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
||||
</Show>
|
||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||
<Composer
|
||||
sessionID={route.sessionID}
|
||||
@@ -1032,6 +1106,11 @@ export function Session() {
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
onEmptySubmit={async () => {
|
||||
const next = queuedPrompts()[0]
|
||||
if (!next) return false
|
||||
return mutatePending("steer", next.id)
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
</Match>
|
||||
@@ -1813,6 +1892,7 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
|
||||
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
@@ -1840,18 +1920,20 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
const mode = themes.mode
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
|
||||
const queued = createMemo(
|
||||
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
|
||||
)
|
||||
const delivery = createMemo(() => ctx.pendingDelivery(props.message.id))
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const promptRef = usePromptRef()
|
||||
|
||||
const updatePendingSteer = async (action: "queue" | "cancel") => {
|
||||
if (await ctx.mutatePending(action, props.message.id)) dialog.clear()
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.message.text.trim() || files().length}>
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={queued() ? theme.border.default : color()}
|
||||
borderColor={delivery() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box
|
||||
@@ -1863,6 +1945,21 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
if (delivery() === "steer") {
|
||||
dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Pending steer"
|
||||
options={[
|
||||
{ title: "Move to queue", value: "queue" as const },
|
||||
{ title: "Delete", value: "cancel" as const },
|
||||
]}
|
||||
onSelect={(option) => {
|
||||
void updatePendingSteer(option.value)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
return
|
||||
}
|
||||
dialog.replace(() => (
|
||||
<DialogMessage
|
||||
messageID={props.message.id}
|
||||
@@ -1910,6 +2007,35 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
)
|
||||
}
|
||||
|
||||
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
|
||||
const theme = useTheme("elevated")
|
||||
const next = createMemo(() => props.prompts[0]?.text)
|
||||
|
||||
return (
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={theme.border.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
onMouseUp={props.onOpen}
|
||||
>
|
||||
<box
|
||||
width="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
backgroundColor={theme.background.default}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<span style={{ fg: theme.text.default }}>{props.prompts.length} queued</span>
|
||||
<Show when={next()}>{(text) => <> · {text()}</>}</Show>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
|
||||
@@ -46,9 +46,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
function reduce() {
|
||||
const messages = data.session.message.list(sessionID())
|
||||
const inputs = new Set(data.session.input.list(sessionID()))
|
||||
const pending = data.session.pending.list(sessionID())
|
||||
const queued = new Set(
|
||||
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
|
||||
)
|
||||
const visible = queued.size === 0 ? messages : messages.filter((message) => !queued.has(message.id))
|
||||
const boundary = revertBoundary()
|
||||
const rows = reduceSessionRows(
|
||||
boundary ? messages.filter((message) => message.id < boundary) : messages,
|
||||
boundary ? visible.filter((message) => message.id < boundary) : visible,
|
||||
inputs,
|
||||
turnTokens(),
|
||||
)
|
||||
@@ -57,8 +62,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
rows.splice(
|
||||
position === -1 ? rows.length : position,
|
||||
0,
|
||||
...data.session.pending
|
||||
.list(sessionID())
|
||||
...pending
|
||||
.filter((item) => item.type === "compaction")
|
||||
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
|
||||
)
|
||||
@@ -112,10 +116,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
createEffect(
|
||||
on(
|
||||
() =>
|
||||
data.session.pending
|
||||
.list(sessionID())
|
||||
.filter((item) => item.type === "compaction")
|
||||
.map((item) => item.id),
|
||||
data.session.pending.list(sessionID()).flatMap((item) => {
|
||||
if (item.type === "compaction") return [`${item.id}:compaction`]
|
||||
if (item.type === "user" && item.delivery === "queue") return [`${item.id}:queue`]
|
||||
return []
|
||||
}),
|
||||
() => setRows(reconcile(reduce())),
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -196,7 +201,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
|
||||
const queuedStart = (rows: SessionRow[]) => {
|
||||
const index = rows.findIndex(
|
||||
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
|
||||
(row) =>
|
||||
row.type === "compaction-queued" ||
|
||||
(row.type === "message" && isPending(row.messageID)),
|
||||
)
|
||||
return index === -1 ? rows.length : index
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function createSingleFlight<Key>() {
|
||||
const pending = new Set<Key>()
|
||||
return async <Value>(key: Key, run: () => Promise<Value>) => {
|
||||
if (pending.has(key)) return
|
||||
pending.add(key)
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
pending.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function primitiveInputSummary(input: Record<string, unknown>, omit: read
|
||||
export function webSearchProviderLabel(provider: unknown) {
|
||||
if (provider === "parallel") return "Parallel Web Search"
|
||||
if (provider === "exa") return "Exa Web Search"
|
||||
if (provider === "firecrawl") return "Firecrawl Web Search"
|
||||
return "Web Search"
|
||||
}
|
||||
|
||||
|
||||
@@ -914,6 +914,106 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("updates and removes queued inputs from durable lifecycle events", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-queue-management"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let rows!: ReturnType<typeof createSessionRows>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
|
||||
function Probe() {
|
||||
client = useClient()
|
||||
data = useData()
|
||||
rows = createSessionRows(() => sessionID)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
emitEvent(events, {
|
||||
id: "evt_queue_admitted",
|
||||
created: 1,
|
||||
type: "session.input.admitted",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
inputID: "message-queued",
|
||||
input: { type: "user", data: { text: "Steer me" }, delivery: "queue" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.pending.list(sessionID).length === 1)
|
||||
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_queue_steered",
|
||||
created: 2,
|
||||
type: "session.input.steered",
|
||||
durable: durable(sessionID, 1),
|
||||
data: { sessionID, inputID: "message-queued" },
|
||||
})
|
||||
await wait(() =>
|
||||
data.session.pending
|
||||
.list(sessionID)
|
||||
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "steer"),
|
||||
)
|
||||
expect(rows).toContainEqual({ type: "message", messageID: "message-queued" })
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_queue_restored",
|
||||
created: 3,
|
||||
type: "session.input.queued",
|
||||
durable: durable(sessionID, 2),
|
||||
data: { sessionID, inputID: "message-queued" },
|
||||
})
|
||||
await wait(() =>
|
||||
data.session.pending
|
||||
.list(sessionID)
|
||||
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "queue"),
|
||||
)
|
||||
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_cancel_admitted",
|
||||
created: 4,
|
||||
type: "session.input.admitted",
|
||||
durable: durable(sessionID, 3),
|
||||
data: {
|
||||
sessionID,
|
||||
inputID: "message-cancelled",
|
||||
input: { type: "user", data: { text: "Delete me" }, delivery: "queue" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.pending.list(sessionID).length === 2)
|
||||
emitEvent(events, {
|
||||
id: "evt_queue_cancelled",
|
||||
created: 5,
|
||||
type: "session.input.cancelled",
|
||||
durable: durable(sessionID, 4),
|
||||
data: { sessionID, inputID: "message-cancelled" },
|
||||
})
|
||||
await wait(() => !data.session.input.has(sessionID, "message-cancelled"))
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-queued"])
|
||||
expect(data.session.message.get(sessionID, "message-cancelled")).toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("classifies live tool rows independently of their call ID", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-tool-call-id"
|
||||
|
||||
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
||||
|
||||
async function mountForm(root: string, width = 80) {
|
||||
async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"]) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
|
||||
@@ -37,7 +37,7 @@ async function mountForm(root: string, width = 80) {
|
||||
id: "frm_test",
|
||||
sessionID: "ses_test",
|
||||
title: "Authorization required",
|
||||
fields: [
|
||||
fields: fields ?? [
|
||||
{
|
||||
key: "authorization",
|
||||
type: "external",
|
||||
@@ -126,3 +126,57 @@ test("includes external acknowledgements in progress", async () => {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("pasting on a custom choice opens its editor without submitting", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
custom: true,
|
||||
},
|
||||
])
|
||||
try {
|
||||
await prompt.app.mockInput.pasteBracketedText("production\nwest")
|
||||
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production\nwest")
|
||||
|
||||
expect(prompt.app.captureCharFrame()).toContain("Type your own answer")
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("text fields retain default paste behavior", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [{ key: "notes", type: "string" }])
|
||||
try {
|
||||
await prompt.app.mockInput.pasteBracketedText("normal paste")
|
||||
|
||||
expect(prompt.app.renderer.currentFocusedEditor?.plainText).toBe("normal paste")
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("pasting on a choice without custom answers does not open an editor", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, [
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
},
|
||||
])
|
||||
try {
|
||||
await prompt.app.mockInput.pasteBracketedText("production")
|
||||
|
||||
expect(prompt.app.renderer.currentFocusedEditor).toBeNull()
|
||||
expect(prompt.app.captureCharFrame()).not.toContain("production")
|
||||
expect(prompt.replies).toEqual([])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -56,9 +56,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||
commits,
|
||||
calls,
|
||||
promptReady,
|
||||
submit(text: string, mode?: RunPrompt["mode"]) {
|
||||
submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
|
||||
if (prompts.size === 0) return false
|
||||
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
|
||||
const prompt: RunPrompt = { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
|
||||
for (const fn of [...prompts]) fn(prompt)
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import { RunFooterView } from "../../src/mini/footer.view"
|
||||
import { RunEntryContent } from "../../src/mini/scrollback.writer"
|
||||
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
|
||||
import type {
|
||||
FooterQueuedPrompt,
|
||||
FooterState,
|
||||
FooterSubagentState,
|
||||
FooterSubagentTab,
|
||||
@@ -120,13 +121,15 @@ async function renderFooter(
|
||||
height?: number
|
||||
state?: Partial<FooterState>
|
||||
onCycle?: () => void
|
||||
onSubmit?: (prompt: RunPrompt) => boolean
|
||||
onSubmit?: (prompt: RunPrompt) => boolean | Promise<boolean>
|
||||
view?: FooterView
|
||||
onFormReply?: (input: unknown) => void
|
||||
miniSettings?: MiniSettings
|
||||
mono?: boolean
|
||||
onStatus?: (status: string) => void
|
||||
onMiniSettingChange?: (change: MiniSettingChange) => void
|
||||
queuedPrompts?: FooterQueuedPrompt[]
|
||||
onQueuedPromptAction?: (action: "steer" | "cancel", inputID: string) => Promise<void>
|
||||
} = {},
|
||||
) {
|
||||
const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" })
|
||||
@@ -164,6 +167,7 @@ async function renderFooter(
|
||||
state={state}
|
||||
view={view}
|
||||
subagent={subagents}
|
||||
queuedPrompts={() => input.queuedPrompts ?? []}
|
||||
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
||||
mono={input.mono ?? false}
|
||||
miniSettings={miniSettings}
|
||||
@@ -173,6 +177,7 @@ async function renderFooter(
|
||||
onFormCancel={() => {}}
|
||||
onCycle={input.onCycle ?? (() => {})}
|
||||
onInterrupt={() => false}
|
||||
onQueuedPromptAction={input.onQueuedPromptAction}
|
||||
onEditorOpen={async () => undefined}
|
||||
onInputClear={() => {}}
|
||||
onExit={() => {}}
|
||||
@@ -272,6 +277,41 @@ test("direct footer preserves a partial multi-field form draft across permission
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer paste opens a custom choice editor without submitting", async () => {
|
||||
const replies: unknown[] = []
|
||||
const app = await renderFooter({
|
||||
height: 12,
|
||||
view: {
|
||||
type: "form",
|
||||
request: {
|
||||
id: "frm_custom_paste",
|
||||
sessionID: "ses_child",
|
||||
title: "Deployment target",
|
||||
fields: [
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
onFormReply: (reply) => replies.push(reply),
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await app.mockInput.pasteBracketedText("production\nwest")
|
||||
await app.renderOnce()
|
||||
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("production\nwest")
|
||||
expect(replies).toEqual([])
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
function expectPaletteList(list: BoxRenderable, selectedIndex: number) {
|
||||
expect(list.backgroundColor.toInts()).toEqual((RUN_THEME_FALLBACK.footer.shade as RGBA).toInts())
|
||||
expect((list.getChildren()[selectedIndex] as BoxRenderable).backgroundColor.toInts()).toEqual(
|
||||
@@ -913,7 +953,7 @@ test("direct subagent panel closes when moving up from the first item", async ()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct pending panel shows durable delivery without edit actions", async () => {
|
||||
test("direct queued panel steers and deletes selected prompts", async () => {
|
||||
const [prompts] = createSignal([
|
||||
{
|
||||
messageID: "m-1",
|
||||
@@ -921,16 +961,22 @@ test("direct pending panel shows durable delivery without edit actions", async (
|
||||
delivery: "queue" as const,
|
||||
},
|
||||
])
|
||||
const steered: string[] = []
|
||||
const deleted: string[] = []
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
|
||||
<RunQueuedPromptSelectBody
|
||||
theme={() => RUN_THEME_FALLBACK.footer}
|
||||
prompts={prompts}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</box>
|
||||
<Keymap.Provider config={tuiConfig}>
|
||||
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
|
||||
<RunQueuedPromptSelectBody
|
||||
theme={() => RUN_THEME_FALLBACK.footer}
|
||||
prompts={prompts}
|
||||
onClose={() => {}}
|
||||
onSteer={(prompt) => steered.push(prompt.messageID)}
|
||||
onDelete={(prompt) => deleted.push(prompt.messageID)}
|
||||
/>
|
||||
</box>
|
||||
</Keymap.Provider>
|
||||
),
|
||||
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
|
||||
)
|
||||
@@ -940,19 +986,98 @@ test("direct pending panel shows durable delivery without edit actions", async (
|
||||
const frame = app.captureCharFrame()
|
||||
const list = panelMenu(app.renderer.root)
|
||||
|
||||
expect(frame).toContain("Pending work")
|
||||
expect(frame).toContain("Queued prompts")
|
||||
expect(frame).toContain("fix the auth test")
|
||||
expect(frame).toContain("queue")
|
||||
expect(frame).toContain("queued")
|
||||
expect(frame).toContain("enter steer · ctrl+d delete")
|
||||
expect(frame).not.toContain("┌")
|
||||
expect(frame).not.toContain("┃")
|
||||
expectPaletteList(list, 0)
|
||||
expect(frame).not.toContain("edit")
|
||||
expect(frame).not.toContain("remove")
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("d", { ctrl: true })
|
||||
expect(steered).toEqual(["m-1"])
|
||||
expect(deleted).toEqual(["m-1"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer steers the oldest queued prompt from an empty composer", async () => {
|
||||
const steered: string[] = []
|
||||
const app = await renderFooter({
|
||||
queuedPrompts: [
|
||||
{ messageID: "m-1", prompt: { text: "first", parts: [] }, delivery: "queue" },
|
||||
{ messageID: "m-2", prompt: { text: "second", parts: [] }, delivery: "queue" },
|
||||
],
|
||||
onQueuedPromptAction: async (action, inputID) => {
|
||||
if (action === "steer") steered.push(inputID)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter({ meta: true })
|
||||
await Bun.sleep(0)
|
||||
expect(steered).toEqual([])
|
||||
app.mockInput.pressEnter()
|
||||
await Bun.sleep(0)
|
||||
expect(steered).toEqual(["m-1"])
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer does not steer queued work on a double submit", async () => {
|
||||
const submitted: RunPrompt[] = []
|
||||
const steered: string[] = []
|
||||
const app = await renderFooter({
|
||||
queuedPrompts: [{ messageID: "m-1", prompt: { text: "queued", parts: [] }, delivery: "queue" }],
|
||||
onSubmit: async (prompt) => {
|
||||
submitted.push(prompt)
|
||||
await Bun.sleep(10)
|
||||
return true
|
||||
},
|
||||
onQueuedPromptAction: async (action, inputID) => {
|
||||
if (action === "steer") steered.push(inputID)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await app.mockInput.typeText("send once")
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressEnter()
|
||||
await Bun.sleep(20)
|
||||
expect(submitted).toHaveLength(1)
|
||||
expect(steered).toEqual([])
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer rejects local commands submitted with the queue shortcut", async () => {
|
||||
const submitted: RunPrompt[] = []
|
||||
const statuses: string[] = []
|
||||
const app = await renderFooter({
|
||||
onSubmit: (prompt) => {
|
||||
submitted.push(prompt)
|
||||
return true
|
||||
},
|
||||
onStatus: (status) => statuses.push(status),
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await app.mockInput.typeText("/settings ")
|
||||
app.mockInput.pressEnter({ meta: true })
|
||||
await Bun.sleep(0)
|
||||
expect(submitted).toEqual([])
|
||||
expect(statuses).toContain("this prompt cannot be queued")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// OpenTUI currently crashes Bun in the full `test/cli/run` directory run here.
|
||||
// Re-enable after the upstream OpenTUI fix lands in this repo.
|
||||
test.skip("direct footer recreates the frame across command panel transitions", async () => {
|
||||
@@ -1068,11 +1193,11 @@ test("direct footer submits slash autocomplete selections without dispatching sh
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
|
||||
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" } },
|
||||
{ text: "/new ", parts: [] },
|
||||
{ text: "/new ", parts: [] },
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
|
||||
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" }, delivery: "steer" },
|
||||
{ text: "/new ", parts: [], delivery: "steer" },
|
||||
{ text: "/new ", parts: [], delivery: "steer" },
|
||||
])
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
|
||||
} finally {
|
||||
@@ -1100,7 +1225,9 @@ test("direct footer slash autocomplete keeps a real skills command", async () =>
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" } }])
|
||||
expect(submits).toEqual([
|
||||
{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" }, delivery: "steer" },
|
||||
])
|
||||
expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
@@ -1158,7 +1285,12 @@ test("direct footer tags skill slash submissions with their catalog source", asy
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([
|
||||
{ text: "/formatter src", parts: [], command: { name: "formatter", arguments: "src", source: "skill" } },
|
||||
{
|
||||
text: "/formatter src",
|
||||
parts: [],
|
||||
command: { name: "formatter", arguments: "src", source: "skill" },
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
app.cleanup()
|
||||
@@ -1238,7 +1370,7 @@ test.skip("direct footer clears the synthetic skills draft when the panel closes
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer shows authoritative pending work while running", async () => {
|
||||
test("direct footer shows authoritative queued work while running", async () => {
|
||||
const [state] = createSignal<FooterState>({
|
||||
phase: "running",
|
||||
status: "",
|
||||
@@ -1342,9 +1474,9 @@ test("direct footer shows authoritative pending work while running", async () =>
|
||||
const hint = statusItems.at(-1)!
|
||||
|
||||
expect(spinner).toBeDefined()
|
||||
expect(frame).toContain("1 pending")
|
||||
expect(frame).toContain("1 queued")
|
||||
expect(frame).toContain("ctrl+b background")
|
||||
expect(frame).toContain("ctrl+x q 1 pending")
|
||||
expect(frame).toContain("ctrl+x q 1 queued")
|
||||
expect(frame).toContain("↓ subagents")
|
||||
expect(frame).toContain("ctrl+p cmd")
|
||||
expect(frame).toContain("subagents · ctrl+p cmd")
|
||||
|
||||
@@ -82,7 +82,8 @@ describe("run runtime boot", () => {
|
||||
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
|
||||
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
|
||||
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
|
||||
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
|
||||
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
|
||||
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
|
||||
})
|
||||
|
||||
test("preserves disabled leader from resolved tui config", async () => {
|
||||
|
||||
@@ -265,6 +265,33 @@ describe("run runtime queue", () => {
|
||||
await task
|
||||
})
|
||||
|
||||
test("preserves explicit steer and queue delivery for in-flight prompts", async () => {
|
||||
const ui = createFooterApiFixture()
|
||||
const admitted: string[] = []
|
||||
const gate = Promise.withResolvers<void>()
|
||||
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
run: async (_input, _signal, onAdmitted) => {
|
||||
onAdmitted()
|
||||
await gate.promise
|
||||
},
|
||||
admit: async (input, delivery) => {
|
||||
admitted.push(`${input.text}:${delivery}`)
|
||||
},
|
||||
settle: async () => ui.api.close(),
|
||||
})
|
||||
|
||||
ui.submit("one")
|
||||
ui.submit("two", undefined, "steer")
|
||||
ui.submit("three", undefined, "queue")
|
||||
while (admitted.length < 2) await Bun.sleep(0)
|
||||
expect(admitted).toEqual(["two:steer", "three:queue"])
|
||||
|
||||
gate.resolve()
|
||||
await task
|
||||
})
|
||||
|
||||
test("continues durable admission after one fails", async () => {
|
||||
const ui = createFooterApiFixture()
|
||||
const admitted: string[] = []
|
||||
@@ -308,7 +335,7 @@ describe("run runtime queue", () => {
|
||||
admitted()
|
||||
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
|
||||
},
|
||||
admit: async (_prompt, signal) => {
|
||||
admit: async (_prompt, _delivery, signal) => {
|
||||
admissionStarted.resolve()
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("run interactive runtime", () => {
|
||||
turnStarted.resolve()
|
||||
api.close()
|
||||
},
|
||||
queuePromptTurn: async () => {},
|
||||
admitPromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
@@ -209,7 +209,7 @@ describe("run interactive runtime", () => {
|
||||
streamStarted.resolve()
|
||||
return {
|
||||
runPromptTurn: async () => {},
|
||||
queuePromptTurn: async () => {},
|
||||
admitPromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
@@ -556,7 +556,7 @@ describe("run interactive runtime", () => {
|
||||
setTimeout(() => input.footer.close(), 0)
|
||||
return {
|
||||
runPromptTurn: async () => {},
|
||||
queuePromptTurn: async () => {},
|
||||
admitPromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
|
||||
@@ -669,6 +669,14 @@ describe("V2 mini transport", () => {
|
||||
data: { text: "follow up" },
|
||||
delivery: "queue",
|
||||
},
|
||||
{
|
||||
id: "msg_cancelled",
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 2,
|
||||
type: "user",
|
||||
data: { text: "remove me" },
|
||||
delivery: "queue",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -684,11 +692,14 @@ describe("V2 mini transport", () => {
|
||||
.findLast((item) => item.type === "queued.prompts")
|
||||
?.prompts.map((item) => [item.messageID, item.delivery])
|
||||
|
||||
expect(pending()).toEqual([["msg_queued", "queue"]])
|
||||
expect(pending()).toEqual([
|
||||
["msg_queued", "queue"],
|
||||
["msg_cancelled", "queue"],
|
||||
])
|
||||
events.push({
|
||||
id: "evt_promoted",
|
||||
created: 2,
|
||||
type: "session.input.promoted",
|
||||
id: "evt_steered",
|
||||
created: 3,
|
||||
type: "session.input.steered",
|
||||
durable: durable("ses_1", 2),
|
||||
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||
})
|
||||
@@ -697,19 +708,53 @@ describe("V2 mini transport", () => {
|
||||
expect(ui.commits).toContainEqual(
|
||||
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
|
||||
)
|
||||
expect(pending()).toEqual([])
|
||||
expect(pending()).toEqual([["msg_cancelled", "queue"]])
|
||||
events.push({
|
||||
id: "evt_queued",
|
||||
created: 4,
|
||||
type: "session.input.queued",
|
||||
durable: durable("ses_1", 3),
|
||||
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||
})
|
||||
while (pending()?.length !== 2) await Bun.sleep(0)
|
||||
expect(pending()).toEqual([
|
||||
["msg_queued", "queue"],
|
||||
["msg_cancelled", "queue"],
|
||||
])
|
||||
events.push({
|
||||
id: "evt_cancelled",
|
||||
created: 5,
|
||||
type: "session.input.cancelled",
|
||||
durable: durable("ses_1", 4),
|
||||
data: { sessionID: "ses_1", inputID: "msg_cancelled" },
|
||||
})
|
||||
while (pending()?.length !== 1) await Bun.sleep(0)
|
||||
expect(pending()).toEqual([["msg_queued", "queue"]])
|
||||
events.push({
|
||||
id: "evt_promoted",
|
||||
created: 6,
|
||||
type: "session.input.promoted",
|
||||
durable: durable("ses_1", 5),
|
||||
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||
})
|
||||
while (pending()?.length !== 0) await Bun.sleep(0)
|
||||
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(1)
|
||||
const prompt = spyOn(client.session, "prompt").mockImplementation(
|
||||
(request) => ok(promptAdmission(request)) as never,
|
||||
)
|
||||
await transport.queuePromptTurn({
|
||||
await transport.admitPromptTurn({
|
||||
agent: "review",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
model: { providerID: "test", modelID: "next" },
|
||||
variant: "high",
|
||||
prompt: { messageID: "msg_next", text: "another", parts: [] },
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
})
|
||||
}, "queue")
|
||||
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything())
|
||||
expect(client.session.switchModel).toHaveBeenCalledWith(
|
||||
{ sessionID: "ses_1", model: { providerID: "test", id: "next", variant: "high" } },
|
||||
expect.anything(),
|
||||
)
|
||||
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
|
||||
events.push({
|
||||
id: "evt_earlier_admission",
|
||||
@@ -722,15 +767,8 @@ describe("V2 mini transport", () => {
|
||||
input: { type: "user", data: { text: "earlier" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
while (true) {
|
||||
const pending = ui.events.findLast((item) => item.type === "queued.prompts")
|
||||
if (pending?.type === "queued.prompts" && pending.prompts.length >= 2) break
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
expect(pending()).toEqual([
|
||||
["msg_next", "queue"],
|
||||
["msg_earlier", "steer"],
|
||||
])
|
||||
await Bun.sleep(10)
|
||||
expect(pending()).toEqual([["msg_next", "queue"]])
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
@@ -813,14 +851,14 @@ describe("V2 mini transport", () => {
|
||||
durable: durable("ses_1", 2),
|
||||
data: { sessionID: "ses_1", inputID: "msg_prompt" },
|
||||
})
|
||||
await transport.queuePromptTurn({
|
||||
await transport.admitPromptTurn({
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: { messageID: "msg_queued", text: "follow up", parts: [] },
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
})
|
||||
}, "queue")
|
||||
events.push({
|
||||
id: "evt_queued_promoted",
|
||||
created: 3,
|
||||
|
||||
Reference in New Issue
Block a user