diff options
| author | Dax <[email protected]> | 2025-11-21 00:21:06 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-11-21 00:21:06 -0500 |
| commit | 23ea8ba1ceb35358c62ba1051ba402223d0fb5b3 (patch) | |
| tree | 675096b0f5d54e5ff766b20191967a8d3f491a86 | |
| parent | c417fec2464079a6e0bba3450d5e41c5394282bc (diff) | |
| download | opencode-23ea8ba1ceb35358c62ba1051ba402223d0fb5b3.tar.gz opencode-23ea8ba1ceb35358c62ba1051ba402223d0fb5b3.zip | |
Tui onboarding (#4569)
Co-authored-by: GitHub Action <[email protected]>
23 files changed, 1253 insertions, 277 deletions
diff --git a/flake.lock b/flake.lock index cdab71ce7..1150e2751 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1763464769, - "narHash": "sha256-AJHrsT7VoeQzErpBRlLJM1SODcaayp0joAoEA35yiwM=", + "lastModified": 1763618868, + "narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "6f374686605df381de8541c072038472a5ea2e2d", + "rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942", "type": "github" }, "original": { diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4f990e76e..7bb10de89 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -2,10 +2,11 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu import { Clipboard } from "@tui/util/clipboard" import { TextAttributes } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" -import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch } from "solid-js" +import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show } from "solid-js" import { Installation } from "@/installation" import { Global } from "@/global" import { DialogProvider, useDialog } from "@tui/ui/dialog" +import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { SDKProvider, useSDK } from "@tui/context/sdk" import { SyncProvider, useSync } from "@tui/context/sync" import { LocalProvider, useLocal } from "@tui/context/local" @@ -294,6 +295,14 @@ function App() { category: "System", }, { + title: "Connect provider", + value: "provider.connect", + onSelect: () => { + dialog.replace(() => <DialogProviderList />) + }, + category: "System", + }, + { title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`, value: "theme.switch_mode", onSelect: () => { @@ -451,16 +460,18 @@ function App() { <text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text> </box> </box> - <box flexDirection="row" flexShrink={0}> - <text fg={theme.textMuted} paddingRight={1}> - tab - </text> - <text fg={local.agent.color(local.agent.current().name)}>{""}</text> - <text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}> - <span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span> - <span> AGENT </span> - </text> - </box> + <Show when={false}> + <box flexDirection="row" flexShrink={0}> + <text fg={theme.textMuted} paddingRight={1}> + tab + </text> + <text fg={local.agent.color(local.agent.current().name)}>{""}</text> + <text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}> + <span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span> + <span> AGENT </span> + </text> + </box> + </Show> </box> </box> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/border.tsx b/packages/opencode/src/cli/cmd/tui/component/border.tsx index 9cbb96068..333071020 100644 --- a/packages/opencode/src/cli/cmd/tui/component/border.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/border.tsx @@ -1,16 +1,21 @@ +export const EmptyBorder = { + topLeft: "", + bottomLeft: "", + vertical: "", + topRight: "", + bottomRight: "", + horizontal: " ", + bottomT: "", + topT: "", + cross: "", + leftT: "", + rightT: "", +} + export const SplitBorder = { border: ["left" as const, "right" as const], customBorderChars: { - topLeft: "", - bottomLeft: "", + ...EmptyBorder, vertical: "┃", - topRight: "", - bottomRight: "", - horizontal: "", - bottomT: "", - topT: "", - cross: "", - leftT: "", - rightT: "", }, } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index bcd1d98d5..35e885243 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -5,10 +5,20 @@ import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy } from "remeda import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { useTheme } from "../context/theme" +import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" function Free() { const { theme } = useTheme() - return <span style={{ fg: theme.secondary }}>Free</span> + return <span style={{ fg: theme.text }}>Free</span> +} +const PROVIDER_PRIORITY: Record<string, number> = { + opencode: 0, + anthropic: 1, + "github-copilot": 2, + openai: 3, + google: 4, + openrouter: 5, + vercel: 6, } export function DialogModel() { @@ -17,9 +27,16 @@ export function DialogModel() { const dialog = useDialog() const [ref, setRef] = createSignal<DialogSelectRef<unknown>>() + const connected = createMemo(() => + sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), + ) + + const showRecent = createMemo(() => !ref()?.filter && local.model.recent().length > 0 && connected()) + const providers = createDialogProviderOptions() + const options = createMemo(() => { return [ - ...(!ref()?.filter + ...(showRecent() ? local.model.recent().flatMap((item) => { const provider = sync.data.provider.find((x) => x.id === item.providerID)! if (!provider) return [] @@ -35,7 +52,17 @@ export function DialogModel() { title: model.name ?? item.modelID, description: provider.name, category: "Recent", - footer: model.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined, + footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect: () => { + dialog.clear() + local.model.set( + { + providerID: provider.id, + modelID: model.id, + }, + { recent: true }, + ) + }, }, ] }) @@ -56,28 +83,56 @@ export function DialogModel() { modelID: model, }, title: info.name ?? model, - description: provider.name, - category: provider.name, - footer: info.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined, + description: connected() ? provider.name : undefined, + category: connected() ? provider.name : undefined, + footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect() { + dialog.clear() + local.model.set( + { + providerID: provider.id, + modelID: model, + }, + { recent: true }, + ) + }, })), - filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))), + filter((x) => !showRecent() || !local.model.recent().find((y) => isDeepEqual(y, x.value))), sortBy((x) => x.title), ), ), ), + ...(!connected() + ? pipe( + providers(), + map((option) => { + return { + ...option, + category: "Popular providers", + } + }), + filter((x) => PROVIDER_PRIORITY[x.value] !== undefined), + sortBy((x) => PROVIDER_PRIORITY[x.value] ?? 99), + ) + : []), ] }) return ( <DialogSelect + keybind={[ + { + keybind: { ctrl: true, name: "a", meta: false, shift: false, leader: false }, + title: connected() ? "Connect provider" : "More providers", + onTrigger() { + dialog.replace(() => <DialogProvider />) + }, + }, + ]} ref={setRef} title="Select model" current={local.model.current()} options={options()} - onSelect={(option) => { - dialog.clear() - local.model.set(option.value, { recent: true }) - }} /> ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx new file mode 100644 index 000000000..0211d029f --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -0,0 +1,223 @@ +import { createMemo, createSignal, onMount, Show } from "solid-js" +import { useSync } from "@tui/context/sync" +import { map, pipe, sortBy } from "remeda" +import { DialogSelect } from "@tui/ui/dialog-select" +import { useDialog } from "@tui/ui/dialog" +import { useSDK } from "../context/sdk" +import { DialogPrompt } from "../ui/dialog-prompt" +import { useTheme } from "../context/theme" +import { TextAttributes } from "@opentui/core" +import type { ProviderAuthAuthorization } from "@opencode-ai/sdk" +import { DialogModel } from "./dialog-model" + +const PROVIDER_PRIORITY: Record<string, number> = { + opencode: 0, + anthropic: 1, + "github-copilot": 2, + openai: 3, + google: 4, + openrouter: 5, + vercel: 6, +} + +export function createDialogProviderOptions() { + const sync = useSync() + const dialog = useDialog() + const sdk = useSDK() + const options = createMemo(() => { + return pipe( + sync.data.provider_next.all, + map((provider) => ({ + title: provider.name, + value: provider.id, + footer: { + opencode: "Recommended", + anthropic: "Claude Max or API key", + }[provider.id], + async onSelect() { + const methods = sync.data.provider_auth[provider.id] ?? [ + { + 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") { + const result = await sdk.client.provider.oauth.authorize({ + path: { + id: provider.id, + }, + body: { + method: index, + }, + }) + if (result.data?.method === "code") { + dialog.replace(() => ( + <CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} /> + )) + } + if (result.data?.method === "auto") { + dialog.replace(() => ( + <AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} /> + )) + } + } + if (method.type === "api") { + return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />) + } + }, + })), + sortBy((x) => PROVIDER_PRIORITY[x.value] ?? 99), + ) + }) + 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() + + onMount(async () => { + const result = await sdk.client.provider.oauth.callback({ + path: { + id: props.providerID, + }, + body: { + method: props.index, + }, + }) + if (result.error) { + dialog.clear() + return + } + await sdk.client.instance.dispose() + await sync.bootstrap() + dialog.replace(() => <DialogModel />) + }) + + return ( + <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}> + <box flexDirection="row" justifyContent="space-between"> + <text attributes={TextAttributes.BOLD}>{props.title}</text> + <text fg={theme.textMuted}>esc</text> + </box> + <box gap={1}> + <text fg={theme.primary}>{props.authorization.url}</text> + <text fg={theme.textMuted}>{props.authorization.instructions}</text> + </box> + <text fg={theme.textMuted}>Waiting for authorization...</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({ + path: { + id: props.providerID, + }, + body: { + method: props.index, + code: value, + }, + }) + if (!error) { + await sdk.client.instance.dispose() + await sync.bootstrap() + dialog.replace(() => <DialogModel />) + return + } + setError(true) + }} + description={() => ( + <box gap={1}> + <text fg={theme.textMuted}>{props.authorization.instructions}</text> + <text fg={theme.primary}>{props.authorization.url}</text> + <Show when={error()}> + <text fg={theme.error}>Invalid code</text> + </Show> + </box> + )} + /> + ) +} + +interface ApiMethodProps { + providerID: string + title: string +} +function ApiMethod(props: ApiMethodProps) { + const dialog = useDialog() + const sdk = useSDK() + const sync = useSync() + + return ( + <DialogPrompt + title={props.title} + placeholder="API key" + onConfirm={async (value) => { + if (!value) return + sdk.client.auth.set({ + path: { + id: props.providerID, + }, + body: { + type: "api", + key: value, + }, + }) + await sdk.client.instance.dispose() + await sync.bootstrap() + dialog.replace(() => <DialogModel />) + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 8281ab617..17ac2381c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,18 +1,8 @@ -import { - TextAttributes, - BoxRenderable, - TextareaRenderable, - MouseEvent, - PasteEvent, - t, - dim, - fg, - type KeyBinding, -} from "@opentui/core" -import { createEffect, createMemo, Match, Switch, type JSX, onMount } from "solid-js" +import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core" +import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js" import { useLocal } from "@tui/context/local" import { useTheme } from "@tui/context/theme" -import { SplitBorder } from "@tui/component/border" +import { EmptyBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" @@ -29,6 +19,8 @@ import { Clipboard } from "../../util/clipboard" import type { FilePart } from "@opencode-ai/sdk" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" +import { Locale } from "@/util/locale" +import { Shimmer } from "../../ui/shimmer" export type PromptProps = { sessionID?: string @@ -57,7 +49,7 @@ export function Prompt(props: PromptProps) { const sdk = useSDK() const route = useRoute() const sync = useSync() - const status = createMemo(() => (props.sessionID ? sync.session.status(props.sessionID) : "idle")) + const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" }) const history = usePromptHistory() const command = useCommandDialog() const renderer = useRenderer() @@ -222,12 +214,17 @@ export function Prompt(props: PromptProps) { title: "Interrupt session", value: "session.interrupt", keybind: "session_interrupt", - disabled: status() !== "working", + disabled: status().type === "idle", category: "Session", onSelect: (dialog) => { - if (!props.sessionID) return if (autocomplete.visible) return if (!input.focused) return + // TODO: this should be its own command + if (store.mode === "shell") { + setStore("mode", "normal") + return + } + if (!props.sessionID) return setStore("interrupt", store.interrupt + 1) @@ -542,6 +539,16 @@ export function Prompt(props: PromptProps) { return } + const highlight = createMemo(() => { + if (keybind.leader) return theme.border + if (store.mode === "shell") return theme.primary + return local.agent.color(local.agent.current().name) + }) + + createEffect(() => { + renderer.setCursorColor(highlight()) + }) + return ( <> <Autocomplete @@ -566,17 +573,22 @@ export function Prompt(props: PromptProps) { /> <box ref={(r) => (anchor = r)}> <box - flexDirection="row" - {...SplitBorder} - borderColor={keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border} - justifyContent="space-evenly" + border={["left"]} + borderColor={highlight()} + customBorderChars={{ + ...EmptyBorder, + vertical: "┃", + bottomLeft: "╹", + }} > - <box backgroundColor={theme.backgroundElement} width={3} height="100%" alignItems="center" paddingTop={1}> - <text attributes={TextAttributes.BOLD} fg={theme.primary}> - {store.mode === "normal" ? ">" : "!"} - </text> - </box> - <box paddingTop={1} paddingBottom={1} backgroundColor={theme.backgroundElement} flexGrow={1}> + <box + paddingLeft={2} + paddingRight={1} + paddingTop={1} + flexShrink={0} + backgroundColor={theme.backgroundElement} + flexGrow={1} + > <textarea placeholder={ props.showPlaceholder @@ -751,35 +763,114 @@ export function Prompt(props: PromptProps) { cursorColor={theme.primary} syntaxStyle={syntax()} /> + <box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}> + <text fg={highlight()}> + {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} + </text> + <Show when={store.mode === "normal"}> + <box flexDirection="row" gap={1}> + <text fg={theme.textMuted}>{local.model.parsed().provider}</text> + <text flexShrink={0} fg={theme.text}> + {local.model.parsed().model} + </text> + </box> + </Show> + </box> </box> - <box backgroundColor={theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box> + </box> + <box + height={1} + border={["left"]} + borderColor={highlight()} + customBorderChars={{ + ...EmptyBorder, + vertical: "╹", + }} + > + <box + height={1} + border={["bottom"]} + borderColor={theme.backgroundElement} + customBorderChars={{ + ...EmptyBorder, + horizontal: "▀", + }} + /> </box> <box flexDirection="row" justifyContent="space-between"> - <text flexShrink={0} wrapMode="none" fg={theme.text}> - <span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "} - <span style={{ bold: true }}>{local.model.parsed().model}</span> - </text> - <Switch> - <Match when={status() === "compacting"}> - <text fg={theme.textMuted}>compacting...</text> - </Match> - <Match when={status() === "working"}> - <box flexDirection="row" gap={1}> - <text fg={store.interrupt > 0 ? theme.primary : theme.text}> - esc{" "} - <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}> - {store.interrupt > 0 ? "again to interrupt" : "interrupt"} - </span> - </text> + <Show when={status().type !== "idle"} fallback={<text />}> + <box + flexDirection="row" + gap={1} + flexGrow={1} + justifyContent={status().type === "retry" ? "space-between" : "flex-start"} + > + <box flexShrink={0} flexDirection="row" gap={1}> + <Shimmer text="Working" color={theme.text} /> + <box flexDirection="row" gap={1} flexShrink={0}> + {(() => { + const retry = createMemo(() => { + const s = status() + if (s.type !== "retry") return + return s + }) + const message = createMemo(() => { + const r = retry() + if (!r) return + if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) + return "gemini 3 way too hot right now" + if (r.message.length > 50) return r.message.slice(0, 50) + "..." + return r.message + }) + const [seconds, setSeconds] = createSignal(0) + onMount(() => { + const timer = setInterval(() => { + const next = retry()?.next + if (next) setSeconds(Math.round((next - Date.now()) / 1000)) + }, 1000) + + onCleanup(() => { + clearInterval(timer) + }) + }) + return ( + <Show when={retry()}> + <text fg={theme.error}> + {message()} [retrying {seconds() > 0 ? `in ${seconds()}s ` : ""} + attempt #{retry()!.attempt}] + </text> + </Show> + ) + })()} + </box> </box> - </Match> - <Match when={props.hint}>{props.hint!}</Match> - <Match when={true}> - <text fg={theme.text}> - {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span> + <text fg={store.interrupt > 0 ? theme.primary : theme.text}> + esc{" "} + <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}> + {store.interrupt > 0 ? "again to interrupt" : "interrupt"} + </span> </text> - </Match> - </Switch> + </box> + </Show> + <Show when={status().type !== "retry"}> + <box gap={2} flexDirection="row"> + <Switch> + <Match when={store.mode === "normal"}> + <text fg={theme.text}> + {keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>switch agent</span> + </text> + <text fg={theme.text}> + {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span> + </text> + </Match> + <Match when={store.mode === "shell"}> + <text fg={theme.text}> + esc <span style={{ fg: theme.textMuted }}>exit shell mode</span> + </text> + </Match> + </Switch> + </box> + </Show> </box> </box> </> diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index c2db85442..f9963fae8 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -10,6 +10,7 @@ import { createSimpleContext } from "./helper" import { useToast } from "../ui/toast" import { Provider } from "@/provider/provider" import { useArgs } from "./args" +import { RGBA } from "@opentui/core" export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", @@ -91,7 +92,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, color(name: string) { const agent = agents().find((x) => x.name === name) - if (agent?.color) return agent.color + if (agent?.color) return RGBA.fromHex(agent.color) const index = agents().findIndex((x) => x.name === name) return colors()[index % colors().length] }, diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 8b7564eb5..41f69f0d9 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -18,7 +18,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ sdk.event.subscribe().then(async (events) => { for await (const event of events.stream) { - console.log("event", event.type) emitter.emit(event.type, event) } }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 74fea2fd0..a5e13adb4 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -12,6 +12,8 @@ import type { McpStatus, FormatterStatus, SessionStatus, + ProviderListResponse, + ProviderAuthMethod, } from "@opencode-ai/sdk" import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" @@ -28,6 +30,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ status: "loading" | "partial" | "complete" provider: Provider[] provider_default: Record<string, string> + provider_next: ProviderListResponse + provider_auth: Record<string, ProviderAuthMethod[]> agent: Agent[] command: Command[] permission: { @@ -56,6 +60,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } formatter: FormatterStatus[] }>({ + provider_next: { + all: [], + default: {}, + connected: [], + }, + provider_auth: {}, config: {}, status: "loading", agent: [], @@ -232,20 +242,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const exit = useExit() - onMount(() => { + async function bootstrap() { // blocking - Promise.all([ + await Promise.all([ sdk.client.config.providers({ throwOnError: true }).then((x) => { batch(() => { setStore("provider", x.data!.providers) setStore("provider_default", x.data!.default) }) }), + sdk.client.provider.list({ throwOnError: true }).then((x) => { + batch(() => { + setStore("provider_next", x.data!) + }) + }), sdk.client.app.agents({ throwOnError: true }).then((x) => setStore("agent", x.data ?? [])), sdk.client.config.get({ throwOnError: true }).then((x) => setStore("config", x.data!)), ]) .then(() => { - setStore("status", "partial") + if (store.status !== "complete") setStore("status", "partial") // non-blocking Promise.all([ sdk.client.session.list().then((x) => @@ -259,6 +274,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)), sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)), sdk.client.session.status().then((x) => setStore("session_status", x.data!)), + sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})), ]).then(() => { setStore("status", "complete") }) @@ -266,6 +282,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .catch(async (e) => { await exit(e) }) + } + + onMount(() => { + bootstrap() }) const result = { @@ -320,6 +340,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ console.log("synced in " + (Date.now() - now), sessionID) }, }, + bootstrap, } return result }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f0954ed52..f028e8a6c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -601,11 +601,9 @@ export function Session() { } // Prompt for optional filename - const customFilename = await DialogPrompt.show( - dialog, - "Export filename", - `session-${sessionData.id.slice(0, 8)}.md`, - ) + const customFilename = await DialogPrompt.show(dialog, "Export filename", { + value: `session-${sessionData.id.slice(0, 8)}.md`, + }) // Cancel if user pressed escape if (customFilename === null) return @@ -904,52 +902,55 @@ function UserMessage(props: { <Show when={text()}> <box id={props.message.id} - onMouseOver={() => { - setHover(true) - }} - onMouseOut={() => { - setHover(false) - }} - onMouseUp={props.onMouseUp} border={["left"]} - paddingTop={1} - paddingBottom={1} - paddingLeft={2} - marginTop={props.index === 0 ? 0 : 1} - backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} - customBorderChars={SplitBorder.customBorderChars} borderColor={color()} - flexShrink={0} + customBorderChars={SplitBorder.customBorderChars} + marginTop={props.index === 0 ? 0 : 1} > - <text fg={theme.text}>{text()?.text}</text> - <Show when={files().length}> - <box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap"> - <For each={files()}> - {(file) => { - const bg = createMemo(() => { - if (file.mime.startsWith("image/")) return theme.accent - if (file.mime === "application/pdf") return theme.primary - return theme.secondary - }) - return ( - <text fg={theme.text}> - <span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span> - <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span> - </text> - ) - }} - </For> - </box> - </Show> - <text fg={theme.text}> - {sync.data.config.username ?? "You"}{" "} - <Show - when={queued()} - fallback={<span style={{ fg: theme.textMuted }}>({Locale.time(props.message.time.created)})</span>} - > - <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span> + <box + onMouseOver={() => { + setHover(true) + }} + onMouseOut={() => { + setHover(false) + }} + onMouseUp={props.onMouseUp} + paddingTop={1} + paddingBottom={1} + paddingLeft={1} + backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} + flexShrink={0} + > + <text fg={theme.text}>{text()?.text}</text> + <Show when={files().length}> + <box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap"> + <For each={files()}> + {(file) => { + const bg = createMemo(() => { + if (file.mime.startsWith("image/")) return theme.accent + if (file.mime === "application/pdf") return theme.primary + return theme.secondary + }) + return ( + <text fg={theme.text}> + <span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span> + <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span> + </text> + ) + }} + </For> + </box> </Show> - </text> + <text fg={theme.textMuted}> + {sync.data.config.username ?? "You"}{" "} + <Show + when={queued()} + fallback={<span style={{ fg: theme.textMuted }}>{Locale.time(props.message.time.created)}</span>} + > + <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span> + </Show> + </text> + </box> </box> </Show> <Show when={compaction()}> @@ -1007,7 +1008,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las </box> </Show> <Switch> - <Match when={props.last && status().type !== "idle"}> + <Match when={props.last && status().type !== "idle" && false}> <box paddingLeft={3} flexDirection="row" gap={1} marginTop={1}> <text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text> <Shimmer text={props.message.modelID} color={theme.text} /> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index eaf427aff..83f8e27fc 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -1,11 +1,13 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" -import { onMount } from "solid-js" +import { onMount, type JSX } from "solid-js" import { useKeyboard } from "@opentui/solid" export type DialogPromptProps = { title: string + description?: () => JSX.Element + placeholder?: string value?: string onConfirm?: (value: string) => void onCancel?: () => void @@ -19,12 +21,11 @@ export function DialogPrompt(props: DialogPromptProps) { useKeyboard((evt) => { if (evt.name === "return") { props.onConfirm?.(textarea.plainText) - dialog.clear() } }) onMount(() => { - dialog.setSize("large") + dialog.setSize("medium") setTimeout(() => { textarea.focus() }, 1) @@ -37,35 +38,36 @@ export function DialogPrompt(props: DialogPromptProps) { <text attributes={TextAttributes.BOLD}>{props.title}</text> <text fg={theme.textMuted}>esc</text> </box> - <box> + <box gap={1}> + {props.description} <textarea onSubmit={() => { props.onConfirm?.(textarea.plainText) - dialog.clear() }} + height={3} keyBindings={[{ name: "return", action: "submit" }]} ref={(val: TextareaRenderable) => (textarea = val)} initialValue={props.value} - placeholder="Enter text" + placeholder={props.placeholder ?? "Enter text"} /> </box> - <box paddingBottom={1}> - <text fg={theme.textMuted}>Press enter to confirm, esc to cancel</text> + <box paddingBottom={1} gap={1} flexDirection="row"> + <text fg={theme.text}> + enter <span style={{ fg: theme.textMuted }}>submit</span> + </text> + <text fg={theme.text}> + esc <span style={{ fg: theme.textMuted }}>cancel</span> + </text> </box> </box> ) } -DialogPrompt.show = (dialog: DialogContext, title: string, value?: string) => { +DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit<DialogPromptProps, "title">) => { return new Promise<string | null>((resolve) => { dialog.replace( () => ( - <DialogPrompt - title={title} - value={value} - onConfirm={(value) => resolve(value)} - onCancel={() => resolve(null)} - /> + <DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} onCancel={() => resolve(null)} /> ), () => resolve(null), ) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 285c039c1..7beef9b08 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -157,7 +157,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { return ( <box gap={1}> - <box paddingLeft={3} paddingRight={2}> + <box paddingLeft={4} paddingRight={4}> <box flexDirection="row" justifyContent="space-between"> <text fg={theme.text} attributes={TextAttributes.BOLD}> {props.title} @@ -184,8 +184,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { </box> </box> <scrollbox - paddingLeft={2} - paddingRight={2} + paddingLeft={1} + paddingRight={1} scrollbarOptions={{ visible: false }} ref={(r: ScrollBoxRenderable) => (scroll = r)} maxHeight={height()} @@ -194,7 +194,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { {([category, options], index) => ( <> <Show when={category}> - <box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}> + <box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}> <text fg={theme.accent} attributes={TextAttributes.BOLD}> {category} </text> @@ -203,6 +203,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { <For each={options}> {(option) => { const active = createMemo(() => isDeepEqual(option.value, selected()?.value)) + const current = createMemo(() => isDeepEqual(option.value, props.current)) return ( <box id={JSON.stringify(option.value)} @@ -217,8 +218,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { moveTo(index) }} backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} - paddingLeft={1} - paddingRight={1} + paddingLeft={current() ? 1 : 3} + paddingRight={3} gap={1} > <Option @@ -226,7 +227,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { footer={option.footer} description={option.description !== category ? option.description : undefined} active={active()} - current={isDeepEqual(option.value, props.current)} + current={current()} /> </box> ) @@ -236,12 +237,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { )} </For> </scrollbox> - <box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1} gap={1}> + <box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={1}> <For each={props.keybind ?? []}> {(item) => ( <text> - <span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span> - <span style={{ fg: theme.textMuted }}> {item.title}</span> + <span style={{ fg: theme.text }}> + <b>{item.title}</b>{" "} + </span> + <span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span> </text> )} </For> @@ -268,7 +271,7 @@ function Option(props: { fg={props.active ? theme.background : props.current ? theme.primary : theme.text} marginRight={0.5} > - ● + ◆ </text> </Show> <text @@ -277,6 +280,7 @@ function Option(props: { attributes={props.active ? TextAttributes.BOLD : undefined} overflow="hidden" wrapMode="none" + paddingLeft={3} > {Locale.truncate(props.title, 62)} <span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 14a9f7152..9b773111c 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -3,6 +3,8 @@ import { batch, createContext, Show, useContext, type JSX, type ParentProps } fr import { useTheme } from "@tui/context/theme" import { Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" +import { Clipboard } from "@tui/util/clipboard" +import { useToast } from "./toast" export function Dialog( props: ParentProps<{ @@ -12,10 +14,12 @@ export function Dialog( ) { const dimensions = useTerminalDimensions() const { theme } = useTheme() + const renderer = useRenderer() return ( <box onMouseUp={async () => { + if (renderer.getSelection()) return props.onClose?.() }} width={dimensions().width} @@ -29,6 +33,7 @@ export function Dialog( > <box onMouseUp={async (e) => { + if (renderer.getSelection()) return e.stopPropagation() }} width={props.size === "large" ? 80 : 60} @@ -124,10 +129,28 @@ const ctx = createContext<DialogContext>() export function DialogProvider(props: ParentProps) { const value = init() + const renderer = useRenderer() + const toast = useToast() return ( <ctx.Provider value={value}> {props.children} - <box position="absolute"> + <box + position="absolute" + onMouseUp={async () => { + const text = renderer.getSelection()?.getSelectedText() + if (text && text.length > 0) { + const base64 = Buffer.from(text).toString("base64") + const osc52 = `\x1b]52;c;${base64}\x07` + const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 + /* @ts-expect-error */ + renderer.writeOut(finalOsc52) + await Clipboard.copy(text) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + renderer.clearSelection() + } + }} + > <Show when={value.stack.length}> <Dialog onClose={() => value.clear()} size={value.size}> {value.stack.at(-1)!.element} diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 6e8ebb7a0..4defefa51 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -51,6 +51,7 @@ export const Instance = { async dispose() { Log.Default.info("disposing instance", { directory: Instance.directory }) await State.dispose(Instance.directory) + cache.delete(Instance.directory) }, async disposeAll() { Log.Default.info("disposing all instances") diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index 5846bf856..c1ac23c5d 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -57,7 +57,7 @@ export namespace State { tasks.push(task) } - entries.delete(key) + entries.clear() await Promise.all(tasks) disposalFinished = true log.info("state disposal completed", { key }) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts new file mode 100644 index 000000000..fb0016039 --- /dev/null +++ b/packages/opencode/src/provider/auth.ts @@ -0,0 +1,143 @@ +import { Instance } from "@/project/instance" +import { Plugin } from "../plugin" +import { map, filter, pipe, fromEntries, mapValues } from "remeda" +import z from "zod" +import { fn } from "@/util/fn" +import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin" +import { NamedError } from "@/util/error" +import { Auth } from "@/auth" + +export namespace ProviderAuth { + const state = Instance.state(async () => { + const methods = pipe( + await Plugin.list(), + filter((x) => x.auth?.provider !== undefined), + map((x) => [x.auth!.provider, x.auth!] as const), + fromEntries(), + ) + return { methods, pending: {} as Record<string, AuthOuathResult> } + }) + + export const Method = z + .object({ + type: z.union([z.literal("oauth"), z.literal("api")]), + label: z.string(), + }) + .meta({ + ref: "ProviderAuthMethod", + }) + export type Method = z.infer<typeof Method> + + export async function methods() { + const s = await state().then((x) => x.methods) + return mapValues(s, (x) => + x.methods.map( + (y): Method => ({ + type: y.type, + label: y.label, + }), + ), + ) + } + + export const Authorization = z + .object({ + url: z.string(), + method: z.union([z.literal("auto"), z.literal("code")]), + instructions: z.string(), + }) + .meta({ + ref: "ProviderAuthAuthorization", + }) + export type Authorization = z.infer<typeof Authorization> + + export const authorize = fn( + z.object({ + providerID: z.string(), + method: z.number(), + }), + async (input): Promise<Authorization | undefined> => { + const auth = await state().then((s) => s.methods[input.providerID]) + const method = auth.methods[input.method] + if (method.type === "oauth") { + const result = await method.authorize() + await state().then((s) => (s.pending[input.providerID] = result)) + return { + url: result.url, + method: result.method, + instructions: result.instructions, + } + } + }, + ) + + export const callback = fn( + z.object({ + providerID: z.string(), + method: z.number(), + code: z.string().optional(), + }), + async (input) => { + const match = await state().then((s) => s.pending[input.providerID]) + if (!match) throw new OauthMissing({ providerID: input.providerID }) + let result + + if (match.method === "code") { + if (!input.code) throw new OauthCodeMissing({ providerID: input.providerID }) + result = await match.callback(input.code) + } + + if (match.method === "auto") { + result = await match.callback() + } + + if (result?.type === "success") { + if ("key" in result) { + await Auth.set(input.providerID, { + type: "api", + key: result.key, + }) + } + if ("refresh" in result) { + await Auth.set(input.providerID, { + type: "oauth", + access: result.access, + refresh: result.refresh, + expires: result.expires, + }) + } + return + } + + throw new OauthCallbackFailed({}) + }, + ) + + export const api = fn( + z.object({ + providerID: z.string(), + key: z.string(), + }), + async (input) => { + await Auth.set(input.providerID, { + type: "api", + key: input.key, + }) + }, + ) + + export const OauthMissing = NamedError.create( + "ProviderAuthOauthMissing", + z.object({ + providerID: z.string(), + }), + ) + export const OauthCodeMissing = NamedError.create( + "ProviderAuthOauthCodeMissing", + z.object({ + providerID: z.string(), + }), + ) + + export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) +} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6a1754429..228c855aa 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,5 +1,4 @@ import z from "zod" -import path from "path" import { Config } from "../config/config" import { mergeDeep, sortBy } from "remeda" import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai" @@ -10,7 +9,6 @@ import { ModelsDev } from "./models" import { NamedError } from "../util/error" import { Auth } from "../auth" import { Instance } from "../project/instance" -import { Global } from "../global" import { Flag } from "../flag/flag" import { iife } from "@/util/iife" diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 32968cfeb..59a41c086 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -147,7 +147,7 @@ export namespace ProviderTransform { result["promptCacheKey"] = sessionID } - if (providerID === "google") { + if (providerID === "google" || (providerID === "opencode" && modelID.includes("gemini-3"))) { result["thinkingConfig"] = { includeThoughts: true, } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index ecdffa0df..1ab4185c2 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -23,6 +23,7 @@ import { Instance } from "../project/instance" import { Agent } from "../agent/agent" import { Auth } from "../auth" import { Command } from "../command" +import { ProviderAuth } from "../provider/auth" import { Global } from "../global" import { ProjectRoute } from "./project" import { ToolRegistry } from "../tool/registry" @@ -306,6 +307,27 @@ export namespace Server { ) }, ) + .post( + "/instance/dispose", + describeRoute({ + description: "Dispose the current instance", + operationId: "instance.dispose", + responses: { + 200: { + description: "Instance disposed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Instance.dispose() + return c.json(true) + }, + ) .get( "/path", describeRoute({ @@ -1164,6 +1186,138 @@ export namespace Server { }, ) .get( + "/provider", + describeRoute({ + description: "List all providers", + operationId: "provider.list", + responses: { + 200: { + description: "List of providers", + content: { + "application/json": { + schema: resolver( + z.object({ + all: ModelsDev.Provider.array(), + default: z.record(z.string(), z.string()), + connected: z.array(z.string()), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + const providers = await ModelsDev.get() + const connected = await Provider.list().then((x) => Object.keys(x)) + return c.json({ + all: Object.values(providers), + default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), + connected, + }) + }, + ) + .get( + "/provider/auth", + describeRoute({ + description: "Get provider authentication methods", + operationId: "provider.auth", + responses: { + 200: { + description: "Provider auth methods", + content: { + "application/json": { + schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await ProviderAuth.methods()) + }, + ) + .post( + "/provider/:id/oauth/authorize", + describeRoute({ + description: "Authorize a provider using OAuth", + operationId: "provider.oauth.authorize", + responses: { + 200: { + description: "Authorization URL and method", + content: { + "application/json": { + schema: resolver(ProviderAuth.Authorization.optional()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + id: z.string().meta({ description: "Provider ID" }), + }), + ), + validator( + "json", + z.object({ + method: z.number().meta({ description: "Auth method index" }), + }), + ), + async (c) => { + const id = c.req.valid("param").id + const { method } = c.req.valid("json") + const result = await ProviderAuth.authorize({ + providerID: id, + method, + }) + return c.json(result) + }, + ) + .post( + "/provider/:id/oauth/callback", + describeRoute({ + description: "Handle OAuth callback for a provider", + operationId: "provider.oauth.callback", + responses: { + 200: { + description: "OAuth callback processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + id: z.string().meta({ description: "Provider ID" }), + }), + ), + validator( + "json", + z.object({ + method: z.number().meta({ description: "Auth method index" }), + code: z.string().optional().meta({ description: "OAuth authorization code" }), + }), + ), + async (c) => { + const id = c.req.valid("param").id + const { method, code } = c.req.valid("json") + await ProviderAuth.callback({ + providerID: id, + method, + code, + }) + return c.json(true) + }, + ) + .get( "/find", describeRoute({ description: "Find text in files", diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 443d24046..ff060ac21 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -49,6 +49,9 @@ import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { SessionStatus } from "./status" +// @ts-ignore +globalThis.AI_SDK_LOG_WARNINGS = false + export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) export const OUTPUT_TOKEN_MAX = 32_000 @@ -239,6 +242,7 @@ export namespace SessionPrompt { let step = 0 while (true) { + SessionStatus.set(sessionID, { type: "busy" }) log.info("loop", { step, sessionID }) if (abort.aborted) break let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 0601e5877..ab7aac251 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -26,120 +26,122 @@ export type PluginInput = { export type Plugin = (input: PluginInput) => Promise<Hooks> +export type AuthHook = { + provider: string + loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>> + methods: ( + | { + type: "oauth" + label: string + prompts?: Array< + | { + type: "text" + key: string + message: string + placeholder?: string + validate?: (value: string) => string | undefined + condition?: (inputs: Record<string, string>) => boolean + } + | { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + condition?: (inputs: Record<string, string>) => boolean + } + > + authorize(inputs?: Record<string, string>): Promise<AuthOuathResult> + } + | { + type: "api" + label: string + prompts?: Array< + | { + type: "text" + key: string + message: string + placeholder?: string + validate?: (value: string) => string | undefined + condition?: (inputs: Record<string, string>) => boolean + } + | { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + condition?: (inputs: Record<string, string>) => boolean + } + > + authorize?(inputs?: Record<string, string>): Promise< + | { + type: "success" + key: string + provider?: string + } + | { + type: "failed" + } + > + } + )[] +} + +export type AuthOuathResult = { url: string; instructions: string } & ( + | { + method: "auto" + callback(): Promise< + | ({ + type: "success" + provider?: string + } & ( + | { + refresh: string + access: string + expires: number + } + | { key: string } + )) + | { + type: "failed" + } + > + } + | { + method: "code" + callback(code: string): Promise< + | ({ + type: "success" + provider?: string + } & ( + | { + refresh: string + access: string + expires: number + } + | { key: string } + )) + | { + type: "failed" + } + > + } +) + export interface Hooks { event?: (input: { event: Event }) => Promise<void> config?: (input: Config) => Promise<void> tool?: { [key: string]: ToolDefinition } - auth?: { - provider: string - loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>> - methods: ( - | { - type: "oauth" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - validate?: (value: string) => string | undefined - condition?: (inputs: Record<string, string>) => boolean - } - | { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - condition?: (inputs: Record<string, string>) => boolean - } - > - authorize(inputs?: Record<string, string>): Promise< - { url: string; instructions: string } & ( - | { - method: "auto" - callback(): Promise< - | ({ - type: "success" - provider?: string - } & ( - | { - refresh: string - access: string - expires: number - } - | { key: string } - )) - | { - type: "failed" - } - > - } - | { - method: "code" - callback(code: string): Promise< - | ({ - type: "success" - provider?: string - } & ( - | { - refresh: string - access: string - expires: number - } - | { key: string } - )) - | { - type: "failed" - } - > - } - ) - > - } - | { - type: "api" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - validate?: (value: string) => string | undefined - condition?: (inputs: Record<string, string>) => boolean - } - | { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - condition?: (inputs: Record<string, string>) => boolean - } - > - authorize?(inputs?: Record<string, string>): Promise< - | { - type: "success" - key: string - provider?: string - } - | { - type: "failed" - } - > - } - )[] - } + auth?: AuthHook /** * Called when a new message is received */ diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index 04dc29ccd..dc2247990 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -19,6 +19,8 @@ import type { ToolListData, ToolListResponses, ToolListErrors, + InstanceDisposeData, + InstanceDisposeResponses, PathGetData, PathGetResponses, SessionListData, @@ -92,6 +94,16 @@ import type { CommandListResponses, ConfigProvidersData, ConfigProvidersResponses, + ProviderListData, + ProviderListResponses, + ProviderAuthData, + ProviderAuthResponses, + ProviderOauthAuthorizeData, + ProviderOauthAuthorizeResponses, + ProviderOauthAuthorizeErrors, + ProviderOauthCallbackData, + ProviderOauthCallbackResponses, + ProviderOauthCallbackErrors, FindTextData, FindTextResponses, FindFilesData, @@ -272,6 +284,18 @@ class Tool extends _HeyApiClient { } } +class Instance extends _HeyApiClient { + /** + * Dispose the current instance + */ + public dispose<ThrowOnError extends boolean = false>(options?: Options<InstanceDisposeData, ThrowOnError>) { + return (options?.client ?? this._client).post<InstanceDisposeResponses, unknown, ThrowOnError>({ + url: "/instance/dispose", + ...options, + }) + } +} + class Path extends _HeyApiClient { /** * Get the current path @@ -554,6 +578,67 @@ class Command extends _HeyApiClient { } } +class Oauth extends _HeyApiClient { + /** + * Authorize a provider using OAuth + */ + public authorize<ThrowOnError extends boolean = false>(options: Options<ProviderOauthAuthorizeData, ThrowOnError>) { + return (options.client ?? this._client).post< + ProviderOauthAuthorizeResponses, + ProviderOauthAuthorizeErrors, + ThrowOnError + >({ + url: "/provider/{id}/oauth/authorize", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Handle OAuth callback for a provider + */ + public callback<ThrowOnError extends boolean = false>(options: Options<ProviderOauthCallbackData, ThrowOnError>) { + return (options.client ?? this._client).post< + ProviderOauthCallbackResponses, + ProviderOauthCallbackErrors, + ThrowOnError + >({ + url: "/provider/{id}/oauth/callback", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } +} + +class Provider extends _HeyApiClient { + /** + * List all providers + */ + public list<ThrowOnError extends boolean = false>(options?: Options<ProviderListData, ThrowOnError>) { + return (options?.client ?? this._client).get<ProviderListResponses, unknown, ThrowOnError>({ + url: "/provider", + ...options, + }) + } + + /** + * Get provider authentication methods + */ + public auth<ThrowOnError extends boolean = false>(options?: Options<ProviderAuthData, ThrowOnError>) { + return (options?.client ?? this._client).get<ProviderAuthResponses, unknown, ThrowOnError>({ + url: "/provider/auth", + ...options, + }) + } + oauth = new Oauth({ client: this._client }) +} + class Find extends _HeyApiClient { /** * Find text in files @@ -891,9 +976,11 @@ export class OpencodeClient extends _HeyApiClient { project = new Project({ client: this._client }) config = new Config({ client: this._client }) tool = new Tool({ client: this._client }) + instance = new Instance({ client: this._client }) path = new Path({ client: this._client }) session = new Session({ client: this._client }) command = new Command({ client: this._client }) + provider = new Provider({ client: this._client }) find = new Find({ client: this._client }) file = new File({ client: this._client }) app = new App({ client: this._client }) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index c776c8dc9..ebd1140f1 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1333,6 +1333,17 @@ export type Provider = { } } +export type ProviderAuthMethod = { + type: "oauth" | "api" + label: string +} + +export type ProviderAuthAuthorization = { + url: string + method: "auto" | "code" + instructions: string +} + export type Symbol = { name: string kind: number @@ -1611,6 +1622,24 @@ export type ToolListResponses = { export type ToolListResponse = ToolListResponses[keyof ToolListResponses] +export type InstanceDisposeData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/instance/dispose" +} + +export type InstanceDisposeResponses = { + /** + * Instance disposed + */ + 200: boolean +} + +export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] + export type PathGetData = { body?: never path?: never @@ -2484,6 +2513,128 @@ export type ConfigProvidersResponses = { export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type ProviderListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/provider" +} + +export type ProviderListResponses = { + /** + * List of providers + */ + 200: { + all: Array<Provider> + default: { + [key: string]: string + } + connected: Array<string> + } +} + +export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] + +export type ProviderAuthData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/provider/auth" +} + +export type ProviderAuthResponses = { + /** + * Provider auth methods + */ + 200: { + [key: string]: Array<ProviderAuthMethod> + } +} + +export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] + +export type ProviderOauthAuthorizeData = { + body?: { + /** + * Auth method index + */ + method: number + } + path: { + /** + * Provider ID + */ + id: string + } + query?: { + directory?: string + } + url: "/provider/{id}/oauth/authorize" +} + +export type ProviderOauthAuthorizeErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] + +export type ProviderOauthAuthorizeResponses = { + /** + * Authorization URL and method + */ + 200: ProviderAuthAuthorization +} + +export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] + +export type ProviderOauthCallbackData = { + body?: { + /** + * Auth method index + */ + method: number + /** + * OAuth authorization code + */ + code?: string + } + path: { + /** + * Provider ID + */ + id: string + } + query?: { + directory?: string + } + url: "/provider/{id}/oauth/callback" +} + +export type ProviderOauthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] + +export type ProviderOauthCallbackResponses = { + /** + * OAuth callback processed successfully + */ + 200: boolean +} + +export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] + export type FindTextData = { body?: never path?: never |
