Compare commits

...
Author SHA1 Message Date
Kit Langton 9ff3f963f1 refactor(tool): convert multiedit tool to Tool.defineEffect
Migrate MultiEditTool from Tool.define to Tool.defineEffect pattern,
resolving EditTool via Effect.gen + Tool.init in the outer generator
and using Effect.promise + Effect.orDie in the execute body.
2026-04-10 15:42:38 -04:00
opencode-agent[bot] 378b8ca241 chore: generate 2026-04-10 19:40:10 +00:00
Kit Langton f63bdc8e08 convert list tool to Tool.defineEffect (#21899) 2026-04-10 15:38:52 -04:00
Aiden Cline ce26120205 tweak: make it so disabling uv or ruff fmters disables both (#21921) 2026-04-10 13:30:30 -05:00
opencode-agent[bot] d2d5d84d1e chore: generate 2026-04-10 17:57:46 +00:00
Kit Langton 847f1d99c9 convert glob tool to Tool.defineEffect (#21897) 2026-04-10 13:56:42 -04:00
opencode-agent[bot] 59d08683ea chore: generate 2026-04-10 17:27:31 +00:00
Kit Langton f7514d9eca refactor(tool): convert bash to defineEffect with ChildProcessSpawner (#21895) 2026-04-10 13:26:31 -04:00
James Long 180ded6a27 rector(core,tui): handle workspace state in project context, add workspace status, improve ui (#21896) 2026-04-10 13:03:20 -04:00
35 changed files with 1334 additions and 1062 deletions
+1 -4
View File
@@ -202,7 +202,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
- `api.kv.get`, `set`, `ready` - `api.kv.get`, `set`, `ready`
- `api.state` - `api.state`
- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready` - `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)` - `api.client`
- `api.event.on(type, handler)` - `api.event.on(type, handler)`
- `api.renderer` - `api.renderer`
- `api.slots.register(plugin)` - `api.slots.register(plugin)`
@@ -270,7 +270,6 @@ Command behavior:
- `provider` - `provider`
- `path.{state,config,worktree,directory}` - `path.{state,config,worktree,directory}`
- `vcs?.branch` - `vcs?.branch`
- `workspace.list()` / `workspace.get(workspaceID)`
- `session.count()` - `session.count()`
- `session.diff(sessionID)` - `session.diff(sessionID)`
- `session.todo(sessionID)` - `session.todo(sessionID)`
@@ -282,8 +281,6 @@ Command behavior:
- `lsp()` - `lsp()`
- `mcp()` - `mcp()`
- `api.client` always reflects the current runtime client. - `api.client` always reflects the current runtime client.
- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function. - `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
- `api.renderer` exposes the raw `CliRenderer`. - `api.renderer` exposes the raw `CliRenderer`.
+1 -18
View File
@@ -22,7 +22,7 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { ErrorComponent } from "@tui/component/error-component" import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing" import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider } from "@tui/context/project" import { ProjectProvider, useProject } from "@tui/context/project"
import { useEvent } from "@tui/context/event" import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk" import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading" import { StartupLoading } from "@tui/component/startup-loading"
@@ -36,7 +36,6 @@ import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent" import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme" import { ThemeProvider, useTheme } from "@tui/context/theme"
@@ -465,22 +464,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.replace(() => <DialogSessionList />) dialog.replace(() => <DialogSessionList />)
}, },
}, },
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
? [
{
title: "Manage workspaces",
value: "workspace.list",
category: "Workspace",
suggested: true,
slash: {
name: "workspaces",
},
onSelect: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
]
: []),
{ {
title: "New session", title: "New session",
suggested: route.data.type === "session", suggested: route.data.type === "session",
@@ -2,25 +2,31 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select" import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind" import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { Flag } from "@/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename" import { DialogSessionRename } from "./dialog-session-rename"
import { useKV } from "../context/kv" import { Keybind } from "@/util/keybind"
import { createDebouncedSignal } from "../util/signal" import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function DialogSessionList() { export function DialogSessionList() {
const dialog = useDialog() const dialog = useDialog()
const route = useRoute() const route = useRoute()
const sync = useSync() const sync = useSync()
const project = useProject()
const keybind = useKeybind() const keybind = useKeybind()
const { theme } = useTheme() const { theme } = useTheme()
const sdk = useSDK() const sdk = useSDK()
const kv = useKV() const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
@@ -31,15 +37,68 @@ export function DialogSessionList() {
}) })
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session) const sessions = createMemo(() => searchResults() ?? sync.data.session)
function createWorkspace() {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
openWorkspaceSession({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
})
}
/>
))
}
const options = createMemo(() => { const options = createMemo(() => {
const today = new Date().toDateString() const today = new Date().toDateString()
return sessions() return sessions()
.filter((x) => x.parentID === undefined) .filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated) .toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => { .map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let workspaceStatus: WorkspaceStatus | null = null
if (x.workspaceID) {
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
}
let footer = ""
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) {
let desc = "unknown"
if (workspace) {
desc = `${workspace.type}: ${workspace.name}`
}
footer = (
<>
{desc}{" "}
<span
style={{
fg:
workspaceStatus === "error"
? theme.error
: workspaceStatus === "disconnected"
? theme.textMuted
: theme.success,
}}
>
</span>
</>
)
}
} else {
footer = Locale.time(x.time.updated)
}
const date = new Date(x.time.updated) const date = new Date(x.time.updated)
let category = date.toDateString() let category = date.toDateString()
if (category === today) { if (category === today) {
@@ -53,7 +112,7 @@ export function DialogSessionList() {
bg: isDeleting ? theme.error : undefined, bg: isDeleting ? theme.error : undefined,
value: x.id, value: x.id,
category, category,
footer: Locale.time(x.time.updated), footer,
gutter: isWorking ? <Spinner /> : undefined, gutter: isWorking ? <Spinner /> : undefined,
} }
}) })
@@ -102,6 +161,15 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />) dialog.replace(() => <DialogSessionRename session={option.value} />)
}, },
}, },
{
keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace",
side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
onTrigger: () => {
createWorkspace()
},
},
]} ]}
/> />
) )
@@ -0,0 +1,121 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
export async function openWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
while (true) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
input.dialog.clear()
return
}
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
const dialog = useDialog()
const sync = useSync()
const project = useProject()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
onMount(() => {
dialog.setSize("medium")
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
})
const create = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: "Failed to create workspace",
variant: "error",
})
return
}
await project.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
void create(option.value)
}}
/>
)
}
@@ -1,319 +0,0 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useProject } from "@tui/context/project"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { setTimeout as sleep } from "node:timers/promises"
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID?: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
forceCreate?: boolean
}) {
const cacheSession = (session: Session) => {
input.sync.set(
"session",
[...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
a.id.localeCompare(b.id),
),
)
}
const client = scoped(input.sdk, input.sync, input.workspaceID)
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
const session = listed?.data?.[0]
if (session?.id) {
cacheSession(session)
input.route.navigate({
type: "session",
sessionID: session.id,
})
input.dialog.clear()
return
}
let created: Session | undefined
while (!created) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
created = result.data
}
cacheSession(created)
input.route.navigate({
type: "session",
sessionID: created.id,
})
input.dialog.clear()
}
function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
onMount(() => {
dialog.setSize("medium")
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
})
const createWorkspace = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
console.log(err)
return undefined
})
console.log(JSON.stringify(result, null, 2))
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: "Failed to create workspace",
variant: "error",
})
return
}
await sync.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
void createWorkspace(option.value)
}}
/>
)
}
export function DialogWorkspaceList() {
const dialog = useDialog()
const project = useProject()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const keybind = useKeybind()
const [toDelete, setToDelete] = createSignal<string>()
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
const open = (workspaceID: string, forceCreate?: boolean) =>
openWorkspace({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
forceCreate,
})
async function selectWorkspace(workspaceID: string | null) {
if (workspaceID == null) {
project.workspace.set(undefined)
if (localCount() > 0) {
dialog.replace(() => <DialogSessionList localOnly={true} />)
return
}
route.navigate({
type: "home",
})
dialog.clear()
return
}
const count = counts()[workspaceID]
if (count && count > 0) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
if (count === 0) {
await open(workspaceID)
return
}
const client = scoped(sdk, sync, workspaceID)
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
if (listed?.data?.length) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
await open(workspaceID)
}
const currentWorkspaceID = createMemo(() => project.workspace.current())
const localCount = createMemo(
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
)
let run = 0
createEffect(() => {
const workspaces = sync.data.workspaceList
const next = ++run
if (!workspaces.length) {
setCounts({})
return
}
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
void Promise.all(
workspaces.map(async (workspace) => {
const client = scoped(sdk, sync, workspace.id)
const result = await client.session.list({ roots: true }).catch(() => undefined)
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
}),
).then((entries) => {
if (run !== next) return
setCounts(Object.fromEntries(entries))
})
})
const options = createMemo(() => [
{
title: "Local",
value: null,
category: "Workspace",
description: "Use the local machine",
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
},
...sync.data.workspaceList.map((workspace) => {
const count = counts()[workspace.id]
return {
title:
toDelete() === workspace.id
? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
: workspace.id,
value: workspace.id,
category: workspace.type,
description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
footer:
count === undefined
? "Loading sessions..."
: count === null
? "Sessions unavailable"
: `${count} session${count === 1 ? "" : "s"}`,
}
}),
{
title: "+ New workspace",
value: "__create__",
category: "Actions",
description: "Create a new workspace",
},
])
onMount(() => {
dialog.setSize("large")
void sync.workspace.sync()
})
return (
<DialogSelect
title="Workspaces"
skipFilter={true}
options={options()}
current={currentWorkspaceID()}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
setToDelete(undefined)
if (option.value === "__create__") {
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
return
}
void selectWorkspace(option.value)
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (option.value === "__create__" || option.value === null) return
if (toDelete() !== option.value) {
setToDelete(option.value)
return
}
const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
setToDelete(undefined)
if (result?.error) {
toast.show({
message: "Failed to delete workspace",
variant: "error",
})
return
}
if (currentWorkspaceID() === option.value) {
project.workspace.set(undefined)
route.navigate({
type: "home",
})
}
await sync.workspace.sync()
},
},
]}
/>
)
}
@@ -1,151 +0,0 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale"
import { useKeybind } from "../../context/keybind"
import { useTheme } from "../../context/theme"
import { useSDK } from "../../context/sdk"
import { DialogSessionRename } from "../dialog-session-rename"
import { useKV } from "../../context/kv"
import { createDebouncedSignal } from "../../util/signal"
import { Spinner } from "../spinner"
import { useToast } from "../../ui/toast"
export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const kv = useKV()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [listed, listedActions] = createResource(
() => props.workspaceID,
async (workspaceID) => {
if (!workspaceID) return undefined
const result = await sdk.client.session.list({ roots: true })
return result.data ?? []
},
)
const [searchResults] = createResource(search, async (query) => {
if (!query || props.localOnly) return undefined
const result = await sdk.client.session.list({
search: query,
limit: 30,
...(props.workspaceID ? { roots: true } : {}),
})
return result.data ?? []
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => {
if (searchResults()) return searchResults()!
if (props.workspaceID) return listed() ?? []
if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
return sync.data.session
})
const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
.filter((x) => {
if (x.parentID !== undefined) return false
if (props.workspaceID && listed()) return true
if (props.workspaceID) return x.workspaceID === props.workspaceID
if (props.localOnly) return !x.workspaceID
return true
})
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => {
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
gutter: isWorking ? <Spinner /> : undefined,
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
options={options()}
skipFilter={!props.localOnly}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
const deleted = await sdk.client.session
.delete({
sessionID: option.value,
})
.then(() => true)
.catch(() => false)
setToDelete(undefined)
if (!deleted) {
toast.show({
message: "Failed to delete session",
variant: "error",
})
return
}
if (props.workspaceID) {
listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
return
}
sync.set(
"session",
sync.data.session.filter((session) => session.id !== option.value),
)
return
}
setToDelete(option.value)
},
},
{
keybind: keybind.all.session_rename?.[0],
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
]}
/>
)
}
@@ -1,9 +1,11 @@
import { batch } from "solid-js" import { batch } from "solid-js"
import type { Path } from "@opencode-ai/sdk" import type { Path, Workspace } from "@opencode-ai/sdk/v2"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({ export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
name: "Project", name: "Project",
init: () => { init: () => {
@@ -14,17 +16,22 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
}, },
instance: { instance: {
path: { path: {
home: "",
state: "", state: "",
config: "", config: "",
worktree: "", worktree: "",
directory: sdk.directory ?? "", directory: sdk.directory ?? "",
} satisfies Path, } satisfies Path,
}, },
workspace: undefined as string | undefined, workspace: {
current: undefined as string | undefined,
list: [] as Workspace[],
status: {} as Record<string, WorkspaceStatus>,
},
}) })
async function sync() { async function sync() {
const workspace = store.workspace const workspace = store.workspace.current
const [path, project] = await Promise.all([ const [path, project] = await Promise.all([
sdk.client.path.get({ workspace }), sdk.client.path.get({ workspace }),
sdk.client.project.current({ workspace }), sdk.client.project.current({ workspace }),
@@ -36,6 +43,27 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
}) })
} }
async function syncWorkspace() {
const listed = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!listed?.data) return
const status = await sdk.client.experimental.workspace.status().catch(() => undefined)
const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status]))
batch(() => {
setStore("workspace", "list", reconcile(listed.data))
setStore("workspace", "status", reconcile(next))
if (!listed.data.some((item) => item.id === store.workspace.current)) {
setStore("workspace", "current", undefined)
}
})
}
sdk.event.on("event", (event) => {
if (event.payload.type === "workspace.status") {
setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status)
}
})
return { return {
data: store, data: store,
project() { project() {
@@ -51,13 +79,26 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
}, },
workspace: { workspace: {
current() { current() {
return store.workspace return store.workspace.current
}, },
set(next?: string | null) { set(next?: string | null) {
const workspace = next ?? undefined const workspace = next ?? undefined
if (store.workspace === workspace) return if (store.workspace.current === workspace) return
setStore("workspace", workspace) setStore("workspace", "current", workspace)
}, },
list() {
return store.workspace.list
},
get(workspaceID: string) {
return store.workspace.list.find((item) => item.id === workspaceID)
},
status(workspaceID: string) {
return store.workspace.status[workspaceID]
},
statuses() {
return store.workspace.status
},
sync: syncWorkspace,
}, },
sync, sync,
} }
@@ -17,7 +17,6 @@ import type {
ProviderListResponse, ProviderListResponse,
ProviderAuthMethod, ProviderAuthMethod,
VcsInfo, VcsInfo,
Workspace,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "@tui/context/project" import { useProject } from "@tui/context/project"
@@ -75,7 +74,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[key: string]: McpResource [key: string]: McpResource
} }
formatter: FormatterStatus[] formatter: FormatterStatus[]
workspaceList: Workspace[]
vcs: VcsInfo | undefined vcs: VcsInfo | undefined
}>({ }>({
provider_next: { provider_next: {
@@ -103,7 +101,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: {}, mcp: {},
mcp_resource: {}, mcp_resource: {},
formatter: [], formatter: [],
workspaceList: [],
vcs: undefined, vcs: undefined,
}) })
@@ -111,16 +108,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const project = useProject() const project = useProject()
const sdk = useSDK() const sdk = useSDK()
async function syncWorkspaces() {
const workspace = project.workspace.current()
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!result?.data) return
setStore("workspaceList", reconcile(result.data))
if (!result.data.some((item) => item.id === workspace)) {
project.workspace.set(undefined)
}
}
event.subscribe((event) => { event.subscribe((event) => {
switch (event.type) { switch (event.type) {
case "server.instance.disposed": case "server.instance.disposed":
@@ -368,7 +355,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const workspace = project.workspace.current() const workspace = project.workspace.current()
const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session const sessionListPromise = sdk.client.session
.list({ start: start, workspace }) .list({ start: start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
// blocking - include session.list when continuing a session // blocking - include session.list when continuing a session
@@ -443,7 +430,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}), }),
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
syncWorkspaces(), project.workspace.sync(),
]).then(() => { ]).then(() => {
setStore("status", "complete") setStore("status", "complete")
}) })
@@ -522,15 +509,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.add(sessionID) fullSyncedSessions.add(sessionID)
}, },
}, },
workspace: {
list() {
return store.workspaceList
},
get(workspaceID: string) {
return store.workspaceList.find((item) => item.id === workspaceID)
},
sync: syncWorkspaces,
},
bootstrap, bootstrap,
} }
return result return result
@@ -146,14 +146,6 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
branch: sync.data.vcs.branch, branch: sync.data.vcs.branch,
} }
}, },
workspace: {
list() {
return sync.data.workspaceList
},
get(workspaceID) {
return sync.workspace.get(workspaceID)
},
},
session: { session: {
count() { count() {
return sync.data.session.length return sync.data.session.length
@@ -26,6 +26,7 @@ export interface DialogSelectProps<T> {
keybind?: { keybind?: {
keybind?: Keybind.Info keybind?: Keybind.Info
title: string title: string
side?: "left" | "right"
disabled?: boolean disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void onTrigger: (option: DialogSelectOption<T>) => void
}[] }[]
@@ -42,6 +43,7 @@ export interface DialogSelectOption<T = any> {
disabled?: boolean disabled?: boolean
bg?: RGBA bg?: RGBA
gutter?: JSX.Element gutter?: JSX.Element
margin?: JSX.Element
onSelect?: (ctx: DialogContext) => void onSelect?: (ctx: DialogContext) => void
} }
@@ -234,6 +236,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.ref?.(ref) props.ref?.(ref)
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? []) const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
const left = createMemo(() => keybinds().filter((item) => item.side !== "right"))
const right = createMemo(() => keybinds().filter((item) => item.side === "right"))
return ( return (
<box gap={1} paddingBottom={1}> <box gap={1} paddingBottom={1}>
@@ -312,6 +316,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box <box
id={JSON.stringify(option.value)} id={JSON.stringify(option.value)}
flexDirection="row" flexDirection="row"
position="relative"
onMouseMove={() => { onMouseMove={() => {
setStore("input", "mouse") setStore("input", "mouse")
}} }}
@@ -335,6 +340,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
paddingRight={3} paddingRight={3}
gap={1} gap={1}
> >
<Show when={!current() && option.margin}>
<box position="absolute" left={1} flexShrink={0}>
{option.margin}
</box>
</Show>
<Option <Option
title={option.title} title={option.title}
footer={flatten() ? (option.category ?? option.footer) : option.footer} footer={flatten() ? (option.category ?? option.footer) : option.footer}
@@ -353,17 +363,38 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</scrollbox> </scrollbox>
</Show> </Show>
<Show when={keybinds().length} fallback={<box flexShrink={0} />}> <Show when={keybinds().length} fallback={<box flexShrink={0} />}>
<box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}> <box
<For each={keybinds()}> paddingRight={2}
{(item) => ( paddingLeft={4}
<text> flexDirection="row"
<span style={{ fg: theme.text }}> justifyContent="space-between"
<b>{item.title}</b>{" "} flexShrink={0}
</span> paddingTop={1}
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span> >
</text> <box flexDirection="row" gap={2}>
)} <For each={left()}>
</For> {(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
<box flexDirection="row" gap={2}>
<For each={right()}>
{(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
</box> </box>
</Show> </Show>
</box> </box>
+102 -41
View File
@@ -5,7 +5,9 @@ import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project" import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { SyncEvent } from "@/sync"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { WorkspaceTable } from "./workspace.sql" import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors" import { getAdaptor } from "./adaptors"
@@ -14,6 +16,18 @@ import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse" import { parseSSE } from "./sse"
export namespace Workspace { export namespace Workspace {
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
export const ConnectionStatus = z.object({
workspaceID: WorkspaceID.zod,
status: z.enum(["connected", "connecting", "disconnected", "error"]),
error: z.string().optional(),
})
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
export const Event = { export const Event = {
Ready: BusEvent.define( Ready: BusEvent.define(
"workspace.ready", "workspace.ready",
@@ -27,13 +41,9 @@ export namespace Workspace {
message: z.string(), message: z.string(),
}), }),
), ),
Status: BusEvent.define("workspace.status", ConnectionStatus),
} }
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
return { return {
id: row.id, id: row.id,
@@ -85,6 +95,9 @@ export namespace Workspace {
}) })
await adaptor.create(config) await adaptor.create(config)
startSync(info)
return info return info
}) })
@@ -92,18 +105,24 @@ export namespace Workspace {
const rows = Database.use((db) => const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
) )
return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space)
return spaces
} }
export const get = fn(WorkspaceID.zod, async (id) => { export const get = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return if (!row) return
return fromRow(row) const space = fromRow(row)
startSync(space)
return space
}) })
export const remove = fn(WorkspaceID.zod, async (id) => { export const remove = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) { if (row) {
stopSync(id)
const info = fromRow(row) const info = fromRow(row)
const adaptor = await getAdaptor(row.type) const adaptor = await getAdaptor(row.type)
adaptor.remove(info) adaptor.remove(info)
@@ -111,58 +130,100 @@ export namespace Workspace {
return info return info
} }
}) })
const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
const prev = connections.get(id)
if (prev?.status === status && prev?.error === error) return
const next = { workspaceID: id, status, error }
connections.set(id, next)
GlobalBus.emit("event", {
directory: "global",
workspace: id,
payload: {
type: Event.Status.type,
properties: next,
},
})
}
export function status(): ConnectionStatus[] {
return [...connections.values()]
}
const log = Log.create({ service: "workspace-sync" }) const log = Log.create({ service: "workspace-sync" })
async function workspaceEventLoop(space: Info, stop: AbortSignal) { async function workspaceEventLoop(space: Info, signal: AbortSignal) {
while (!stop.aborted) { log.info("starting sync: " + space.id)
while (!signal.aborted) {
log.info("connecting to sync: " + space.id)
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.type) const adaptor = await getAdaptor(space.type)
const target = await Promise.resolve(adaptor.target(space)) const target = await adaptor.target(space)
if (target.type === "local") { if (target.type === "local") return
return
}
const baseURL = String(target.url).replace(/\/?$/, "/") const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => {
setStatus(space.id, "error", String(err))
const res = await fetch(new URL(baseURL + "/event"), { return undefined
method: "GET",
signal: stop,
}) })
if (!res || !res.ok || !res.body) {
log.info("failed to connect to sync: " + res?.status)
if (!res.ok || !res.body) { setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response")
await sleep(1000) await sleep(1000)
continue continue
} }
setStatus(space.id, "connected")
await parseSSE(res.body, signal, (evt) => {
const event = evt as SyncEvent.SerializedEvent
// await parseSSE(res.body, stop, (event) => { try {
// GlobalBus.emit("event", { if (!event.type.startsWith("server.")) {
// directory: space.id, SyncEvent.replay(event)
// payload: event, }
// }) } catch (err) {
// }) log.warn("failed to replay sync event", {
workspaceID: space.id,
// Wait 250ms and retry if SSE connection fails error: err,
})
}
})
setStatus(space.id, "disconnected")
log.info("disconnected to sync: " + space.id)
await sleep(250) await sleep(250)
} }
} }
export function startSyncing(project: Project.Info) { function startSync(space: Info) {
const stop = new AbortController() if (space.type === "worktree") {
const spaces = list(project).filter((space) => space.type !== "worktree") void Filesystem.exists(space.directory!).then((exists) => {
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
})
return
}
spaces.forEach((space) => { if (aborts.has(space.id)) return
void workspaceEventLoop(space, stop.signal).catch((error) => { const abort = new AbortController()
log.warn("workspace sync listener failed", { aborts.set(space.id, abort)
workspaceID: space.id, setStatus(space.id, "disconnected")
error,
}) void workspaceEventLoop(space, abort.signal).catch((error) => {
setStatus(space.id, "error", String(error))
log.warn("workspace sync listener failed", {
workspaceID: space.id,
error,
}) })
}) })
}
return { function stopSync(id: WorkspaceID) {
async stop() { aborts.get(id)?.abort()
stop.abort() aborts.delete(id)
}, connections.delete(id)
}
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context"
export const memoMap = Layer.makeMemoMapUnsafe() export const memoMap = Layer.makeMemoMapUnsafe()
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> { export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try { try {
const ctx = Instance.current const ctx = Instance.current
const workspaceID = WorkspaceContext.workspaceID const workspaceID = WorkspaceContext.workspaceID
+70
View File
@@ -3,10 +3,17 @@ import path from "path"
import { Global } from "../global" import { Global } from "../global"
import fs from "fs/promises" import fs from "fs/promises"
import z from "zod" import z from "zod"
import { Effect, Layer, ServiceMap } from "effect"
import * as Stream from "effect/Stream"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import type { PlatformError } from "effect/PlatformError"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { AppFileSystem } from "../filesystem"
import { Process } from "../util/process" import { Process } from "../util/process"
import { which } from "../util/which" import { which } from "../util/which"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
@@ -274,6 +281,69 @@ export namespace Ripgrep {
input.signal?.throwIfAborted() input.signal?.throwIfAborted()
} }
export interface Interface {
readonly files: (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) => Stream.Stream<string, PlatformError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Ripgrep") {}
export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const afs = yield* AppFileSystem.Service
const files = Effect.fn("Ripgrep.files")(function* (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) {
const rgPath = yield* Effect.promise(() => filepath())
const isDir = yield* afs.isDir(input.cwd)
if (!isDir) {
return yield* Effect.die(
Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
code: "ENOENT" as const,
errno: -2,
path: input.cwd,
}),
)
}
const args = [rgPath, "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
return spawner
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
.pipe(Stream.filter((line: string) => line.length > 0))
})
return Service.of({
files: (input) => Stream.unwrap(files(input)),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) { export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
log.info("tree", input) log.info("tree", input)
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal })) const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
+7
View File
@@ -51,6 +51,13 @@ export namespace Format {
formatters[item.name] = item formatters[item.name] = item
} }
for (const [name, item] of Object.entries(cfg.formatter ?? {})) { for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
// Ruff and uv are both the same formatter, so disabling either should disable both.
if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) {
// TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here.
delete formatters.ruff
delete formatters.uv
continue
}
if (item.disabled) { if (item.disabled) {
delete formatters[name] delete formatters[name]
continue continue
+20 -2
View File
@@ -29,13 +29,20 @@ function local(method: string, path: string) {
return false return false
} }
async function getSessionWorkspace(url: URL) { function getSessionID(url: URL) {
if (url.pathname === "/session/status") return null if (url.pathname === "/session/status") return null
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
if (!id) return null if (!id) return null
const session = await Session.get(SessionID.make(id)).catch(() => undefined) return SessionID.make(id)
}
async function getSessionWorkspace(url: URL) {
const id = getSessionID(url)
if (!id) return null
const session = await Session.get(id).catch(() => undefined)
return session?.workspaceID return session?.workspaceID
} }
@@ -71,7 +78,18 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
} }
const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
if (!workspace) { if (!workspace) {
// Special-case deleting a session in case user's data in a
// weird state. Allow them to forcefully delete a synced session
// even if the remote workspace is not in their data.
//
// The lets the `DELETE /session/:id` endpoint through and we've
// made sure that it will run without an instance
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
return routes().fetch(c.req.raw, c.env)
}
return new Response(`Workspace not found: ${workspaceID}`, { return new Response(`Workspace not found: ${workspaceID}`, {
status: 500, status: 500,
headers: { headers: {
@@ -62,6 +62,28 @@ export const WorkspaceRoutes = lazy(() =>
return c.json(Workspace.list(Instance.project)) return c.json(Workspace.list(Instance.project))
}, },
) )
.get(
"/status",
describeRoute({
summary: "Workspace status",
description: "Get connection status for workspaces in the current project.",
operationId: "experimental.workspace.status",
responses: {
200: {
description: "Workspace status",
content: {
"application/json": {
schema: resolver(z.array(Workspace.ConnectionStatus)),
},
},
},
},
}),
async (c) => {
const ids = new Set(Workspace.list(Instance.project).map((item) => item.id))
return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID)))
},
)
.delete( .delete(
"/:id", "/:id",
describeRoute({ describeRoute({
+13 -4
View File
@@ -413,26 +413,35 @@ export namespace Session {
}) })
const children = Effect.fn("Session.children")(function* (parentID: SessionID) { const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
const ctx = yield* InstanceState.context
const rows = yield* db((d) => const rows = yield* db((d) =>
d d
.select() .select()
.from(SessionTable) .from(SessionTable)
.where(and(eq(SessionTable.project_id, ctx.project.id), eq(SessionTable.parent_id, parentID))) .where(and(eq(SessionTable.parent_id, parentID)))
.all(), .all(),
) )
return rows.map(fromRow) return rows.map(fromRow)
}) })
const remove: (sessionID: SessionID) => Effect.Effect<void> = Effect.fnUntraced(function* (sessionID: SessionID) { const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
try { try {
const session = yield* get(sessionID) const session = yield* get(sessionID)
const kids = yield* children(sessionID) const kids = yield* children(sessionID)
for (const child of kids) { for (const child of kids) {
yield* remove(child.id) yield* remove(child.id)
} }
// `remove` needs to work in all cases, such as a broken
// sessions that run cleanup. In certain cases these will
// run without any instance state, so we need to turn off
// publishing of events in that case
const hasInstance = yield* InstanceState.directory.pipe(
Effect.as(true),
Effect.catchCause(() => Effect.succeed(false)),
)
yield* Effect.sync(() => { yield* Effect.sync(() => {
SyncEvent.run(Event.Deleted, { sessionID, info: session }) SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
SyncEvent.remove(sessionID) SyncEvent.remove(sessionID)
}) })
} catch (e) { } catch (e) {
+6 -4
View File
@@ -165,7 +165,7 @@ export namespace SyncEvent {
// and it validets all the sequence ids // and it validets all the sequence ids
// * when loading events from db, apply zod validation to ensure shape // * when loading events from db, apply zod validation to ensure shape
export function replay(event: SerializedEvent, options?: { republish: boolean }) { export function replay(event: SerializedEvent, options?: { publish: boolean }) {
const def = registry.get(event.type) const def = registry.get(event.type)
if (!def) { if (!def) {
throw new Error(`Unknown event type: ${event.type}`) throw new Error(`Unknown event type: ${event.type}`)
@@ -189,10 +189,10 @@ export namespace SyncEvent {
throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`) throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
} }
process(def, event, { publish: !!options?.republish }) process(def, event, { publish: !!options?.publish })
} }
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"]) { export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
const agg = (data as Record<string, string>)[def.aggregate] const agg = (data as Record<string, string>)[def.aggregate]
// This should never happen: we've enforced it via typescript in // This should never happen: we've enforced it via typescript in
// the definition // the definition
@@ -204,6 +204,8 @@ export namespace SyncEvent {
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`) throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
} }
const { publish = true } = options || {}
// Note that this is an "immediate" transaction which is critical. // Note that this is an "immediate" transaction which is critical.
// We need to make sure we can safely read and write with nothing // We need to make sure we can safely read and write with nothing
// else changing the data from under us // else changing the data from under us
@@ -218,7 +220,7 @@ export namespace SyncEvent {
const seq = row?.seq != null ? row.seq + 1 : 0 const seq = row?.seq != null ? row.seq + 1 : 0
const event = { id, seq, aggregateID: agg, data } const event = { id, seq, aggregateID: agg, data }
process(def, event, { publish: true }) process(def, event, { publish })
}, },
{ {
behavior: "immediate", behavior: "immediate",
+237 -224
View File
@@ -8,8 +8,7 @@ import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Language, type Node } from "web-tree-sitter" import { Language, type Node } from "web-tree-sitter"
import { Filesystem } from "@/util/filesystem" import { AppFileSystem } from "@/filesystem"
import { Process } from "@/util/process"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
@@ -17,9 +16,9 @@ import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity" import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncate" import { Truncate } from "./truncate"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Cause, Effect, Exit, Stream } from "effect" import { Effect, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
const MAX_METADATA_LENGTH = 30_000 const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@@ -183,34 +182,6 @@ function prefix(text: string) {
return text.slice(0, match.index) return text.slice(0, match.index)
} }
async function cygpath(shell: string, text: string) {
const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true })
if (out.code !== 0) return
const file = out.text.trim()
if (!file) return
return Filesystem.normalizePath(file)
}
async function resolvePath(text: string, root: string, shell: string) {
if (process.platform === "win32") {
if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) {
const file = await cygpath(shell, text)
if (file) return file
}
return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text)))
}
return path.resolve(root, text)
}
async function argPath(arg: string, cwd: string, ps: boolean, shell: string) {
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
const file = text && prefix(text)
if (!file || dynamic(file, ps)) return
const next = ps ? provider(file) : file
if (!next) return
return resolvePath(next, cwd, shell)
}
function pathArgs(list: Part[], ps: boolean) { function pathArgs(list: Part[], ps: boolean) {
if (!ps) { if (!ps) {
return list return list
@@ -238,78 +209,43 @@ function pathArgs(list: Part[], ps: boolean) {
return out return out
} }
async function collect(root: Node, cwd: string, ps: boolean, shell: string): Promise<Scan> {
const scan: Scan = {
dirs: new Set<string>(),
patterns: new Set<string>(),
always: new Set<string>(),
}
for (const node of commands(root)) {
const command = parts(node)
const tokens = command.map((item) => item.text)
const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
if (cmd && FILES.has(cmd)) {
for (const arg of pathArgs(command, ps)) {
const resolved = await argPath(arg, cwd, ps, shell)
log.info("resolved path", { arg, resolved })
if (!resolved || Instance.containsPath(resolved)) continue
const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved)
scan.dirs.add(dir)
}
}
if (tokens.length && (!cmd || !CWD.has(cmd))) {
scan.patterns.add(source(node))
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
}
}
return scan
}
function preview(text: string) { function preview(text: string) {
if (text.length <= MAX_METADATA_LENGTH) return text if (text.length <= MAX_METADATA_LENGTH) return text
return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..." return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..."
} }
async function parse(command: string, ps: boolean) { const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) {
const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command)) const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command)))
if (!tree) throw new Error("Failed to parse command") if (!tree) throw new Error("Failed to parse command")
return tree.rootNode return tree.rootNode
} })
async function ask(ctx: Tool.Context, scan: Scan) { const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) {
if (scan.dirs.size > 0) { if (scan.dirs.size > 0) {
const globs = Array.from(scan.dirs).map((dir) => { const globs = Array.from(scan.dirs).map((dir) => {
if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*")) if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
return path.join(dir, "*") return path.join(dir, "*")
}) })
await ctx.ask({ yield* Effect.promise(() =>
permission: "external_directory", ctx.ask({
patterns: globs, permission: "external_directory",
always: globs, patterns: globs,
metadata: {}, always: globs,
}) metadata: {},
}),
)
} }
if (scan.patterns.size === 0) return if (scan.patterns.size === 0) return
await ctx.ask({ yield* Effect.promise(() =>
permission: "bash", ctx.ask({
patterns: Array.from(scan.patterns), permission: "bash",
always: Array.from(scan.always), patterns: Array.from(scan.patterns),
metadata: {}, always: Array.from(scan.always),
}) metadata: {},
} }),
)
async function shellEnv(ctx: Tool.Context, cwd: string) { })
const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} })
return {
...process.env,
...extra.env,
}
}
function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && PS.has(name)) { if (process.platform === "win32" && PS.has(name)) {
@@ -330,100 +266,6 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
}) })
} }
async function run(
input: {
shell: string
name: string
command: string
cwd: string
env: NodeJS.ProcessEnv
timeout: number
description: string
},
ctx: Tool.Context,
) {
let output = ""
let expired = false
let aborted = false
ctx.metadata({
metadata: {
output: "",
description: input.description,
},
})
const exit = await CrossSpawnSpawner.runPromiseExit((spawner) =>
Effect.gen(function* () {
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
Effect.sync(() => {
output += chunk
ctx.metadata({
metadata: {
output: preview(output),
description: input.description,
},
})
}),
),
)
const abort = Effect.callback<void>((resume) => {
if (ctx.abort.aborted) return resume(Effect.void)
const handler = () => resume(Effect.void)
ctx.abort.addEventListener("abort", handler, { once: true })
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
})
const timeout = Effect.sleep(`${input.timeout + 100} millis`)
const exit = yield* Effect.raceAll([
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])
if (exit.kind === "abort") {
aborted = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
if (exit.kind === "timeout") {
expired = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
return exit.kind === "exit" ? exit.code : null
}).pipe(Effect.scoped, Effect.orDie),
)
let code: number | null = null
if (Exit.isSuccess(exit)) {
code = exit.value
} else if (!Cause.hasInterruptsOnly(exit.cause)) {
throw Cause.squash(exit.cause)
}
const meta: string[] = []
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
if (aborted) meta.push("User aborted the command")
if (meta.length > 0) {
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
}
return {
title: input.description,
metadata: {
output: preview(output),
exit: code,
description: input.description,
},
output,
}
}
const parser = lazy(async () => { const parser = lazy(async () => {
const { Parser } = await import("web-tree-sitter") const { Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
@@ -452,47 +294,218 @@ const parser = lazy(async () => {
}) })
// TODO: we may wanna rename this tool so it works better on other shells // TODO: we may wanna rename this tool so it works better on other shells
export const BashTool = Tool.define("bash", async () => { export const BashTool = Tool.defineEffect(
const shell = Shell.acceptable() "bash",
const name = Shell.name(shell) Effect.gen(function* () {
const chain = const spawner = yield* ChildProcessSpawner
name === "powershell" const fs = yield* AppFileSystem.Service
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." const plugin = yield* Plugin.Service
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
return { const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory) const lines = yield* spawner
.replaceAll("${os}", process.platform) .lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]))
.replaceAll("${shell}", name) .pipe(Effect.catch(() => Effect.succeed([] as string[])))
.replaceAll("${chaining}", chain) const file = lines[0]?.trim()
.replaceAll("${maxLines}", String(Truncate.MAX_LINES)) if (!file) return
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), return AppFileSystem.normalizePath(file)
parameters: Parameters, })
async execute(params, ctx) {
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) {
if (params.timeout !== undefined && params.timeout < 0) { if (process.platform === "win32") {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) {
const file = yield* cygpath(shell, text)
if (file) return file
}
return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text)))
} }
const timeout = params.timeout ?? DEFAULT_TIMEOUT return path.resolve(root, text)
const ps = PS.has(name) })
const root = await parse(params.command, ps)
const scan = await collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
await ask(ctx, scan)
return run( const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
{ const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
shell, const file = text && prefix(text)
name, if (!file || dynamic(file, ps)) return
command: params.command, const next = ps ? provider(file) : file
cwd, if (!next) return
env: await shellEnv(ctx, cwd), return yield* resolvePath(next, cwd, shell)
timeout, })
description: params.description,
}, const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) {
ctx, const scan: Scan = {
dirs: new Set<string>(),
patterns: new Set<string>(),
always: new Set<string>(),
}
for (const node of commands(root)) {
const command = parts(node)
const tokens = command.map((item) => item.text)
const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
if (cmd && FILES.has(cmd)) {
for (const arg of pathArgs(command, ps)) {
const resolved = yield* argPath(arg, cwd, ps, shell)
log.info("resolved path", { arg, resolved })
if (!resolved || Instance.containsPath(resolved)) continue
const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
scan.dirs.add(dir)
}
}
if (tokens.length && (!cmd || !CWD.has(cmd))) {
scan.patterns.add(source(node))
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
}
}
return scan
})
const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
const extra = yield* plugin.trigger(
"shell.env",
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
{ env: {} },
) )
}, return {
} ...process.env,
}) ...extra.env,
}
})
const run = Effect.fn("BashTool.run")(function* (
input: {
shell: string
name: string
command: string
cwd: string
env: NodeJS.ProcessEnv
timeout: number
description: string
},
ctx: Tool.Context,
) {
let output = ""
let expired = false
let aborted = false
ctx.metadata({
metadata: {
output: "",
description: input.description,
},
})
const code: number | null = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
Effect.sync(() => {
output += chunk
ctx.metadata({
metadata: {
output: preview(output),
description: input.description,
},
})
}),
),
)
const abort = Effect.callback<void>((resume) => {
if (ctx.abort.aborted) return resume(Effect.void)
const handler = () => resume(Effect.void)
ctx.abort.addEventListener("abort", handler, { once: true })
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
})
const timeout = Effect.sleep(`${input.timeout + 100} millis`)
const exit = yield* Effect.raceAll([
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
])
if (exit.kind === "abort") {
aborted = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
if (exit.kind === "timeout") {
expired = true
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
}
return exit.kind === "exit" ? exit.code : null
}),
).pipe(Effect.orDie)
const meta: string[] = []
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
if (aborted) meta.push("User aborted the command")
if (meta.length > 0) {
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
}
return {
title: input.description,
metadata: {
output: preview(output),
exit: code,
description: input.description,
},
output,
}
})
return async () => {
const shell = Shell.acceptable()
const name = Shell.name(shell)
const chain =
name === "powershell"
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform)
.replaceAll("${shell}", name)
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const cwd = params.workdir
? yield* resolvePath(params.workdir, Instance.directory, shell)
: Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = params.timeout ?? DEFAULT_TIMEOUT
const ps = PS.has(name)
const root = yield* parse(params.command, ps)
const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
yield* ask(ctx, scan)
return yield* run(
{
shell,
name,
command: params.command,
cwd,
env: yield* shellEnv(ctx, cwd),
timeout,
description: params.description,
},
ctx,
)
}).pipe(Effect.orDie, Effect.runPromise),
}
}
}),
)
+86 -68
View File
@@ -1,78 +1,96 @@
import z from "zod" import z from "zod"
import path from "path" import path from "path"
import { Effect, Option } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import DESCRIPTION from "./glob.txt" import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "../filesystem"
export const GlobTool = Tool.define("glob", { export const GlobTool = Tool.defineEffect(
description: DESCRIPTION, "glob",
parameters: z.object({ Effect.gen(function* () {
pattern: z.string().describe("The glob pattern to match files against"), const rg = yield* Ripgrep.Service
path: z const fs = yield* AppFileSystem.Service
.string()
.optional()
.describe(
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
),
}),
async execute(params, ctx) {
await ctx.ask({
permission: "glob",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
},
})
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
await assertExternalDirectory(ctx, search, { kind: "directory" })
const limit = 100
const files = []
let truncated = false
for await (const file of Ripgrep.files({
cwd: search,
glob: [params.pattern],
signal: ctx.abort,
})) {
if (files.length >= limit) {
truncated = true
break
}
const full = path.resolve(search, file)
const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0
files.push({
path: full,
mtime: stats,
})
}
files.sort((a, b) => b.mtime - a.mtime)
const output = []
if (files.length === 0) output.push("No files found")
if (files.length > 0) {
output.push(...files.map((f) => f.path))
if (truncated) {
output.push("")
output.push(
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
)
}
}
return { return {
title: path.relative(Instance.worktree, search), description: DESCRIPTION,
metadata: { parameters: z.object({
count: files.length, pattern: z.string().describe("The glob pattern to match files against"),
truncated, path: z
}, .string()
output: output.join("\n"), .optional()
.describe(
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
),
}),
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
ctx.ask({
permission: "glob",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
},
}),
)
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
const limit = 100
let truncated = false
const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe(
Stream.mapEffect((file) =>
Effect.gen(function* () {
const full = path.resolve(search, file)
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
const mtime =
info?.mtime.pipe(
Option.map((d) => d.getTime()),
Option.getOrElse(() => 0),
) ?? 0
return { path: full, mtime }
}),
),
Stream.take(limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
if (files.length > limit) {
truncated = true
files.length = limit
}
files.sort((a, b) => b.mtime - a.mtime)
const output = []
if (files.length === 0) output.push("No files found")
if (files.length > 0) {
output.push(...files.map((f) => f.path))
if (truncated) {
output.push("")
output.push(
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
)
}
}
return {
title: path.relative(Instance.worktree, search),
metadata: {
count: files.length,
truncated,
},
output: output.join("\n"),
}
}).pipe(Effect.orDie, Effect.runPromise),
} }
}, }),
}) )
+97 -82
View File
@@ -1,10 +1,12 @@
import z from "zod" import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool" import { Tool } from "./tool"
import * as path from "path" import * as path from "path"
import DESCRIPTION from "./ls.txt" import DESCRIPTION from "./ls.txt"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
export const IGNORE_PATTERNS = [ export const IGNORE_PATTERNS = [
"node_modules/", "node_modules/",
@@ -35,87 +37,100 @@ export const IGNORE_PATTERNS = [
const LIMIT = 100 const LIMIT = 100
export const ListTool = Tool.define("list", { export const ListTool = Tool.defineEffect(
description: DESCRIPTION, "list",
parameters: z.object({ Effect.gen(function* () {
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(), const rg = yield* Ripgrep.Service
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
async execute(params, ctx) {
const searchPath = path.resolve(Instance.directory, params.path || ".")
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
await ctx.ask({
permission: "list",
patterns: [searchPath],
always: ["*"],
metadata: {
path: searchPath,
},
})
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = []
for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, signal: ctx.abort })) {
files.push(file)
if (files.length >= LIMIT) break
}
// Build directory structure
const dirs = new Set<string>()
const filesByDir = new Map<string, string[]>()
for (const file of files) {
const dir = path.dirname(file)
const parts = dir === "." ? [] : dir.split("/")
// Add all parent directories
for (let i = 0; i <= parts.length; i++) {
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
dirs.add(dirPath)
}
// Add file to its directory
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
filesByDir.get(dir)!.push(path.basename(file))
}
function renderDir(dirPath: string, depth: number): string {
const indent = " ".repeat(depth)
let output = ""
if (depth > 0) {
output += `${indent}${path.basename(dirPath)}/\n`
}
const childIndent = " ".repeat(depth + 1)
const children = Array.from(dirs)
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
.sort()
// Render subdirectories first
for (const child of children) {
output += renderDir(child, depth + 1)
}
// Render files
const files = filesByDir.get(dirPath) || []
for (const file of files.sort()) {
output += `${childIndent}${file}\n`
}
return output
}
const output = `${searchPath}/\n` + renderDir(".", 0)
return { return {
title: path.relative(Instance.worktree, searchPath), description: DESCRIPTION,
metadata: { parameters: z.object({
count: files.length, path: z
truncated: files.length >= LIMIT, .string()
}, .describe("The absolute path to the directory to list (must be absolute, not relative)")
output, .optional(),
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) =>
Effect.gen(function* () {
const searchPath = path.resolve(Instance.directory, params.path || ".")
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
yield* Effect.promise(() =>
ctx.ask({
permission: "list",
patterns: [searchPath],
always: ["*"],
metadata: {
path: searchPath,
},
}),
)
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
Stream.take(LIMIT),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
// Build directory structure
const dirs = new Set<string>()
const filesByDir = new Map<string, string[]>()
for (const file of files) {
const dir = path.dirname(file)
const parts = dir === "." ? [] : dir.split("/")
// Add all parent directories
for (let i = 0; i <= parts.length; i++) {
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
dirs.add(dirPath)
}
// Add file to its directory
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
filesByDir.get(dir)!.push(path.basename(file))
}
function renderDir(dirPath: string, depth: number): string {
const indent = " ".repeat(depth)
let output = ""
if (depth > 0) {
output += `${indent}${path.basename(dirPath)}/\n`
}
const childIndent = " ".repeat(depth + 1)
const children = Array.from(dirs)
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
.sort()
// Render subdirectories first
for (const child of children) {
output += renderDir(child, depth + 1)
}
// Render files
const files = filesByDir.get(dirPath) || []
for (const file of files.sort()) {
output += `${childIndent}${file}\n`
}
return output
}
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
title: path.relative(Instance.worktree, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,
},
output,
}
}).pipe(Effect.orDie, Effect.runPromise),
} }
}, }),
}) )
+50 -38
View File
@@ -4,43 +4,55 @@ import { EditTool } from "./edit"
import DESCRIPTION from "./multiedit.txt" import DESCRIPTION from "./multiedit.txt"
import path from "path" import path from "path"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Effect } from "effect"
export const MultiEditTool = Tool.define("multiedit", { const Parameters = z.object({
description: DESCRIPTION, filePath: z.string().describe("The absolute path to the file to modify"),
parameters: z.object({ edits: z
filePath: z.string().describe("The absolute path to the file to modify"), .array(
edits: z z.object({
.array( filePath: z.string().describe("The absolute path to the file to modify"),
z.object({ oldString: z.string().describe("The text to replace"),
filePath: z.string().describe("The absolute path to the file to modify"), newString: z.string().describe("The text to replace it with (must be different from oldString)"),
oldString: z.string().describe("The text to replace"), replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"), }),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), )
}), .describe("Array of edit operations to perform sequentially on the file"),
)
.describe("Array of edit operations to perform sequentially on the file"),
}),
async execute(params, ctx) {
const tool = await EditTool.init()
const results = []
for (const [, edit] of params.edits.entries()) {
const result = await tool.execute(
{
filePath: params.filePath,
oldString: edit.oldString,
newString: edit.newString,
replaceAll: edit.replaceAll,
},
ctx,
)
results.push(result)
}
return {
title: path.relative(Instance.worktree, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
},
output: results.at(-1)!.output,
}
},
}) })
export const MultiEditTool = Tool.defineEffect(
"multiedit",
Effect.gen(function* () {
const tool = yield* Tool.init(EditTool)
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const results = []
for (const [, edit] of params.edits.entries()) {
const result = yield* Effect.promise(() =>
tool.execute(
{
filePath: params.filePath,
oldString: edit.oldString,
newString: edit.newString,
replaceAll: edit.replaceAll,
},
ctx,
),
)
results.push(result)
}
return {
title: path.relative(Instance.worktree, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
},
output: results.at(-1)!.output,
}
}).pipe(Effect.orDie, Effect.runPromise),
}
}),
)
+11 -2
View File
@@ -31,6 +31,9 @@ import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env" import { Env } from "../env"
@@ -86,6 +89,8 @@ export namespace ToolRegistry {
| Instruction.Service | Instruction.Service
| AppFileSystem.Service | AppFileSystem.Service
| HttpClient.HttpClient | HttpClient.HttpClient
| ChildProcessSpawner
| Ripgrep.Service
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -102,7 +107,9 @@ export namespace ToolRegistry {
const plan = yield* PlanExitTool const plan = yield* PlanExitTool
const webfetch = yield* WebFetchTool const webfetch = yield* WebFetchTool
const websearch = yield* WebSearchTool const websearch = yield* WebSearchTool
const bash = yield* BashTool
const codesearch = yield* CodeSearchTool const codesearch = yield* CodeSearchTool
const globtool = yield* GlobTool
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) { Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -161,9 +168,9 @@ export namespace ToolRegistry {
const tool = yield* Effect.all({ const tool = yield* Effect.all({
invalid: Tool.init(InvalidTool), invalid: Tool.init(InvalidTool),
bash: Tool.init(BashTool), bash: Tool.init(bash),
read: Tool.init(read), read: Tool.init(read),
glob: Tool.init(GlobTool), glob: Tool.init(globtool),
grep: Tool.init(GrepTool), grep: Tool.init(GrepTool),
edit: Tool.init(EditTool), edit: Tool.init(EditTool),
write: Tool.init(WriteTool), write: Tool.init(WriteTool),
@@ -315,6 +322,8 @@ export namespace ToolRegistry {
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
), ),
) )
@@ -244,7 +244,6 @@ describe("SyncProvider", () => {
expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true) expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true) expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/session" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true) expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true)
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -52,3 +54,52 @@ describe("file.ripgrep", () => {
expect(hits).toEqual([]) expect(hits).toEqual([])
}) })
}) })
describe("Ripgrep.Service", () => {
test("files returns stream of filenames", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "a.txt"), "hello")
await Bun.write(path.join(dir, "b.txt"), "world")
},
})
const files = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.files({ cwd: tmp.path }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk].sort()),
)
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(files).toEqual(["a.txt", "b.txt"])
})
test("files respects glob filter", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "keep.ts"), "yes")
await Bun.write(path.join(dir, "skip.txt"), "no")
},
})
const files = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(files).toEqual(["keep.ts"])
})
test("files dies on nonexistent directory", async () => {
const exit = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect)
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit)
expect(exit._tag).toBe("Failure")
})
})
+1 -6
View File
@@ -93,7 +93,6 @@ type Opts = {
provider?: HostPluginApi["state"]["provider"] provider?: HostPluginApi["state"]["provider"]
path?: HostPluginApi["state"]["path"] path?: HostPluginApi["state"]["path"]
vcs?: HostPluginApi["state"]["vcs"] vcs?: HostPluginApi["state"]["vcs"]
workspace?: Partial<HostPluginApi["state"]["workspace"]>
session?: Partial<HostPluginApi["state"]["session"]> session?: Partial<HostPluginApi["state"]["session"]>
part?: HostPluginApi["state"]["part"] part?: HostPluginApi["state"]["part"]
lsp?: HostPluginApi["state"]["lsp"] lsp?: HostPluginApi["state"]["lsp"]
@@ -277,15 +276,11 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
return opts.state?.provider ?? [] return opts.state?.provider ?? []
}, },
get path() { get path() {
return opts.state?.path ?? { state: "", config: "", worktree: "", directory: "" } return opts.state?.path ?? { home: "", state: "", config: "", worktree: "", directory: "" }
}, },
get vcs() { get vcs() {
return opts.state?.vcs return opts.state?.vcs
}, },
workspace: {
list: opts.state?.workspace?.list ?? (() => []),
get: opts.state?.workspace?.get ?? (() => undefined),
},
session: { session: {
count: opts.state?.session?.count ?? (() => 0), count: opts.state?.session?.count ?? (() => 0),
diff: opts.state?.session?.diff ?? (() => []), diff: opts.state?.session?.diff ?? (() => []),
@@ -64,6 +64,46 @@ describe("Format", () => {
), ),
) )
it.live("status() excludes uv when ruff is disabled", () =>
provideTmpdirInstance(
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
}),
),
{
config: {
formatter: {
ruff: { disabled: true },
},
},
},
),
)
it.live("status() excludes ruff when uv is disabled", () =>
provideTmpdirInstance(
() =>
Format.Service.use((fmt) =>
Effect.gen(function* () {
const statuses = yield* fmt.status()
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
}),
),
{
config: {
formatter: {
uv: { disabled: true },
},
},
},
),
)
it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void))) it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void)))
it.live("status() initializes formatter state per directory", () => it.live("status() initializes formatter state per directory", () =>
@@ -37,6 +37,7 @@ import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server" import { reply, TestLLMServer } from "../lib/llm-server"
@@ -171,6 +172,8 @@ function makeHttp() {
const registry = ToolRegistry.layer.pipe( const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),
@@ -6,6 +6,7 @@ import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID } from "../../src/session/schema" import { MessageID, PartID } from "../../src/session/schema"
import { tmpdir } from "../fixture/fixture"
const projectRoot = path.join(__dirname, "../..") const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false }) Log.init({ print: false })
@@ -140,3 +141,25 @@ describe("step-finish token propagation via Bus event", () => {
{ timeout: 30000 }, { timeout: 30000 },
) )
}) })
describe("Session", () => {
test("remove works without an instance", async () => {
await using tmp = await tmpdir({ git: true })
const session = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "remove-without-instance" }),
})
await expect(async () => {
await Session.remove(session.id)
}).not.toThrow()
let missing = false
await Session.get(session.id).catch(() => {
missing = true
})
expect(missing).toBe(true)
})
})
@@ -53,6 +53,7 @@ import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
Log.init({ print: false }) Log.init({ print: false })
@@ -135,6 +136,8 @@ function makeHttp() {
const registry = ToolRegistry.layer.pipe( const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),
+50 -38
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, Layer, ManagedRuntime } from "effect"
import os from "os" import os from "os"
import path from "path" import path from "path"
import { Shell } from "../../src/shell/shell" import { Shell } from "../../src/shell/shell"
@@ -9,6 +10,17 @@ import { tmpdir } from "../fixture/fixture"
import type { Permission } from "../../src/permission" import type { Permission } from "../../src/permission"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { AppFileSystem } from "../../src/filesystem"
import { Plugin } from "../../src/plugin"
const runtime = ManagedRuntime.make(
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Plugin.defaultLayer),
)
function initBash() {
return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
}
const ctx = { const ctx = {
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
@@ -118,7 +130,7 @@ describe("tool.bash", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const result = await bash.execute( const result = await bash.execute(
{ {
command: "echo test", command: "echo test",
@@ -139,7 +151,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -160,7 +172,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -184,7 +196,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -208,7 +220,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
@@ -242,7 +254,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
@@ -273,7 +285,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -301,7 +313,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
await bash.execute( await bash.execute(
@@ -331,7 +343,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -359,7 +371,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -388,7 +400,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -416,7 +428,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -448,7 +460,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
@@ -481,7 +493,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -508,7 +520,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -538,7 +550,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -568,7 +580,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -597,7 +609,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -622,7 +634,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -645,7 +657,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -673,7 +685,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*")) const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*"))
for (const dir of forms(outerTmp.path)) { for (const dir of forms(outerTmp.path)) {
@@ -707,7 +719,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const want = glob(path.join(os.tmpdir(), "*")) const want = glob(path.join(os.tmpdir(), "*"))
@@ -737,7 +749,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const want = glob(path.join(os.tmpdir(), "*")) const want = glob(path.join(os.tmpdir(), "*"))
@@ -772,7 +784,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const filepath = path.join(outerTmp.path, "outside.txt") const filepath = path.join(outerTmp.path, "outside.txt")
@@ -803,7 +815,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -823,7 +835,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -844,7 +856,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute( await bash.execute(
{ {
@@ -864,7 +876,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await expect( await expect(
@@ -885,7 +897,7 @@ describe("tool.bash permissions", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
await bash.execute({ command: "ls -la", description: "List" }, capture(requests)) await bash.execute({ command: "ls -la", description: "List" }, capture(requests))
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
@@ -901,7 +913,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const controller = new AbortController() const controller = new AbortController()
const collected: string[] = [] const collected: string[] = []
const result = bash.execute( const result = bash.execute(
@@ -933,7 +945,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const result = await bash.execute( const result = await bash.execute(
{ {
command: `echo started && sleep 60`, command: `echo started && sleep 60`,
@@ -952,7 +964,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const result = await bash.execute( const result = await bash.execute(
{ {
command: `echo stdout_msg && echo stderr_msg >&2`, command: `echo stdout_msg && echo stderr_msg >&2`,
@@ -971,7 +983,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const result = await bash.execute( const result = await bash.execute(
{ {
command: `exit 42`, command: `exit 42`,
@@ -988,7 +1000,7 @@ describe("tool.bash abort", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const updates: string[] = [] const updates: string[] = []
const result = await bash.execute( const result = await bash.execute(
{ {
@@ -1016,7 +1028,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const lineCount = Truncate.MAX_LINES + 500 const lineCount = Truncate.MAX_LINES + 500
const result = await bash.execute( const result = await bash.execute(
{ {
@@ -1036,7 +1048,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const byteCount = Truncate.MAX_BYTES + 10000 const byteCount = Truncate.MAX_BYTES + 10000
const result = await bash.execute( const result = await bash.execute(
{ {
@@ -1056,7 +1068,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const result = await bash.execute( const result = await bash.execute(
{ {
command: "echo hello", command: "echo hello",
@@ -1074,7 +1086,7 @@ describe("tool.bash truncation", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const bash = await BashTool.init() const bash = await initBash()
const lineCount = Truncate.MAX_LINES + 100 const lineCount = Truncate.MAX_LINES + 100
const result = await bash.execute( const result = await bash.execute(
{ {
-4
View File
@@ -272,10 +272,6 @@ export type TuiState = {
directory: string directory: string
} }
readonly vcs: { branch?: string } | undefined readonly vcs: { branch?: string } | undefined
readonly workspace: {
list: () => ReadonlyArray<Workspace>
get: (workspaceID: string) => Workspace | undefined
}
session: { session: {
count: () => number count: () => number
diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem> diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
+31
View File
@@ -34,6 +34,7 @@ import type {
ExperimentalWorkspaceListResponses, ExperimentalWorkspaceListResponses,
ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveErrors,
ExperimentalWorkspaceRemoveResponses, ExperimentalWorkspaceRemoveResponses,
ExperimentalWorkspaceStatusResponses,
FileListResponses, FileListResponses,
FilePartInput, FilePartInput,
FilePartSource, FilePartSource,
@@ -1163,6 +1164,36 @@ export class Workspace extends HeyApiClient {
}) })
} }
/**
* Workspace status
*
* Get connection status for workspaces in the current project.
*/
public status<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<ExperimentalWorkspaceStatusResponses, unknown, ThrowOnError>({
url: "/experimental/workspace/status",
...options,
...params,
})
}
/** /**
* Remove workspace * Remove workspace
* *
+34
View File
@@ -330,6 +330,15 @@ export type EventWorkspaceFailed = {
} }
} }
export type EventWorkspaceStatus = {
type: "workspace.status"
properties: {
workspaceID: string
status: "connected" | "connecting" | "disconnected" | "error"
error?: string
}
}
export type QuestionOption = { export type QuestionOption = {
/** /**
* Display text (1-5 words, concise) * Display text (1-5 words, concise)
@@ -988,6 +997,7 @@ export type Event =
| EventCommandExecuted | EventCommandExecuted
| EventWorkspaceReady | EventWorkspaceReady
| EventWorkspaceFailed | EventWorkspaceFailed
| EventWorkspaceStatus
| EventQuestionAsked | EventQuestionAsked
| EventQuestionReplied | EventQuestionReplied
| EventQuestionRejected | EventQuestionRejected
@@ -2857,6 +2867,30 @@ export type ExperimentalWorkspaceCreateResponses = {
export type ExperimentalWorkspaceCreateResponse = export type ExperimentalWorkspaceCreateResponse =
ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses]
export type ExperimentalWorkspaceStatusData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/experimental/workspace/status"
}
export type ExperimentalWorkspaceStatusResponses = {
/**
* Workspace status
*/
200: Array<{
workspaceID: string
status: "connected" | "connecting" | "disconnected" | "error"
error?: string
}>
}
export type ExperimentalWorkspaceStatusResponse =
ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses]
export type ExperimentalWorkspaceRemoveData = { export type ExperimentalWorkspaceRemoveData = {
body?: never body?: never
path: { path: {
+88
View File
@@ -1656,6 +1656,64 @@
] ]
} }
}, },
"/experimental/workspace/status": {
"get": {
"operationId": "experimental.workspace.status",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "Workspace status",
"description": "Get connection status for workspaces in the current project.",
"responses": {
"200": {
"description": "Workspace status",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"workspaceID": {
"type": "string",
"pattern": "^wrk.*"
},
"status": {
"type": "string",
"enum": ["connected", "connecting", "disconnected", "error"]
},
"error": {
"type": "string"
}
},
"required": ["workspaceID", "status"]
}
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})"
}
]
}
},
"/experimental/workspace/{id}": { "/experimental/workspace/{id}": {
"delete": { "delete": {
"operationId": "experimental.workspace.remove", "operationId": "experimental.workspace.remove",
@@ -7966,6 +8024,33 @@
}, },
"required": ["type", "properties"] "required": ["type", "properties"]
}, },
"Event.workspace.status": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "workspace.status"
},
"properties": {
"type": "object",
"properties": {
"workspaceID": {
"type": "string",
"pattern": "^wrk.*"
},
"status": {
"type": "string",
"enum": ["connected", "connecting", "disconnected", "error"]
},
"error": {
"type": "string"
}
},
"required": ["workspaceID", "status"]
}
},
"required": ["type", "properties"]
},
"QuestionOption": { "QuestionOption": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -9858,6 +9943,9 @@
{ {
"$ref": "#/components/schemas/Event.workspace.failed" "$ref": "#/components/schemas/Event.workspace.failed"
}, },
{
"$ref": "#/components/schemas/Event.workspace.status"
},
{ {
"$ref": "#/components/schemas/Event.question.asked" "$ref": "#/components/schemas/Event.question.asked"
}, },