refactor(tui): remove legacy sdk surfaces
This commit is contained in:
parent
56a7c06a80
commit
2a7e32c416
54 changed files with 278 additions and 2324 deletions
|
|
@ -1,135 +0,0 @@
|
|||
import { createResource, createMemo, createSignal } from "solid-js"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { errorMessage } from "../util/error"
|
||||
import type { ExperimentalConsoleListOrgsResponse } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number]
|
||||
|
||||
const accountHost = (url: string) => {
|
||||
try {
|
||||
return new URL(url).host
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
const accountLabel = (item: Pick<OrgOption, "accountEmail" | "accountUrl">) =>
|
||||
`${item.accountEmail} ${accountHost(item.accountUrl)}`
|
||||
|
||||
export function DialogConsoleOrg() {
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
|
||||
const [orgs] = createResource(() =>
|
||||
sdk.client.experimental.console
|
||||
.listOrgs({}, { throwOnError: true })
|
||||
.then((result) => result.data?.orgs ?? [])
|
||||
// Catch so the rejected resource never reaches the memos below: reading
|
||||
// orgs() in an errored state re-throws and tears down the dialog.
|
||||
.catch((error) => {
|
||||
setLoadError(error)
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
|
||||
const showError = createMemo(() => Boolean(loadError()))
|
||||
|
||||
const current = createMemo(() => orgs()?.find((item) => item.active))
|
||||
|
||||
const options = createMemo(() => {
|
||||
if (showError()) return []
|
||||
const listed = orgs()
|
||||
if (listed === undefined) {
|
||||
return [
|
||||
{
|
||||
title: "Loading orgs...",
|
||||
value: "loading",
|
||||
onSelect: () => {},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (listed.length === 0) {
|
||||
return [
|
||||
{
|
||||
title: "No orgs found",
|
||||
value: "empty",
|
||||
onSelect: () => {},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return listed
|
||||
.toSorted((a, b) => {
|
||||
const activeAccountA = a.active ? 0 : 1
|
||||
const activeAccountB = b.active ? 0 : 1
|
||||
if (activeAccountA !== activeAccountB) return activeAccountA - activeAccountB
|
||||
|
||||
const accountCompare = accountLabel(a).localeCompare(accountLabel(b))
|
||||
if (accountCompare !== 0) return accountCompare
|
||||
|
||||
return a.orgName.localeCompare(b.orgName)
|
||||
})
|
||||
.map((item) => ({
|
||||
title: item.orgName,
|
||||
value: item,
|
||||
category: accountLabel(item),
|
||||
categoryView: (
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.accent}>{item.accountEmail}</text>
|
||||
<text fg={theme.textMuted}>{accountHost(item.accountUrl)}</text>
|
||||
</box>
|
||||
),
|
||||
onSelect: async () => {
|
||||
if (item.active) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
|
||||
await sdk.client.experimental.console.switchOrg(
|
||||
{
|
||||
accountID: item.accountID,
|
||||
orgID: item.orgID,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
|
||||
await sdk.client.instance.dispose()
|
||||
toast.show({
|
||||
message: `Switched to ${item.orgName}`,
|
||||
variant: "info",
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect<string | OrgOption>
|
||||
title="Switch org"
|
||||
options={options()}
|
||||
current={current()}
|
||||
renderFilter={!showError()}
|
||||
locked={showError()}
|
||||
emptyView={
|
||||
showError() ? (
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.error} attributes={TextAttributes.BOLD}>
|
||||
Could not load orgs
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
|
||||
</box>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { TextAttributes } from "@opentui/core"
|
||||
import type { IntegrationConnectOauthOutput } from "@opencode-ai/client/promise"
|
||||
import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2"
|
||||
import type {
|
||||
ConnectionInfo,
|
||||
IntegrationConnectOauthOutput,
|
||||
IntegrationInfo,
|
||||
IntegrationOAuthMethod,
|
||||
} from "@opencode-ai/client"
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useData } from "../context/data"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { DialogSelect } from "../ui/dialog-select"
|
|||
import { useDialog } from "../ui/dialog"
|
||||
import { useTheme, type Theme } from "../context/theme"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { McpServer } from "@opencode-ai/sdk/v2"
|
||||
import type { McpServer } from "@opencode-ai/client"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
|||
import { useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSync } from "../context/sync"
|
||||
import { useData } from "../context/data"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { Locale } from "../util/locale"
|
||||
|
|
@ -17,7 +17,7 @@ import { useCommandShortcut } from "../keymap"
|
|||
import { useProject } from "../context/project"
|
||||
import { Spinner } from "./spinner"
|
||||
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||
import type { ProjectDirectoriesOutput } from "@opencode-ai/client/promise"
|
||||
import type { ProjectDirectoriesOutput } from "@opencode-ai/client"
|
||||
import { useRoute } from "../context/route"
|
||||
import { DialogProjectCopyName } from "./dialog-project-copy-name"
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||
const sdk = useSDK()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const sync = useSync()
|
||||
const sessionData = useData()
|
||||
const projectContext = useProject()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
|
@ -132,9 +132,13 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||
})
|
||||
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
|
||||
|
||||
const subdirectories = sync.data.session
|
||||
.filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
|
||||
.map((session) => session.directory)
|
||||
const subdirectories = sessionData.session
|
||||
.list()
|
||||
.filter(
|
||||
(session) =>
|
||||
session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
|
||||
)
|
||||
.map((session) => session.location.directory)
|
||||
.filter((directory) => !roots.some((root) => root.directory === directory))
|
||||
.filter((directory, index, directories) => directories.indexOf(directory) === index)
|
||||
.map((location) => ({
|
||||
|
|
|
|||
|
|
@ -1,469 +0,0 @@
|
|||
import { createMemo, createSignal, onMount, Show } from "solid-js"
|
||||
import { useSync } from "../context/sync"
|
||||
import { map, pipe, sortBy } from "remeda"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { Link } from "../ui/link"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
|
||||
import { DialogModel } from "./dialog-model"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { isConsoleManagedProvider } from "../util/provider-origin"
|
||||
import { useConnected } from "./use-connected"
|
||||
import { useBindings } from "../keymap"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
|
||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
opencode: 0,
|
||||
"opencode-go": 1,
|
||||
openai: 2,
|
||||
"github-copilot": 3,
|
||||
anthropic: 4,
|
||||
google: 5,
|
||||
}
|
||||
|
||||
const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__"
|
||||
const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||
|
||||
type ProviderOptionBase = {
|
||||
title: string
|
||||
value: string
|
||||
description?: string
|
||||
category: string
|
||||
}
|
||||
|
||||
type ProviderOption =
|
||||
| (ProviderOptionBase & {
|
||||
type: "provider"
|
||||
providerID: string
|
||||
})
|
||||
| (ProviderOptionBase & {
|
||||
type: "custom"
|
||||
})
|
||||
|
||||
export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] {
|
||||
return [
|
||||
...pipe(
|
||||
list,
|
||||
sortBy(
|
||||
(x) => PROVIDER_PRIORITY[x.id] ?? 99,
|
||||
(x) => x.name.toLowerCase(),
|
||||
(x) => x.id,
|
||||
),
|
||||
map((provider) => ({
|
||||
type: "provider" as const,
|
||||
title: provider.name,
|
||||
value: provider.id,
|
||||
providerID: provider.id,
|
||||
description: {
|
||||
opencode: "(Recommended)",
|
||||
anthropic: "(API key)",
|
||||
openai: "(ChatGPT Plus/Pro or API key)",
|
||||
"opencode-go": "Low cost subscription for everyone",
|
||||
}[provider.id],
|
||||
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers",
|
||||
})),
|
||||
),
|
||||
{
|
||||
type: "custom",
|
||||
title: "Other",
|
||||
value: CUSTOM_PROVIDER_OPTION_VALUE,
|
||||
description: "Custom provider",
|
||||
category: "Providers",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function normalizeCustomProviderID(value: string) {
|
||||
const providerID = value.trim().replace(/^@ai-sdk\//, "")
|
||||
if (!CUSTOM_PROVIDER_ID.test(providerID)) return
|
||||
return providerID
|
||||
}
|
||||
|
||||
export function createDialogProviderOptions() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
const onboarded = useConnected()
|
||||
|
||||
async function promptCustomProviderID(): Promise<string | undefined> {
|
||||
const value = await DialogPrompt.show(dialog, "Other", {
|
||||
placeholder: "Provider id",
|
||||
description: () => (
|
||||
<text fg={theme.textMuted}>
|
||||
This only stores a credential. Configure the provider in opencode.json to use it.
|
||||
</text>
|
||||
),
|
||||
})
|
||||
if (value === null) return
|
||||
|
||||
const providerID = normalizeCustomProviderID(value)
|
||||
if (providerID) return providerID
|
||||
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message:
|
||||
"Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores",
|
||||
})
|
||||
return promptCustomProviderID()
|
||||
}
|
||||
|
||||
const options = createMemo(() => {
|
||||
return pipe(
|
||||
providerOptions(sync.data.provider_next.all),
|
||||
map((provider) => {
|
||||
if (provider.type === "custom") {
|
||||
return {
|
||||
title: provider.title,
|
||||
value: provider.value,
|
||||
description: provider.description,
|
||||
category: provider.category,
|
||||
async onSelect() {
|
||||
const providerID = await promptCustomProviderID()
|
||||
if (!providerID) return
|
||||
return dialog.replace(() => <ApiMethod providerID={providerID} title="API key" custom />)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const providerID = provider.providerID
|
||||
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID)
|
||||
const connected = sync.data.provider_next.connected.includes(providerID)
|
||||
|
||||
return {
|
||||
title: provider.title,
|
||||
value: provider.value,
|
||||
description: provider.description,
|
||||
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
|
||||
category: provider.category,
|
||||
gutter: connected && onboarded() ? () => <text fg={theme.success}>✓</text> : undefined,
|
||||
async onSelect() {
|
||||
if (consoleManaged) return
|
||||
|
||||
const methods = sync.data.provider_auth[providerID] ?? [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
},
|
||||
]
|
||||
let index: number | null = 0
|
||||
if (methods.length > 1) {
|
||||
index = await new Promise<number | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title="Select auth method"
|
||||
options={methods.map((x, index) => ({
|
||||
title: x.label,
|
||||
value: index,
|
||||
}))}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
if (index == null) return
|
||||
const method = methods[index]
|
||||
if (method.type === "oauth") {
|
||||
let inputs: Record<string, string> | undefined
|
||||
if (method.prompts?.length) {
|
||||
const value = await PromptsMethod({
|
||||
dialog,
|
||||
prompts: method.prompts,
|
||||
})
|
||||
if (!value) return
|
||||
inputs = value
|
||||
}
|
||||
|
||||
const result = await sdk.client.provider.oauth.authorize({
|
||||
providerID,
|
||||
method: index,
|
||||
inputs,
|
||||
})
|
||||
if (result.error) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message: JSON.stringify(result.error),
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
if (result.data?.method === "code") {
|
||||
dialog.replace(() => (
|
||||
<CodeMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
|
||||
))
|
||||
}
|
||||
if (result.data?.method === "auto") {
|
||||
dialog.replace(() => (
|
||||
<AutoMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
|
||||
))
|
||||
}
|
||||
}
|
||||
if (method.type === "api") {
|
||||
let metadata: Record<string, string> | undefined
|
||||
if (method.prompts?.length) {
|
||||
const value = await PromptsMethod({ dialog, prompts: method.prompts })
|
||||
if (!value) return
|
||||
metadata = value
|
||||
}
|
||||
return dialog.replace(() => (
|
||||
<ApiMethod providerID={providerID} title={method.label} metadata={metadata} />
|
||||
))
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
export function DialogProvider() {
|
||||
const options = createDialogProviderOptions()
|
||||
return <DialogSelect title="Connect a provider" options={options()} />
|
||||
}
|
||||
|
||||
interface AutoMethodProps {
|
||||
index: number
|
||||
providerID: string
|
||||
title: string
|
||||
authorization: ProviderAuthAuthorization
|
||||
}
|
||||
function AutoMethod(props: AutoMethodProps) {
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const clipboard = useClipboard()
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "c",
|
||||
desc: "Copy provider code",
|
||||
group: "Dialog",
|
||||
cmd: () => {
|
||||
const code =
|
||||
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
|
||||
clipboard
|
||||
.write?.(code)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
onMount(async () => {
|
||||
const result = await sdk.client.provider.oauth.callback({
|
||||
providerID: props.providerID,
|
||||
method: props.index,
|
||||
})
|
||||
if (result.error) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message:
|
||||
"name" in result.error && result.error.name === "ProviderAuthOauthCallbackFailed"
|
||||
? "OAuth authorization failed. Try /connect again."
|
||||
: JSON.stringify(result.error),
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
dialog.replace(() => <DialogModel providerID={props.providerID} />)
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={1}>
|
||||
<Link href={props.authorization.url} fg={theme.primary} />
|
||||
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>Waiting for authorization...</text>
|
||||
<text fg={theme.text}>
|
||||
c <span style={{ fg: theme.textMuted }}>copy</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
interface CodeMethodProps {
|
||||
index: number
|
||||
title: string
|
||||
providerID: string
|
||||
authorization: ProviderAuthAuthorization
|
||||
}
|
||||
function CodeMethod(props: CodeMethodProps) {
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const [error, setError] = createSignal(false)
|
||||
|
||||
return (
|
||||
<DialogPrompt
|
||||
title={props.title}
|
||||
placeholder="Authorization code"
|
||||
onConfirm={async (value) => {
|
||||
const { error } = await sdk.client.provider.oauth.callback({
|
||||
providerID: props.providerID,
|
||||
method: props.index,
|
||||
code: value,
|
||||
})
|
||||
if (!error) {
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
dialog.replace(() => <DialogModel providerID={props.providerID} />)
|
||||
return
|
||||
}
|
||||
setError(true)
|
||||
}}
|
||||
description={() => (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
|
||||
<Link href={props.authorization.url} fg={theme.primary} />
|
||||
<Show when={error()}>
|
||||
<text fg={theme.error}>Invalid code</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ApiMethodProps {
|
||||
providerID: string
|
||||
title: string
|
||||
metadata?: Record<string, string>
|
||||
custom?: boolean
|
||||
}
|
||||
function ApiMethod(props: ApiMethodProps) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<DialogPrompt
|
||||
title={props.title}
|
||||
placeholder="API key"
|
||||
description={() =>
|
||||
({
|
||||
opencode: (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API
|
||||
key.
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> to get a key
|
||||
</text>
|
||||
</box>
|
||||
),
|
||||
"opencode-go": (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models
|
||||
with generous usage limits.
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/go</span> and enable OpenCode Go
|
||||
</text>
|
||||
</box>
|
||||
),
|
||||
})[props.providerID] ?? undefined
|
||||
}
|
||||
onConfirm={async (value) => {
|
||||
if (!value) return
|
||||
await sdk.client.auth.set({
|
||||
providerID: props.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: value,
|
||||
...(props.metadata ? { metadata: props.metadata } : {}),
|
||||
},
|
||||
})
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: `Saved credential for ${props.providerID}. Configure it in opencode.json to use it.`,
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
dialog.replace(() => <DialogModel providerID={props.providerID} />)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface PromptsMethodProps {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
prompts: NonNullable<ProviderAuthMethod["prompts"]>[number][]
|
||||
}
|
||||
async function PromptsMethod(props: PromptsMethodProps) {
|
||||
const inputs: Record<string, string> = {}
|
||||
for (const prompt of props.prompts) {
|
||||
if (prompt.when) {
|
||||
const value = inputs[prompt.when.key]
|
||||
if (value === undefined) continue
|
||||
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
|
||||
if (!matches) continue
|
||||
}
|
||||
|
||||
if (prompt.type === "select") {
|
||||
const value = await new Promise<string | null>((resolve) => {
|
||||
props.dialog.replace(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title={prompt.message}
|
||||
options={prompt.options.map((x) => ({
|
||||
title: x.label,
|
||||
value: x.value,
|
||||
description: x.hint,
|
||||
}))}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
if (value === null) return null
|
||||
inputs[prompt.key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
const value = await new Promise<string | null>((resolve) => {
|
||||
props.dialog.replace(
|
||||
() => (
|
||||
<DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={(value) => resolve(value)} />
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
if (value === null) return null
|
||||
inputs[prompt.key] = value
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import path from "path"
|
||||
import type { SessionInfo } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useDialog } from "../ui/dialog"
|
|||
import { useTheme } from "../context/theme"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useData } from "../context/data"
|
||||
import type { LocationRef } from "@opencode-ai/sdk/v2"
|
||||
import type { LocationRef } from "@opencode-ai/client"
|
||||
|
||||
export type DialogSkillProps = {
|
||||
location?: LocationRef
|
||||
|
|
|
|||
|
|
@ -1,308 +0,0 @@
|
|||
import type { ExperimentalWorkspaceAdapterListResponse, Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useSync } from "../context/sync"
|
||||
import { useProject } from "../context/project"
|
||||
import { useRoute } from "../context/route"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||
|
||||
type Adapter = ExperimentalWorkspaceAdapterListResponse[number]
|
||||
|
||||
export type WorkspaceSelection =
|
||||
| {
|
||||
type: "none"
|
||||
}
|
||||
| {
|
||||
type: "new"
|
||||
workspaceType: string
|
||||
workspaceName: string
|
||||
}
|
||||
| {
|
||||
type: "existing"
|
||||
workspaceID: string
|
||||
workspaceType: string
|
||||
workspaceName: string
|
||||
}
|
||||
|
||||
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
|
||||
type ExistingWorkspaceSelectValue = { workspace: Workspace }
|
||||
|
||||
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string; timeUsed: number | string }>(input: {
|
||||
workspaces: readonly WorkspaceInfo[]
|
||||
status: (workspaceID: string) => string | undefined
|
||||
limit?: number
|
||||
omitWorkspaceID?: string
|
||||
}) {
|
||||
const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected")
|
||||
const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed))
|
||||
const recent = workspaces.slice(0, input.limit ?? 3)
|
||||
|
||||
return { recent, hasMore: recent.length < workspaces.length }
|
||||
}
|
||||
|
||||
export function warpReminderText(dir: string) {
|
||||
return `<system-reminder>The user has changed the current working directory to "${dir}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
|
||||
}
|
||||
|
||||
async function loadWorkspaceAdapters(input: {
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
}) {
|
||||
const dir = input.sync.path.directory || process.cwd()
|
||||
try {
|
||||
const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir })
|
||||
if (response.error) throw response.error
|
||||
return response.data
|
||||
} catch (err) {
|
||||
input.toast.show({
|
||||
title: "Failed to load workspace adapters",
|
||||
message: errorMessage(err),
|
||||
variant: "error",
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function openWorkspaceSelect(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
project: ReturnType<typeof useProject>
|
||||
toast: ReturnType<typeof useToast>
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
input.dialog.clear()
|
||||
await input.sdk.client.experimental.workspace.syncList().catch(() => undefined)
|
||||
await input.project.workspace.sync().catch(() => undefined)
|
||||
const adapters = await loadWorkspaceAdapters(input)
|
||||
if (!adapters) return
|
||||
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
|
||||
}
|
||||
|
||||
export async function warpWorkspaceSession(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
project: ReturnType<typeof useProject>
|
||||
toast: ReturnType<typeof useToast>
|
||||
sourceWorkspaceID?: string
|
||||
workspaceID: string | null
|
||||
sessionID: string
|
||||
copyChanges: boolean
|
||||
done?: () => void
|
||||
}): Promise<boolean> {
|
||||
let result
|
||||
try {
|
||||
result = await input.sdk.client.experimental.workspace.warp({
|
||||
id: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
copyChanges: input.copyChanges,
|
||||
})
|
||||
} catch (err) {
|
||||
input.toast.show({
|
||||
title: "Failed to warp session",
|
||||
message: errorMessage(err),
|
||||
variant: "error",
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!result?.data) {
|
||||
if (result?.error && "name" in result.error && result.error.name === "VcsApplyError") {
|
||||
await DialogAlert.show(
|
||||
input.dialog,
|
||||
"Unable to Warp Session",
|
||||
"Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.",
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
input.toast.show({
|
||||
title: "Failed to warp session",
|
||||
message: errorMessage(result?.error ?? "no response"),
|
||||
variant: "error",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
input.project.workspace.set(input.workspaceID)
|
||||
|
||||
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
|
||||
const dir = input.project.instance.directory() || input.sync.path.directory
|
||||
if (dir) {
|
||||
await input.sdk.client.session
|
||||
.promptAsync({
|
||||
sessionID: input.sessionID,
|
||||
workspace: input.workspaceID ?? undefined,
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: warpReminderText(dir),
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
|
||||
|
||||
if (input.done) {
|
||||
input.done()
|
||||
return true
|
||||
}
|
||||
input.dialog.clear()
|
||||
return true
|
||||
}
|
||||
|
||||
export async function confirmWorkspaceFileChanges(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sourceWorkspaceID?: string
|
||||
}) {
|
||||
const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined)
|
||||
const fileChangeChoice = status?.data?.length
|
||||
? await DialogWorkspaceFileChanges.show(input.dialog, status.data)
|
||||
: "no"
|
||||
if (!fileChangeChoice) return
|
||||
return fileChangeChoice === "yes"
|
||||
}
|
||||
|
||||
export function DialogWorkspaceSelect(props: {
|
||||
adapters?: Adapter[]
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const project = useProject()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
|
||||
const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined))
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
void (async () => {
|
||||
if (adapters()) return
|
||||
const res = await loadWorkspaceAdapters({ sdk, sync, toast })
|
||||
if (!res) return
|
||||
setAdapters(res)
|
||||
})()
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
|
||||
const list = adapters()
|
||||
if (!list) return []
|
||||
const { recent, hasMore } = recentConnectedWorkspaces({
|
||||
workspaces: project.workspace.list(),
|
||||
status: project.workspace.status,
|
||||
omitWorkspaceID: omittedWorkspaceID(),
|
||||
})
|
||||
return [
|
||||
...list.map((adapter) => ({
|
||||
title: adapter.name,
|
||||
value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name },
|
||||
description: adapter.description,
|
||||
category: "New workspace",
|
||||
})),
|
||||
{
|
||||
title: "None",
|
||||
value: { type: "none" as const },
|
||||
description: "Use the local project",
|
||||
category: "Choose workspace",
|
||||
},
|
||||
...recent.map((workspace: Workspace) => ({
|
||||
title: workspace.name,
|
||||
description: `(${workspace.type})`,
|
||||
value: {
|
||||
type: "existing" as const,
|
||||
workspaceID: workspace.id,
|
||||
workspaceType: workspace.type,
|
||||
workspaceName: workspace.name,
|
||||
},
|
||||
category: "Choose workspace",
|
||||
})),
|
||||
...(hasMore
|
||||
? [
|
||||
{
|
||||
title: "View all workspaces",
|
||||
value: { type: "existing-list" as const },
|
||||
description: "Choose from all workspaces",
|
||||
category: "Choose workspace",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
})
|
||||
|
||||
if (!adapters()) return null
|
||||
return (
|
||||
<DialogSelect<WorkspaceSelectValue>
|
||||
title="Warp"
|
||||
skipFilter={true}
|
||||
renderFilter={false}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (!option.value) return
|
||||
if (option.value.type === "none") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
if (option.value.type === "new") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
if (option.value.type === "existing") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.replace(() => (
|
||||
<DialogExistingWorkspaceSelect omitWorkspaceID={omittedWorkspaceID()} onSelect={props.onSelect} />
|
||||
))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogExistingWorkspaceSelect(props: {
|
||||
omitWorkspaceID?: string
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
const project = useProject()
|
||||
|
||||
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
|
||||
project.workspace
|
||||
.list()
|
||||
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
|
||||
.filter((workspace) => workspace.id !== props.omitWorkspaceID)
|
||||
.map((workspace: Workspace) => ({
|
||||
title: workspace.name,
|
||||
description: `(${workspace.type})`,
|
||||
value: { workspace },
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect<ExistingWorkspaceSelectValue>
|
||||
title="Existing Workspace"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
void props.onSelect({
|
||||
type: "existing",
|
||||
workspaceID: option.value.workspace.id,
|
||||
workspaceType: option.value.workspace.type,
|
||||
workspaceName: option.value.workspace.name,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import type { VcsFileStatus } from "@opencode-ai/sdk/v2"
|
||||
import type { VcsFileStatus } from "@opencode-ai/client"
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FilePath } from "../ui/file-path"
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useProject } from "../context/project"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useSync } from "../context/sync"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type WorkspaceOption = { workspace: Workspace }
|
||||
|
||||
export function DialogWorkspaceList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const project = useProject()
|
||||
const { theme } = useTheme()
|
||||
const [deleting, setDeleting] = createSignal<string>()
|
||||
const [removing, setRemoving] = createSignal<string>()
|
||||
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
|
||||
|
||||
const current = createMemo(() => {
|
||||
if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID
|
||||
return project.workspace.current()
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<WorkspaceOption>[]>(() =>
|
||||
project.workspace
|
||||
.list()
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((workspace) => {
|
||||
const status = project.workspace.status(workspace.id)
|
||||
return {
|
||||
title:
|
||||
removing() === workspace.id
|
||||
? "Deleting..."
|
||||
: deleting() === workspace.id
|
||||
? `Delete ${workspace.name}? Press delete again`
|
||||
: workspace.name,
|
||||
value: { workspace },
|
||||
footer: workspace.type,
|
||||
details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined,
|
||||
gutter: () => <text fg={status === "connected" ? theme.success : theme.error}>●</text>,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
function showDetails(workspace: Workspace) {
|
||||
setExpanded(workspace.id, (open) => !open)
|
||||
}
|
||||
|
||||
async function remove(workspace: Workspace) {
|
||||
if (removing()) return
|
||||
if (deleting() !== workspace.id) {
|
||||
setDeleting(workspace.id)
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting(undefined)
|
||||
setRemoving(workspace.id)
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({
|
||||
error: err,
|
||||
}))
|
||||
if (result?.error) {
|
||||
setRemoving(undefined)
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete workspace",
|
||||
message: errorMessage(result.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (current() === workspace.id) {
|
||||
project.workspace.set(undefined)
|
||||
route.navigate({ type: "home" })
|
||||
}
|
||||
await project.workspace.sync()
|
||||
await sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
setRemoving(undefined)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
void sdk.client.experimental.workspace.syncList().catch(() => undefined)
|
||||
void project.workspace.sync()
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Workspaces"
|
||||
options={options()}
|
||||
onMove={(option) => {
|
||||
setDeleting(undefined)
|
||||
}}
|
||||
onSelect={(option) => showDetails(option.value.workspace)}
|
||||
actions={[
|
||||
{
|
||||
command: "session.delete",
|
||||
title: "delete",
|
||||
onTrigger: (option) => void remove(option.value.workspace),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import { TextAttributes } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const [store, setStore] = createStore({
|
||||
active: "restore" as "cancel" | "restore",
|
||||
})
|
||||
|
||||
const options = ["cancel", "restore"] as const
|
||||
|
||||
async function confirm() {
|
||||
if (store.active === "cancel") {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const result = await props.onRestore?.()
|
||||
if (result === false) return
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{ key: "return", desc: "Confirm workspace option", group: "Dialog", cmd: () => void confirm() },
|
||||
{ key: "left", desc: "Cancel workspace restore", group: "Dialog", cmd: () => setStore("active", "cancel") },
|
||||
{ key: "right", desc: "Restore workspace", group: "Dialog", cmd: () => setStore("active", "restore") },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Workspace Unavailable
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
This session is attached to a workspace that is no longer available.
|
||||
</text>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Would you like to restore this session into a new workspace?
|
||||
</text>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
|
||||
<For each={options}>
|
||||
{(item) => (
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={item === store.active ? theme.primary : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item)
|
||||
void confirm()
|
||||
}}
|
||||
>
|
||||
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ import type { PromptInfo, PromptPartRef } from "../../prompt/history"
|
|||
import { useFrecency } from "../../prompt/frecency"
|
||||
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
|
||||
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
||||
import type { FileSystemEntry } from "@opencode-ai/sdk/v2"
|
||||
import type { FileSystemEntry } from "@opencode-ai/client"
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
const hashIndex = input.lastIndexOf("#")
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ import { usePromptStash } from "../../prompt/stash"
|
|||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { createColors, createFrames } from "../../ui/spinner"
|
||||
|
|
@ -48,11 +47,9 @@ import { useToast } from "../../ui/toast"
|
|||
import { useKV } from "../../context/kv"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { usePromptWorkspace } from "./workspace"
|
||||
import { usePromptMove } from "./move"
|
||||
import { readLocalAttachment } from "./local-attachment"
|
||||
import { useData } from "../../context/data"
|
||||
|
|
@ -219,7 +216,6 @@ export function Prompt(props: PromptProps) {
|
|||
})
|
||||
const editorContextLabelState = createMemo(() => editor.labelState())
|
||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||
const workspace = usePromptWorkspace(props.sessionID)
|
||||
const move = usePromptMove({
|
||||
projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(),
|
||||
sessionID: () => props.sessionID,
|
||||
|
|
@ -269,13 +265,6 @@ export function Prompt(props: PromptProps) {
|
|||
if (!props.disabled) input.cursorColor = theme.text
|
||||
})
|
||||
|
||||
const lastUserMessage = createMemo(() => {
|
||||
if (!props.sessionID) return undefined
|
||||
const messages = sync.data.message[props.sessionID]
|
||||
if (!messages) return undefined
|
||||
return messages.findLast((m): m is UserMessage => m.role === "user")
|
||||
})
|
||||
|
||||
const usage = createMemo(() => {
|
||||
if (!props.sessionID) return
|
||||
const session = data.session.get(props.sessionID)
|
||||
|
|
@ -529,17 +518,6 @@ export function Prompt(props: PromptProps) {
|
|||
))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Warp",
|
||||
desc: "Change the workspace for the session",
|
||||
name: "workspace.set",
|
||||
category: "Session",
|
||||
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
slashName: "warp",
|
||||
run: () => {
|
||||
workspace.open()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Move session",
|
||||
desc: "Move to another project dir",
|
||||
|
|
@ -572,7 +550,6 @@ export function Prompt(props: PromptProps) {
|
|||
"prompt.skills",
|
||||
"session.interrupt",
|
||||
"session.background",
|
||||
"workspace.set",
|
||||
"session.move",
|
||||
]),
|
||||
}))
|
||||
|
|
@ -957,8 +934,6 @@ export function Prompt(props: PromptProps) {
|
|||
}
|
||||
|
||||
async function submitInner() {
|
||||
workspace.clearNotice()
|
||||
|
||||
// IME: double-defer may fire before onContentChange flushes the last
|
||||
// composed character (e.g. Korean hangul) to the store, so read
|
||||
// plainText directly and sync before any downstream reads.
|
||||
|
|
@ -967,7 +942,7 @@ export function Prompt(props: PromptProps) {
|
|||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (props.disabled) return false
|
||||
if (workspace.creating() || move.creating()) return false
|
||||
if (move.creating()) return false
|
||||
if (auto()?.visible) return false
|
||||
if (!store.prompt.text) return false
|
||||
const trimmed = store.prompt.text.trim()
|
||||
|
|
@ -983,29 +958,11 @@ export function Prompt(props: PromptProps) {
|
|||
return false
|
||||
}
|
||||
|
||||
const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
|
||||
const workspaceID = workspaceSession?.workspaceID
|
||||
const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
|
||||
if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceUnavailable
|
||||
onRestore={() => {
|
||||
workspace.open()
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
))
|
||||
return false
|
||||
}
|
||||
|
||||
const variant = local.model.variant.current()
|
||||
let sessionID = props.sessionID
|
||||
let session = sessionID ? data.session.get(sessionID) : undefined
|
||||
let finishMoveProgress = false
|
||||
if (sessionID == null) {
|
||||
const selectedWorkspace = workspace.selection()
|
||||
const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined
|
||||
|
||||
const directory = await move.getDirectory()
|
||||
if (move.pending() && !directory) return false
|
||||
finishMoveProgress = Boolean(move.progress())
|
||||
|
|
@ -1013,9 +970,7 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
const created = await sdk.api.session
|
||||
.create({
|
||||
location: directory
|
||||
? { directory, workspaceID }
|
||||
: { directory: location.directory, workspaceID: workspaceID ?? location.workspaceID },
|
||||
location: directory ? { directory } : location,
|
||||
agent: agent.id,
|
||||
model: {
|
||||
providerID: selectedModel.providerID,
|
||||
|
|
@ -1348,7 +1303,7 @@ export function Prompt(props: PromptProps) {
|
|||
const spinnerDef = createMemo(() => {
|
||||
const agent =
|
||||
status() === "running"
|
||||
? (local.agent.list().find((agent) => agent.id === lastUserMessage()?.agent) ?? local.agent.current())
|
||||
? local.agent.current()
|
||||
: local.agent.current()
|
||||
const color = agent ? local.agent.color(agent.id) : theme.border
|
||||
return {
|
||||
|
|
@ -1554,41 +1509,6 @@ export function Prompt(props: PromptProps) {
|
|||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={workspace.notice()}>
|
||||
{(notice) => (
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.accent}>{notice()}</text>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={workspace.label()}>
|
||||
{(label) => (
|
||||
<box paddingLeft={3} flexDirection="row" gap={1}>
|
||||
<Show when={workspace.creating()}>
|
||||
<Spinner color={theme.accent} />
|
||||
</Show>
|
||||
<text fg={workspace.creating() ? theme.accent : theme.text}>
|
||||
{(() => {
|
||||
const item = label()
|
||||
if (item.type === "new") {
|
||||
if (workspace.creating())
|
||||
return `Creating ${item.workspaceType}${".".repeat(workspace.creatingDots())}`
|
||||
return (
|
||||
<>
|
||||
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.progress()}>
|
||||
{(progress) => (
|
||||
<box paddingLeft={3}>
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import {
|
||||
confirmWorkspaceFileChanges,
|
||||
openWorkspaceSelect,
|
||||
warpWorkspaceSession,
|
||||
type WorkspaceSelection,
|
||||
} from "../dialog-workspace-create"
|
||||
import type { WorkspaceStatus } from "../workspace-label"
|
||||
|
||||
export function usePromptWorkspace(sessionID?: string) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const [selection, setSelection] = createSignal<WorkspaceSelection>()
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||
const [notice, setNotice] = createSignal<string>()
|
||||
|
||||
async function create(selection: Extract<WorkspaceSelection, { type: "new" }>) {
|
||||
setCreating(true)
|
||||
let result
|
||||
try {
|
||||
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
|
||||
} catch (err) {
|
||||
setSelection(undefined)
|
||||
setCreating(false)
|
||||
toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
|
||||
return
|
||||
}
|
||||
if (result.error || !result.data) {
|
||||
setSelection(undefined)
|
||||
setCreating(false)
|
||||
toast.show({
|
||||
title: "Creating workspace failed",
|
||||
message: errorMessage(result.error ?? "no response"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await project.workspace.sync()
|
||||
const workspace = result.data
|
||||
setSelection({
|
||||
type: "existing",
|
||||
workspaceID: workspace.id,
|
||||
workspaceType: workspace.type,
|
||||
workspaceName: workspace.name,
|
||||
})
|
||||
setCreating(false)
|
||||
return workspace
|
||||
}
|
||||
|
||||
async function warp(selection: WorkspaceSelection) {
|
||||
if (!sessionID) {
|
||||
setSelection(selection)
|
||||
dialog.clear()
|
||||
if (selection.type === "new") void create(selection)
|
||||
return
|
||||
}
|
||||
const sourceWorkspaceID = project.workspace.current()
|
||||
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
|
||||
if (copyChanges === undefined) return
|
||||
setSelection(selection)
|
||||
dialog.clear()
|
||||
|
||||
const workspace =
|
||||
selection.type === "none"
|
||||
? { id: null, name: "local project" }
|
||||
: selection.type === "existing"
|
||||
? { id: selection.workspaceID, name: selection.workspaceName }
|
||||
: await create(selection)
|
||||
if (!workspace) return
|
||||
|
||||
const warped = await warpWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
sourceWorkspaceID,
|
||||
workspaceID: workspace.id,
|
||||
sessionID,
|
||||
copyChanges,
|
||||
})
|
||||
if (warped) showNotice(workspace.name)
|
||||
}
|
||||
|
||||
function showNotice(name: string) {
|
||||
setNotice(`Warped to ${name}`)
|
||||
setTimeout(() => setNotice(undefined), 4000)
|
||||
}
|
||||
|
||||
function clearNotice() {
|
||||
setNotice(undefined)
|
||||
}
|
||||
|
||||
function open() {
|
||||
void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp })
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!creating()) {
|
||||
setCreatingDots(3)
|
||||
return
|
||||
}
|
||||
const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
const label = createMemo<
|
||||
| { type: "new"; workspaceType: string }
|
||||
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
|
||||
| undefined
|
||||
>(() => {
|
||||
const selected = selection()
|
||||
if (!selected) return
|
||||
if (selected.type === "none") return
|
||||
if (sessionID && !creating()) return
|
||||
if (selected.type === "new") return { type: "new", workspaceType: selected.workspaceType }
|
||||
return {
|
||||
type: "existing",
|
||||
workspaceType: selected.workspaceType,
|
||||
workspaceName: selected.workspaceName,
|
||||
status: selected.type === "existing" ? "connected" : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
return { selection, creating, creatingDots, notice, label, open, warp, clearNotice }
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { useTheme } from "../context/theme"
|
||||
|
||||
export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
|
||||
export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
|
||||
const { theme } = useTheme()
|
||||
const color = () => {
|
||||
if (props.status === "connected") return theme.success
|
||||
if (props.status === "error") return theme.error
|
||||
return theme.textMuted
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.icon ? <span style={{ fg: color() }}>● </span> : undefined}
|
||||
<span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue