diff --git a/bun.lock b/bun.lock
index bd770d5251a..a02f2398935 100644
--- a/bun.lock
+++ b/bun.lock
@@ -41,7 +41,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
- "version": "1.0.83",
+ "version": "1.0.85",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -68,7 +68,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
- "version": "1.0.83",
+ "version": "1.0.85",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -92,7 +92,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
- "version": "1.0.83",
+ "version": "1.0.85",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -116,7 +116,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
- "version": "1.0.83",
+ "version": "1.0.85",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -156,7 +156,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
- "version": "1.0.83",
+ "version": "1.0.85",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -172,7 +172,7 @@
},
"packages/opencode": {
"name": "opencode",
- "version": "1.0.83",
+ "version": "1.0.85",
"bin": {
"opencode": "./bin/opencode",
},
@@ -251,7 +251,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
- "version": "1.0.83",
+ "version": "1.0.85",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -271,7 +271,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
- "version": "1.0.83",
+ "version": "1.0.85",
"devDependencies": {
"@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:",
@@ -282,7 +282,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
- "version": "1.0.83",
+ "version": "1.0.85",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -295,7 +295,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
- "version": "1.0.83",
+ "version": "1.0.85",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -325,7 +325,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
- "version": "1.0.83",
+ "version": "1.0.85",
"dependencies": {
"zod": "catalog:",
},
@@ -335,7 +335,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
- "version": "1.0.83",
+ "version": "1.0.85",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
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/console/app/package.json b/packages/console/app/package.json
index 071041da9ca..745bf6bebbd 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "./script/generate-sitemap.ts && vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
- "version": "1.0.83"
+ "version": "1.0.85"
},
"dependencies": {
"@ibm/plex": "6.4.1",
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index 770ccc3aa8a..1ff03db3451 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
- "version": "1.0.83",
+ "version": "1.0.85",
"private": true,
"type": "module",
"dependencies": {
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index 5192c5f625e..74588bbd17b 100644
--- a/packages/console/function/package.json
+++ b/packages/console/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
- "version": "1.0.83",
+ "version": "1.0.85",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index 9a12d93ce31..c92fb685310 100644
--- a/packages/console/mail/package.json
+++ b/packages/console/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
- "version": "1.0.83",
+ "version": "1.0.85",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index ba75cf84dfc..cc05656c396 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
- "version": "1.0.83",
+ "version": "1.0.85",
"description": "",
"type": "module",
"scripts": {
diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml
index 3f850d46f34..e1ceeaf8059 100644
--- a/packages/extensions/zed/extension.toml
+++ b/packages/extensions/zed/extension.toml
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The AI coding agent built for the terminal"
-version = "1.0.83"
+version = "1.0.85"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.83/opencode-darwin-arm64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.83/opencode-darwin-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.83/opencode-linux-arm64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-linux-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.83/opencode-linux-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-linux-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.83/opencode-windows-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
diff --git a/packages/function/package.json b/packages/function/package.json
index c87be58f590..9a8e3350262 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
- "version": "1.0.83",
+ "version": "1.0.85",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 86244e780da..4a919d7cf66 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.0.83",
+ "version": "1.0.85",
"name": "opencode",
"type": "module",
"private": true,
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..c25e7e370ab 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
@@ -1,15 +1,10 @@
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
-import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy } from "remeda"
+import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy, take } from "remeda"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
-import { useTheme } from "../context/theme"
-
-function Free() {
- const { theme } = useTheme()
- return Free
-}
+import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
export function DialogModel() {
const local = useLocal()
@@ -17,9 +12,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 +37,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 +68,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,
+ disabled: provider.id === "opencode" && model.includes("-nano"),
+ 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",
+ }
+ }),
+ take(6),
+ )
+ : []),
]
})
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..109d4d25ade
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
@@ -0,0 +1,222 @@
+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,
+}
+
+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..b04cb7c6043 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" ? ">" : "!"}
-
-
-
+
+
+
+ {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
+
+
+
+ {local.model.parsed().provider}
+
+ {local.model.parsed().model}
+
+
+
+
-
+
+
+
-
- {local.model.parsed().provider}{" "}
- {local.model.parsed().model}
-
-
-
- compacting...
-
-
-
- 0 ? theme.primary : theme.text}>
- esc{" "}
- 0 ? theme.primary : theme.textMuted }}>
- {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
-
-
+ }>
+
+
+
+
+ {(() => {
+ 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 (
+
+
+ {message()} [retrying {seconds() > 0 ? `in ${seconds()}s ` : ""}
+ attempt #{retry()!.attempt}]
+
+
+ )
+ })()}
+
-
- {props.hint!}
-
-
- {keybind.print("command_list")} commands
+ 0 ? theme.primary : theme.text}>
+ esc{" "}
+ 0 ? theme.primary : theme.textMuted }}>
+ {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
+
-
-
+
+
+
+
+
+
+
+ {keybind.print("agent_cycle")} switch agent
+
+
+ {keybind.print("command_list")} commands
+
+
+
+
+ esc exit shell mode
+
+
+
+
+
>
)
}
+
+function Loader() {
+ const FRAMES = [
+ "▱▱▱▱▱▱▱",
+ "▱▱▱▱▱▱▱",
+ "▱▱▱▱▱▱▱",
+ "▱▱▱▱▱▱▱",
+ "▰▱▱▱▱▱▱",
+ "▰▰▱▱▱▱▱",
+ "▰▰▰▱▱▱▱",
+ "▱▰▰▰▱▱▱",
+ "▱▱▰▰▰▱▱",
+ "▱▱▱▰▰▰▱",
+ "▱▱▱▱▰▰▰",
+ "▱▱▱▱▱▰▰",
+ "▱▱▱▱▱▱▰",
+ "▱▱▱▱▱▱▱",
+ "▱▱▱▱▱▱▱",
+ "▱▱▱▱▱▱▱",
+ "▱▱▱▱▱▱▱",
+ ]
+ const [frame, setFrame] = createSignal(0)
+
+ onMount(() => {
+ const timer = setInterval(() => {
+ setFrame((frame() + 1) % FRAMES.length)
+ }, 100)
+ onCleanup(() => {
+ clearInterval(timer)
+ })
+ })
+
+ const { theme } = useTheme()
+ return {FRAMES[frame()]}
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx
index c2db85442f8..f9963fae880 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 8b7564eb5d7..41f69f0d92e 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 74fea2fd002..a5e13adb417 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
+ provider_next: ProviderListResponse
+ provider_auth: Record
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 0698f366f28..f028e8a6c26 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: {
{
- 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()?.text}
-
-
-
- {(file) => {
- const bg = createMemo(() => {
- if (file.mime.startsWith("image/")) return theme.accent
- if (file.mime === "application/pdf") return theme.primary
- return theme.secondary
- })
- return (
-
- {MIME_BADGE[file.mime] ?? file.mime}
- {file.filename}
-
- )
- }}
-
-
-
-
- {sync.data.config.username ?? "You"}{" "}
- ({Locale.time(props.message.time.created)})}
- >
- QUEUED
+ {
+ setHover(true)
+ }}
+ onMouseOut={() => {
+ setHover(false)
+ }}
+ onMouseUp={props.onMouseUp}
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={1}
+ backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
+ flexShrink={0}
+ >
+ {text()?.text}
+
+
+
+ {(file) => {
+ const bg = createMemo(() => {
+ if (file.mime.startsWith("image/")) return theme.accent
+ if (file.mime === "application/pdf") return theme.primary
+ return theme.secondary
+ })
+ return (
+
+ {MIME_BADGE[file.mime] ?? file.mime}
+ {file.filename}
+
+ )
+ }}
+
+
-
+
+ {sync.data.config.username ?? "You"}{" "}
+ {Locale.time(props.message.time.created)}}
+ >
+ QUEUED
+
+
+
@@ -1007,7 +1008,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
-
+
{Locale.titlecase(props.message.mode)}
@@ -1310,7 +1311,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
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 eaf427aff04..83f8e27fcd4 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) {
{props.title}
esc
-
+
+ {props.description}
-
- Press enter to confirm, esc to cancel
+
+
+ enter submit
+
+
+ esc cancel
+
)
}
-DialogPrompt.show = (dialog: DialogContext, title: string, value?: string) => {
+DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit) => {
return new Promise((resolve) => {
dialog.replace(
() => (
- resolve(value)}
- onCancel={() => resolve(null)}
- />
+ 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 285c039c11e..7beef9b08dd 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(props: DialogSelectProps) {
return (
-
+
{props.title}
@@ -184,8 +184,8 @@ export function DialogSelect(props: DialogSelectProps) {
(scroll = r)}
maxHeight={height()}
@@ -194,7 +194,7 @@ export function DialogSelect(props: DialogSelectProps) {
{([category, options], index) => (
<>
- 0 ? 1 : 0} paddingLeft={1}>
+ 0 ? 1 : 0} paddingLeft={3}>
{category}
@@ -203,6 +203,7 @@ export function DialogSelect(props: DialogSelectProps) {
{(option) => {
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
+ const current = createMemo(() => isDeepEqual(option.value, props.current))
return (
(props: DialogSelectProps) {
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}
>
)
@@ -236,12 +237,14 @@ export function DialogSelect(props: DialogSelectProps) {
)}
-
+
{(item) => (
- {Keybind.toString(item.keybind)}
- {item.title}
+
+ {item.title}{" "}
+
+ {Keybind.toString(item.keybind)}
)}
@@ -268,7 +271,7 @@ function Option(props: {
fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
marginRight={0.5}
>
- ●
+ ◆
{Locale.truncate(props.title, 62)}
{props.description}
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
index 14a9f715268..9b773111c35 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 (
{
+ if (renderer.getSelection()) return
props.onClose?.()
}}
width={dimensions().width}
@@ -29,6 +33,7 @@ export function Dialog(
>
{
+ if (renderer.getSelection()) return
e.stopPropagation()
}}
width={props.size === "large" ? 80 : 60}
@@ -124,10 +129,28 @@ const ctx = createContext()
export function DialogProvider(props: ParentProps) {
const value = init()
+ const renderer = useRenderer()
+ const toast = useToast()
return (
{props.children}
-
+ {
+ 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()
+ }
+ }}
+ >