From 5413b16b573320f0626d93e701b783ce986750e3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 20 Nov 2025 22:33:46 -0600 Subject: [PATCH 1/9] fix: split not a function error --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 0698f366f28..f0954ed52a9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1310,7 +1310,10 @@ ToolRegistry.register({ container: "block", render(props) { const { theme, syntax } = useTheme() - const lines = createMemo(() => props.input.content?.split("\n") ?? [], [] as string[]) + const lines = createMemo( + () => (typeof props.input.content === "string" ? props.input.content.split("\n") : []), + [] as string[], + ) const code = createMemo(() => { if (!props.input.content) return "" const text = props.input.content From c417fec2464079a6e0bba3450d5e41c5394282bc Mon Sep 17 00:00:00 2001 From: Zak Date: Thu, 20 Nov 2025 20:36:07 -0800 Subject: [PATCH 2/9] tweak: adjust invalid directory error message (#4567) Co-authored-by: opencode-agent[bot] Co-authored-by: rekram1-node Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/cli/error.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index b3ff5670e35..464afc7107b 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -12,7 +12,7 @@ export function FormatError(input: unknown) { ) } if (Config.ConfigDirectoryTypoError.isInstance(input)) { - return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Use "${input.data.suggestion}" instead. This is a common typo.` + return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.` } if (ConfigMarkdown.FrontmatterError.isInstance(input)) { return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}` From 23ea8ba1ceb35358c62ba1051ba402223d0fb5b3 Mon Sep 17 00:00:00 2001 From: Dax Date: Fri, 21 Nov 2025 00:21:06 -0500 Subject: [PATCH 3/9] Tui onboarding (#4569) Co-authored-by: GitHub Action --- flake.lock | 6 +- packages/opencode/src/cli/cmd/tui/app.tsx | 33 ++- .../src/cli/cmd/tui/component/border.tsx | 25 +- .../cli/cmd/tui/component/dialog-model.tsx | 77 +++++- .../cli/cmd/tui/component/dialog-provider.tsx | 223 ++++++++++++++++++ .../cli/cmd/tui/component/prompt/index.tsx | 191 +++++++++++---- .../src/cli/cmd/tui/context/local.tsx | 3 +- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 1 - .../opencode/src/cli/cmd/tui/context/sync.tsx | 27 ++- .../src/cli/cmd/tui/routes/session/index.tsx | 97 ++++---- .../src/cli/cmd/tui/ui/dialog-prompt.tsx | 32 +-- .../src/cli/cmd/tui/ui/dialog-select.tsx | 26 +- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 25 +- packages/opencode/src/project/instance.ts | 1 + packages/opencode/src/project/state.ts | 2 +- packages/opencode/src/provider/auth.ts | 143 +++++++++++ packages/opencode/src/provider/provider.ts | 2 - packages/opencode/src/provider/transform.ts | 2 +- packages/opencode/src/server/server.ts | 154 ++++++++++++ packages/opencode/src/session/prompt.ts | 4 + packages/plugin/src/index.ts | 218 ++++++++--------- packages/sdk/js/src/gen/sdk.gen.ts | 87 +++++++ packages/sdk/js/src/gen/types.gen.ts | 151 ++++++++++++ 23 files changed, 1253 insertions(+), 277 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx create mode 100644 packages/opencode/src/provider/auth.ts diff --git a/flake.lock b/flake.lock index cdab71ce74a..1150e275150 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 4f990e76e9f..7bb10de899a 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" @@ -293,6 +294,14 @@ function App() { }, category: "System", }, + { + title: "Connect provider", + value: "provider.connect", + onSelect: () => { + dialog.replace(() => ) + }, + category: "System", + }, { title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`, value: "theme.switch_mode", @@ -451,16 +460,18 @@ function App() { {process.cwd().replace(Global.Path.home, "~")} - - - tab - - {""} - - {local.agent.current().name.toUpperCase()} - AGENT - - + + + + tab + + {""} + + {local.agent.current().name.toUpperCase()} + AGENT + + + ) diff --git a/packages/opencode/src/cli/cmd/tui/component/border.tsx b/packages/opencode/src/cli/cmd/tui/component/border.tsx index 9cbb96068d3..333071020c4 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 bcd1d98d56a..35e885243b9 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 Free + return Free +} +const PROVIDER_PRIORITY: Record = { + 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>() + 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" ? : 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" ? : 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 ( ) + }, + }, + ]} 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 00000000000..0211d029f53 --- /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 = { + 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((resolve) => { + dialog.replace( + () => ( + ({ + 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(() => ( + + )) + } + if (result.data?.method === "auto") { + dialog.replace(() => ( + + )) + } + } + if (method.type === "api") { + return dialog.replace(() => ) + } + }, + })), + sortBy((x) => PROVIDER_PRIORITY[x.value] ?? 99), + ) + }) + return options +} + +export function DialogProvider() { + const options = createDialogProviderOptions() + return +} + +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(() => ) + }) + + return ( + + + {props.title} + esc + + + {props.authorization.url} + {props.authorization.instructions} + + Waiting for authorization... + + ) +} + +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 ( + { + 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(() => ) + return + } + setError(true) + }} + description={() => ( + + {props.authorization.instructions} + {props.authorization.url} + + Invalid code + + + )} + /> + ) +} + +interface ApiMethodProps { + providerID: string + title: string +} +function ApiMethod(props: ApiMethodProps) { + const dialog = useDialog() + const sdk = useSDK() + const sync = useSync() + + return ( + { + 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(() => ) + }} + /> + ) +} 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 8281ab617aa..17ac2381c9a 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 ( <> (anchor = r)}> - - - {store.mode === "normal" ? ">" : "!"} - - - +