Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ff3f963f1 | ||
|
|
378b8ca241 | ||
|
|
f63bdc8e08 | ||
|
|
ce26120205 | ||
|
|
d2d5d84d1e | ||
|
|
847f1d99c9 | ||
|
|
59d08683ea | ||
|
|
f7514d9eca | ||
|
|
180ded6a27 |
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user