mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-25 10:04:40 +00:00
feat(tui): add initial support for workspaces into the tui (#16230)
This commit is contained in:
@@ -20,6 +20,7 @@ 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 { KeybindProvider } from "@tui/context/keybind"
|
import { KeybindProvider } from "@tui/context/keybind"
|
||||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||||
import { Home } from "@tui/routes/home"
|
import { Home } from "@tui/routes/home"
|
||||||
@@ -371,6 +372,22 @@ function App() {
|
|||||||
dialog.replace(() => <DialogSessionList />)
|
dialog.replace(() => <DialogSessionList />)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
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 { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||||
|
import 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 { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
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 = createOpencodeClient({
|
||||||
|
baseUrl: input.sdk.url,
|
||||||
|
fetch: input.sdk.fetch,
|
||||||
|
directory: input.sync.data.path.directory || input.sdk.directory,
|
||||||
|
experimental_workspaceID: 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({}).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 Bun.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 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) {
|
||||||
|
if (workspaceID === "__local__") {
|
||||||
|
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 = createOpencodeClient({
|
||||||
|
baseUrl: sdk.url,
|
||||||
|
fetch: sdk.fetch,
|
||||||
|
directory: sync.data.path.directory || sdk.directory,
|
||||||
|
experimental_workspaceID: 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(() => {
|
||||||
|
if (route.data.type === "session") {
|
||||||
|
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
|
||||||
|
}
|
||||||
|
return "__local__"
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = createOpencodeClient({
|
||||||
|
baseUrl: sdk.url,
|
||||||
|
fetch: sdk.fetch,
|
||||||
|
directory: sync.data.path.directory || sdk.directory,
|
||||||
|
experimental_workspaceID: 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: "__local__",
|
||||||
|
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 === "__local__") 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) {
|
||||||
|
route.navigate({
|
||||||
|
type: "home",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await sync.workspace.sync()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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} />)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { batch, onCleanup, onMount } from "solid-js"
|
|||||||
|
|
||||||
export type EventSource = {
|
export type EventSource = {
|
||||||
on: (handler: (event: Event) => void) => () => void
|
on: (handler: (event: Event) => void) => () => void
|
||||||
|
setWorkspace?: (workspaceID?: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
@@ -17,13 +18,21 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
events?: EventSource
|
events?: EventSource
|
||||||
}) => {
|
}) => {
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const sdk = createOpencodeClient({
|
let workspaceID: string | undefined
|
||||||
baseUrl: props.url,
|
let sse: AbortController | undefined
|
||||||
signal: abort.signal,
|
|
||||||
directory: props.directory,
|
function createSDK() {
|
||||||
fetch: props.fetch,
|
return createOpencodeClient({
|
||||||
headers: props.headers,
|
baseUrl: props.url,
|
||||||
})
|
signal: abort.signal,
|
||||||
|
directory: props.directory,
|
||||||
|
fetch: props.fetch,
|
||||||
|
headers: props.headers,
|
||||||
|
experimental_workspaceID: workspaceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let sdk = createSDK()
|
||||||
|
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||||
@@ -61,41 +70,56 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
flush()
|
flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
function startSSE() {
|
||||||
// If an event source is provided, use it instead of SSE
|
sse?.abort()
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
sse = ctrl
|
||||||
|
;(async () => {
|
||||||
|
while (true) {
|
||||||
|
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||||
|
const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
|
||||||
|
|
||||||
|
for await (const event of events.stream) {
|
||||||
|
if (ctrl.signal.aborted) break
|
||||||
|
handleEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
if (queue.length > 0) flush()
|
||||||
|
}
|
||||||
|
})().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
if (props.events) {
|
if (props.events) {
|
||||||
const unsub = props.events.on(handleEvent)
|
const unsub = props.events.on(handleEvent)
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
return
|
} else {
|
||||||
}
|
startSSE()
|
||||||
|
|
||||||
// Fall back to SSE
|
|
||||||
while (true) {
|
|
||||||
if (abort.signal.aborted) break
|
|
||||||
const events = await sdk.event.subscribe(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
signal: abort.signal,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
for await (const event of events.stream) {
|
|
||||||
handleEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush any remaining events
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
if (queue.length > 0) {
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
abort.abort()
|
abort.abort()
|
||||||
|
sse?.abort()
|
||||||
if (timer) clearTimeout(timer)
|
if (timer) clearTimeout(timer)
|
||||||
})
|
})
|
||||||
|
|
||||||
return { client: sdk, event: emitter, url: props.url }
|
return {
|
||||||
|
get client() {
|
||||||
|
return sdk
|
||||||
|
},
|
||||||
|
directory: props.directory,
|
||||||
|
event: emitter,
|
||||||
|
fetch: props.fetch ?? fetch,
|
||||||
|
setWorkspace(next?: string) {
|
||||||
|
if (workspaceID === next) return
|
||||||
|
workspaceID = next
|
||||||
|
sdk = createSDK()
|
||||||
|
props.events?.setWorkspace?.(next)
|
||||||
|
if (!props.events) startSSE()
|
||||||
|
},
|
||||||
|
url: props.url,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useArgs } from "./args"
|
|||||||
import { batch, onMount } from "solid-js"
|
import { batch, onMount } from "solid-js"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import type { Path } from "@opencode-ai/sdk"
|
import type { Path } from "@opencode-ai/sdk"
|
||||||
|
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
@@ -73,6 +74,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
formatter: FormatterStatus[]
|
formatter: FormatterStatus[]
|
||||||
vcs: VcsInfo | undefined
|
vcs: VcsInfo | undefined
|
||||||
path: Path
|
path: Path
|
||||||
|
workspaceList: Workspace[]
|
||||||
}>({
|
}>({
|
||||||
provider_next: {
|
provider_next: {
|
||||||
all: [],
|
all: [],
|
||||||
@@ -100,10 +102,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
formatter: [],
|
formatter: [],
|
||||||
vcs: undefined,
|
vcs: undefined,
|
||||||
path: { state: "", config: "", worktree: "", directory: "" },
|
path: { state: "", config: "", worktree: "", directory: "" },
|
||||||
|
workspaceList: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
|
async function syncWorkspaces() {
|
||||||
|
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||||
|
if (!result?.data) return
|
||||||
|
setStore("workspaceList", reconcile(result.data))
|
||||||
|
}
|
||||||
|
|
||||||
sdk.event.listen((e) => {
|
sdk.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -413,6 +422,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||||
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
|
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
|
||||||
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
|
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
|
||||||
|
syncWorkspaces(),
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
setStore("status", "complete")
|
setStore("status", "complete")
|
||||||
})
|
})
|
||||||
@@ -481,6 +491,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
fullSyncedSessions.add(sessionID)
|
fullSyncedSessions.add(sessionID)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
workspace: {
|
||||||
|
get(workspaceID: string) {
|
||||||
|
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
|
||||||
|
},
|
||||||
|
sync: syncWorkspaces,
|
||||||
|
},
|
||||||
bootstrap,
|
bootstrap,
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { SplitBorder } from "@tui/component/border"
|
|||||||
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
||||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||||
import { useKeybind } from "../../context/keybind"
|
import { useKeybind } from "../../context/keybind"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
|
|
||||||
const Title = (props: { session: Accessor<Session> }) => {
|
const Title = (props: { session: Accessor<Session> }) => {
|
||||||
@@ -29,6 +30,17 @@ const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Acces
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WorkspaceInfo = (props: { workspace: Accessor<string | undefined> }) => {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
return (
|
||||||
|
<Show when={props.workspace()}>
|
||||||
|
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||||
|
{props.workspace()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const route = useRouteData("session")
|
const route = useRouteData("session")
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -59,6 +71,14 @@ export function Header() {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const workspace = createMemo(() => {
|
||||||
|
const id = session()?.workspaceID
|
||||||
|
if (!id) return "Workspace local"
|
||||||
|
const info = sync.workspace.get(id)
|
||||||
|
if (!info) return `Workspace ${id}`
|
||||||
|
return `Workspace ${id} (${info.type})`
|
||||||
|
})
|
||||||
|
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const keybind = useKeybind()
|
const keybind = useKeybind()
|
||||||
const command = useCommandDialog()
|
const command = useCommandDialog()
|
||||||
@@ -83,9 +103,19 @@ export function Header() {
|
|||||||
<Match when={session()?.parentID}>
|
<Match when={session()?.parentID}>
|
||||||
<box flexDirection="column" gap={1}>
|
<box flexDirection="column" gap={1}>
|
||||||
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
|
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
|
||||||
<text fg={theme.text}>
|
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
|
||||||
<b>Subagent session</b>
|
<box flexDirection="column">
|
||||||
</text>
|
<text fg={theme.text}>
|
||||||
|
<b>Subagent session</b>
|
||||||
|
</text>
|
||||||
|
<WorkspaceInfo workspace={workspace} />
|
||||||
|
</box>
|
||||||
|
) : (
|
||||||
|
<text fg={theme.text}>
|
||||||
|
<b>Subagent session</b>
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
<ContextInfo context={context} cost={cost} />
|
<ContextInfo context={context} cost={cost} />
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={2}>
|
<box flexDirection="row" gap={2}>
|
||||||
@@ -124,7 +154,14 @@ export function Header() {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
|
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
|
||||||
<Title session={session} />
|
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
|
||||||
|
<box flexDirection="column">
|
||||||
|
<Title session={session} />
|
||||||
|
<WorkspaceInfo workspace={workspace} />
|
||||||
|
</box>
|
||||||
|
) : (
|
||||||
|
<Title session={session} />
|
||||||
|
)}
|
||||||
<ContextInfo context={context} cost={cost} />
|
<ContextInfo context={context} cost={cost} />
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -182,6 +182,12 @@ export function Session() {
|
|||||||
return new CustomSpeedScroll(3)
|
return new CustomSpeedScroll(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (session()?.workspaceID) {
|
||||||
|
sdk.setWorkspace(session()?.workspaceID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(async () => {
|
createEffect(async () => {
|
||||||
await sync.session
|
await sync.session
|
||||||
.sync(route.sessionID)
|
.sync(route.sessionID)
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
|
|||||||
function createEventSource(client: RpcClient): EventSource {
|
function createEventSource(client: RpcClient): EventSource {
|
||||||
return {
|
return {
|
||||||
on: (handler) => client.on<Event>("event", handler),
|
on: (handler) => client.on<Event>("event", handler),
|
||||||
|
setWorkspace: (workspaceID) => {
|
||||||
|
void client.call("setWorkspace", { workspaceID })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const eventStream = {
|
|||||||
abort: undefined as AbortController | undefined,
|
abort: undefined as AbortController | undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
const startEventStream = (directory: string) => {
|
const startEventStream = (input: { directory: string; workspaceID?: string }) => {
|
||||||
if (eventStream.abort) eventStream.abort.abort()
|
if (eventStream.abort) eventStream.abort.abort()
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
eventStream.abort = abort
|
eventStream.abort = abort
|
||||||
@@ -59,7 +59,8 @@ const startEventStream = (directory: string) => {
|
|||||||
|
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: "http://opencode.internal",
|
baseUrl: "http://opencode.internal",
|
||||||
directory,
|
directory: input.directory,
|
||||||
|
experimental_workspaceID: input.workspaceID,
|
||||||
fetch: fetchFn,
|
fetch: fetchFn,
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
@@ -95,7 +96,7 @@ const startEventStream = (directory: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
startEventStream(process.cwd())
|
startEventStream({ directory: process.cwd() })
|
||||||
|
|
||||||
export const rpc = {
|
export const rpc = {
|
||||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
||||||
@@ -135,6 +136,9 @@ export const rpc = {
|
|||||||
Config.global.reset()
|
Config.global.reset()
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
},
|
},
|
||||||
|
async setWorkspace(input: { workspaceID?: string }) {
|
||||||
|
startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })
|
||||||
|
},
|
||||||
async shutdown() {
|
async shutdown() {
|
||||||
Log.Default.info("worker shutting down")
|
Log.Default.info("worker shutting down")
|
||||||
if (eventStream.abort) eventStream.abort.abort()
|
if (eventStream.abort) eventStream.abort.abort()
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export namespace Flag {
|
|||||||
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
|
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
|
||||||
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
|
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
|
||||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||||
|
export const OPENCODE_EXPERIMENTAL_WORKSPACES_TUI =
|
||||||
|
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES_TUI")
|
||||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { type Config } from "./gen/client/types.gen.js"
|
|||||||
import { OpencodeClient } from "./gen/sdk.gen.js"
|
import { OpencodeClient } from "./gen/sdk.gen.js"
|
||||||
export { type Config as OpencodeClientConfig, OpencodeClient }
|
export { type Config as OpencodeClientConfig, OpencodeClient }
|
||||||
|
|
||||||
export function createOpencodeClient(config?: Config & { directory?: string }) {
|
export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) {
|
||||||
if (!config?.fetch) {
|
if (!config?.fetch) {
|
||||||
const customFetch: any = (req: any) => {
|
const customFetch: any = (req: any) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -27,6 +27,13 @@ export function createOpencodeClient(config?: Config & { directory?: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config?.experimental_workspaceID) {
|
||||||
|
config.headers = {
|
||||||
|
...config.headers,
|
||||||
|
"x-opencode-workspace": config.experimental_workspaceID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const client = createClient(config)
|
const client = createClient(config)
|
||||||
return new OpencodeClient({ client })
|
return new OpencodeClient({ client })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user