Compare commits

...

27 Commits

Author SHA1 Message Date
Aiden Cline e6a3be31de merge dev
format / format (push) Has been cancelled
test / test (push) Has been cancelled
2025-11-21 01:44:45 -06:00
Aiden Cline d16c8c9f0f ignore: update sdk 2025-11-21 01:25:06 -06:00
Aiden Cline fffe20cbe5 add provider whitelist 2025-11-21 01:24:44 -06:00
Aiden Cline f6da3c467b ignore: sync sdk 2025-11-21 01:04:02 -06:00
geril07 c0d9f21c0f feat: whitelist/blacklist config options for provider (#3416)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-11-21 01:01:30 -06:00
GitHub Action a67b616139 chore: format code 2025-11-21 06:03:38 +00:00
opencode 2991547974 release: v1.0.85
Update Nix Hashes / update (push) Has been cancelled
2025-11-21 06:03:38 +00:00
Dax Raad b59def2e4a hide gpt5 nano 2025-11-21 00:58:02 -05:00
opencode d842353f39 release: v1.0.84
Update Nix Hashes / update (push) Has been cancelled
2025-11-21 05:55:03 +00:00
Dax Raad d20ef569de tui: replace text shimmer with animated progress bar during model processing 2025-11-21 00:47:27 -05:00
Dax Raad ca2b871810 tui: simplify popular providers list to show only top 6 instead of applying provider priority sorting 2025-11-21 00:32:21 -05:00
Dax 23ea8ba1ce Tui onboarding (#4569)
Co-authored-by: GitHub Action <action@github.com>
2025-11-21 00:21:06 -05:00
Zak c417fec246 tweak: adjust invalid directory error message (#4567)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-11-20 22:36:07 -06:00
Aiden Cline 5413b16b57 fix: split not a function error 2025-11-20 22:33:59 -06:00
Spoon 43c021ed80 feat: add field to allow user only messages (#4554)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-20 19:13:42 -06:00
GitHub Action 3b005d29d7 chore: format code 2025-11-21 00:49:39 +00:00
opencode 635f70f477 release: v1.0.83
Update Nix Hashes / update (push) Has been cancelled
2025-11-21 00:49:39 +00:00
Aiden Cline adbb6037ac fix: undefined err 2025-11-20 18:43:58 -06:00
Ian Maurer 715265de4b Update .gitignore to remove '*.bun-build'
Remove '*.bun-build' from the .gitignore file
2025-11-12 11:33:59 -05:00
Ian Maurer 2048f32491 Merge branch 'dev' into fix/cli-clean-exit-on-model-errors 2025-11-12 11:30:36 -05:00
GitHub Action 2d7ba43a21 chore: format code 2025-11-12 16:14:12 +00:00
Ian Maurer bd198d8550 fix(cli): robust suggestions for unknown provider and model-only input; ignore bun build artifacts 2025-11-12 11:13:34 -05:00
GitHub Action cbffbcdd3d chore: format code 2025-11-12 15:43:30 +00:00
Ian Maurer 2be8b2269f feat(cli): suggest closest provider/model on not found ("Did you mean…")\n\nSummary\n- Add fuzzy suggestions to ProviderModelNotFoundError with up to 3 candidates\n- Normalize punctuation (e.g., 4.5 vs 4-5) and case to better match common typos\n- Support model-only input (no provider) by searching across all providers\n- Enhance CLI error formatter to display suggestions when present\n\nImplementation\n- provider.ts: use fuzzysort; add normalization by stripping non-alphanumerics; search by key for robust matches\n- provider.ts: when provider is unknown and model is empty, treat token as unqualified model and search across all providers' models; otherwise suggest provider matches\n- error.ts: print "Did you mean: <provider/model>, …" when suggestions exist\n\nExamples\n1) Typo in model ID\n $ bun run ./src/index.ts run --model anthropic/claude-haiu-4-5 "hi"\n Error: Model not found: anthropic/claude-haiu-4-5\n Did you mean: anthropic/claude-haiku-4-5, anthropic/claude-haiku-4-5-20251001\n Try: zai-coding-plan/glm-4.5-flash
zai-coding-plan/glm-4.5
zai-coding-plan/glm-4.5-air
zai-coding-plan/glm-4.5v
zai-coding-plan/glm-4.6
opencode/big-pickle
opencode/grok-code
anthropic/claude-opus-4-0
anthropic/claude-3-5-sonnet-20241022
anthropic/claude-opus-4-1
anthropic/claude-haiku-4-5
anthropic/claude-3-5-sonnet-20240620
anthropic/claude-3-5-haiku-latest
anthropic/claude-3-opus-20240229
anthropic/claude-sonnet-4-5
anthropic/claude-sonnet-4-5-20250929
anthropic/claude-sonnet-4-20250514
anthropic/claude-opus-4-20250514
anthropic/claude-3-5-haiku-20241022
anthropic/claude-3-haiku-20240307
anthropic/claude-3-7-sonnet-20250219
anthropic/claude-3-7-sonnet-latest
anthropic/claude-sonnet-4-0
anthropic/claude-opus-4-1-20250805
anthropic/claude-3-sonnet-20240229
anthropic/claude-haiku-4-5-20251001
openai/gpt-4.1-nano
openai/text-embedding-3-small
openai/gpt-4
openai/o1-pro
openai/gpt-4o-2024-05-13
openai/gpt-4o-2024-08-06
openai/gpt-4.1-mini
openai/o3-deep-research
openai/gpt-3.5-turbo
openai/text-embedding-3-large
openai/gpt-4-turbo
openai/o1-preview
openai/o3-mini
openai/codex-mini-latest
openai/gpt-5-nano
openai/gpt-5-codex
openai/gpt-4o
openai/gpt-4.1
openai/o4-mini
openai/o1
openai/gpt-5-mini
openai/o1-mini
openai/text-embedding-ada-002
openai/o3-pro
openai/gpt-4o-2024-11-20
openai/o3
openai/o4-mini-deep-research
openai/gpt-4o-mini
openai/gpt-5
openai/gpt-5-pro to list available models\n   Or check your config (opencode.json) provider/model names\n\n2) Dot vs dash (punctuation normalization)\n   $ bun run ./src/index.ts run --model anthropic/claude-haiku-4.5 "hi"\n   Error: Model not found: anthropic/claude-haiku-4.5\n   Did you mean: anthropic/claude-haiku-4-5, anthropic/claude-haiku-4-5-20251001\n   Try: zai-coding-plan/glm-4.5-flash
zai-coding-plan/glm-4.5
zai-coding-plan/glm-4.5-air
zai-coding-plan/glm-4.5v
zai-coding-plan/glm-4.6
opencode/big-pickle
opencode/grok-code
anthropic/claude-opus-4-0
anthropic/claude-3-5-sonnet-20241022
anthropic/claude-opus-4-1
anthropic/claude-haiku-4-5
anthropic/claude-3-5-sonnet-20240620
anthropic/claude-3-5-haiku-latest
anthropic/claude-3-opus-20240229
anthropic/claude-sonnet-4-5
anthropic/claude-sonnet-4-5-20250929
anthropic/claude-sonnet-4-20250514
anthropic/claude-opus-4-20250514
anthropic/claude-3-5-haiku-20241022
anthropic/claude-3-haiku-20240307
anthropic/claude-3-7-sonnet-20250219
anthropic/claude-3-7-sonnet-latest
anthropic/claude-sonnet-4-0
anthropic/claude-opus-4-1-20250805
anthropic/claude-3-sonnet-20240229
anthropic/claude-haiku-4-5-20251001
openai/gpt-4.1-nano
openai/text-embedding-3-small
openai/gpt-4
openai/o1-pro
openai/gpt-4o-2024-05-13
openai/gpt-4o-2024-08-06
openai/gpt-4.1-mini
openai/o3-deep-research
openai/gpt-3.5-turbo
openai/text-embedding-3-large
openai/gpt-4-turbo
openai/o1-preview
openai/o3-mini
openai/codex-mini-latest
openai/gpt-5-nano
openai/gpt-5-codex
openai/gpt-4o
openai/gpt-4.1
openai/o4-mini
openai/o1
openai/gpt-5-mini
openai/o1-mini
openai/text-embedding-ada-002
openai/o3-pro
openai/gpt-4o-2024-11-20
openai/o3
openai/o4-mini-deep-research
openai/gpt-4o-mini
openai/gpt-5
openai/gpt-5-pro to list available models\n   Or check your config (opencode.json) provider/model names\n\n3) Missing provider (model-only input)\n   $ bun run ./src/index.ts run --model big-pickle "hi"\n   Error: Model not found: big-pickle/\n   Did you mean: opencode/big-pickle\n\n4) Correct model after suggestion\n   $ bun run ./src/index.ts run --model opencode/big-pickle "hi"\n   Hi! How can I help you with your opencode project today?\n\nNotes\n- Suggestions are hints only; behavior is unchanged (no auto-selection).\n- This runs locally as part of the CLI error path; performance impact is negligible (small in-memory scans).
2025-11-12 10:42:18 -05:00
Ian Maurer c1fa257a92 Merge branch 'dev' into fix/cli-clean-exit-on-model-errors 2025-11-11 17:18:23 -05:00
Ian Maurer 7fd81dd93e chore(cli): resolve merge conflict with dev in bootstrap; keep try/finally with explicit return 2025-11-11 17:16:47 -05:00
Ian Maurer d554e7aaef fix(cli): always dispose instance on error to prevent hanging; add friendly ProviderModelNotFoundError/InitError messages\n\n- Wrap bootstrap callback in try/finally to guarantee Instance.dispose()\n- Format provider/model errors into actionable guidance (opencode models, config)\n\nRepro: running opencode run --model typo/claude-haiku-4-5 prints stack and hangs until SIGINT due to lingering watchers.\nFix: disposing Instance tears down watchers/subscriptions, allowing process to exit.\n\nNotes: Prior attempt (#3083) explicitly exited; this approach addresses root cause without forcing exit and improves UX for common misconfigurations. 2025-11-11 17:13:55 -05:00
42 changed files with 1447 additions and 324 deletions
+12 -12
View File
@@ -41,7 +41,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.82",
"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.82",
"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.82",
"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.82",
"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.82",
"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.82",
"version": "1.0.85",
"bin": {
"opencode": "./bin/opencode",
},
@@ -251,7 +251,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.82",
"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.82",
"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.82",
"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.82",
"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.82",
"version": "1.0.85",
"dependencies": {
"zod": "catalog:",
},
@@ -335,7 +335,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.82",
"version": "1.0.85",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
Generated
+3 -3
View File
@@ -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": {
+1 -1
View File
@@ -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.82"
"version": "1.0.85"
},
"dependencies": {
"@ibm/plex": "6.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.82",
"version": "1.0.85",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.82",
"version": "1.0.85",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.82",
"version": "1.0.85",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.82",
"version": "1.0.85",
"description": "",
"type": "module",
"scripts": {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The AI coding agent built for the terminal"
version = "1.0.82"
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.82/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.82/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.82/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.82/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.82/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.82",
"version": "1.0.85",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.82",
"version": "1.0.85",
"name": "opencode",
"type": "module",
"private": true,
+22 -11
View File
@@ -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(() => <DialogProviderList />)
},
category: "System",
},
{
title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
value: "theme.switch_mode",
@@ -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>
)
@@ -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: "",
},
}
@@ -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 <span style={{ fg: theme.secondary }}>Free</span>
}
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<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 +37,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 +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" ? <Free /> : 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 (
<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 })
}}
/>
)
}
@@ -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<string, number> = {
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<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 />)
}}
/>
)
}
@@ -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,37 +763,151 @@ 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}>
<Loader />
<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>
</>
)
}
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 <text fg={theme.diffAdded}>{FRAMES[frame()]}</text>
}
@@ -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]
},
@@ -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)
}
})
@@ -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
},
@@ -147,6 +147,7 @@ export function Session() {
// Auto-navigate to whichever session currently needs permission input
createEffect(() => {
const currentSession = session()
if (!currentSession) return
const currentPermissions = permissions()
let targetID = currentPermissions.length > 0 ? currentSession.id : undefined
@@ -600,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
@@ -903,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()}>
@@ -1006,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} />
@@ -1309,7 +1311,10 @@ ToolRegistry.register<typeof WriteTool>({
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
@@ -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),
)
@@ -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>
@@ -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}
+14 -1
View File
@@ -1,18 +1,31 @@
import { ConfigMarkdown } from "@/config/markdown"
import { Config } from "../config/config"
import { MCP } from "../mcp"
import { Provider } from "../provider/provider"
import { UI } from "./ui"
export function FormatError(input: unknown) {
if (MCP.Failed.isInstance(input))
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
if (Provider.ModelNotFoundError.isInstance(input)) {
const { providerID, modelID, suggestions } = input.data
return [
`Model not found: ${providerID}/${modelID}`,
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`,
].join("\n")
}
if (Provider.InitError.isInstance(input)) {
return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.`
}
if (Config.JsonError.isInstance(input)) {
return (
`Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "")
)
}
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}`
+6
View File
@@ -480,6 +480,10 @@ export namespace Config {
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
enabled_providers: z
.array(z.string())
.optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: z
.string()
@@ -511,6 +515,8 @@ export namespace Config {
z.string(),
ModelsDev.Provider.partial()
.extend({
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
models: z.record(z.string(), ModelsDev.Model.partial()).optional(),
options: z
.object({
@@ -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")
+1 -1
View File
@@ -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 })
+143
View File
@@ -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({}))
}
+100 -6
View File
@@ -1,5 +1,5 @@
import z from "zod"
import path from "path"
import fuzzysort from "fuzzysort"
import { Config } from "../config/config"
import { mergeDeep, sortBy } from "remeda"
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
@@ -10,7 +10,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"
@@ -243,6 +242,15 @@ export namespace Provider {
const config = await Config.get()
const database = await ModelsDev.get()
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null
function isProviderAllowed(providerID: string): boolean {
if (enabled && !enabled.has(providerID)) return false
if (disabled.has(providerID)) return false
return true
}
const providers: {
[providerID: string]: {
source: Source
@@ -367,10 +375,10 @@ export namespace Provider {
}
parsed.models[modelID] = parsedModel
}
database[providerID] = parsed
}
const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? []))
// load env
for (const [providerID, provider] of Object.entries(database)) {
if (disabled.has(providerID)) continue
@@ -448,6 +456,12 @@ export namespace Provider {
}
for (const [providerID, provider] of Object.entries(providers)) {
if (!isProviderAllowed(providerID)) {
delete providers[providerID]
continue
}
const configProvider = config.provider?.[providerID]
const filteredModels = Object.fromEntries(
Object.entries(provider.info.models)
// Filter out blacklisted models
@@ -460,8 +474,18 @@ export namespace Provider {
([, model]) =>
((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) &&
model.status !== "deprecated",
),
)
// Filter by provider's whitelist/blacklist from config
.filter(([modelID]) => {
if (!configProvider) return true
return (
(!configProvider.blacklist || !configProvider.blacklist.includes(modelID)) &&
(!configProvider.whitelist || configProvider.whitelist.includes(modelID))
)
}),
)
provider.info.models = filteredModels
if (Object.keys(provider.info.models).length === 0) {
@@ -574,9 +598,78 @@ export namespace Provider {
})
const provider = s.providers[providerID]
if (!provider) throw new ModelNotFoundError({ providerID, modelID })
if (!provider) {
let suggestions: string[] = []
const normalize = (str: string) => str.toLowerCase().replace(/[^a-z0-9]/g, "")
const levenshtein = (a: string, b: string) => {
const m = a.length,
n = b.length
const dp = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0))
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost)
}
}
return dp[m][n]
}
if (!modelID || modelID.trim() === "") {
// Treat single-token input as an unqualified model; search across all providers' models.
const q = normalize(providerID)
const entries: { combo: string; norm: string }[] = []
for (const [pid, prov] of Object.entries(s.providers)) {
for (const mid of Object.keys(prov.info.models)) {
entries.push({ combo: pid + "/" + mid, norm: normalize(mid) })
}
}
const byNorm = fuzzysort.go(q, entries as any, { limit: 5, key: "norm" }).map((r: any) => r.obj.combo)
const combos = entries.map((e) => e.combo)
const byRaw = fuzzysort.go(providerID, combos, { limit: 5 }).map((r) => r.target)
let merged = Array.from(new Set([...byNorm, ...byRaw]))
if (merged.length === 0) {
// fallback to edit distance on normalized mid
const scored = entries
.map((e) => ({ combo: e.combo, d: levenshtein(q, e.norm) }))
.sort((a, b) => a.d - b.d)
.slice(0, 3)
.map((x) => x.combo)
merged = scored
}
suggestions = merged.slice(0, 3)
} else {
const pcands = Object.keys(s.providers)
const corpus = pcands.map((raw) => ({ raw, norm: normalize(raw) }))
const q = normalize(providerID)
const hits = fuzzysort.go(q, corpus as any, { limit: 5, key: "norm" })
let ranked = hits.map((r: any) => r.obj.raw)
if (ranked.length === 0) {
ranked = pcands
.map((p) => ({ p, d: levenshtein(q, normalize(p)) }))
.sort((a, b) => a.d - b.d)
.slice(0, 3)
.map((x) => x.p)
}
const providerSuggestions = ranked.map((r) => r + "/" + modelID)
suggestions = providerSuggestions
}
throw new ModelNotFoundError({ providerID, modelID, suggestions })
}
const info = provider.info.models[modelID]
if (!info) throw new ModelNotFoundError({ providerID, modelID })
if (!info) {
const candidates = Object.keys(provider.info.models)
// Normalize punctuation differences like '-' vs '.' by stripping non-alphanumerics
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
const corpus = candidates.map((raw) => ({ raw, norm: normalize(raw) }))
const query = normalize(modelID)
const results = fuzzysort.go(query, corpus as any, { limit: 5, key: "norm" })
const ranked = results.map((r) => ("obj" in r ? (r as any).obj.raw : (r as any).target)) as string[]
const fallback = fuzzysort.go(modelID, candidates, { limit: 5 }).map((r) => r.target)
const merged = Array.from(new Set([...ranked, ...fallback]))
const suggestions = merged.slice(0, 3).map((m) => providerID + "/" + m)
throw new ModelNotFoundError({ providerID, modelID, suggestions })
}
const sdk = await getSDK(provider.info, info)
try {
@@ -677,6 +770,7 @@ export namespace Provider {
z.object({
providerID: z.string(),
modelID: z.string(),
suggestions: z.array(z.string()).optional(),
}),
)
+1 -1
View File
@@ -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,
}
+154
View File
@@ -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({
@@ -1163,6 +1185,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({
+2 -1
View File
@@ -58,6 +58,7 @@ export namespace MessageV2 {
type: z.literal("text"),
text: z.string(),
synthetic: z.boolean().optional(),
ignored: z.boolean().optional(),
time: z
.object({
start: z.number(),
@@ -566,7 +567,7 @@ export namespace MessageV2 {
}
result.push(userMessage)
for (const part of msg.parts) {
if (part.type === "text")
if (part.type === "text" && !part.ignored)
userMessage.parts.push({
type: "text",
text: part.text,
+4
View File
@@ -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))
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.82",
"version": "1.0.85",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
@@ -24,4 +24,4 @@
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
}
+110 -108
View File
@@ -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
*/
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.82",
"version": "1.0.85",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
@@ -26,4 +26,4 @@
"publishConfig": {
"directory": "dist"
}
}
}
+87
View File
@@ -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 })
+159
View File
@@ -150,6 +150,7 @@ export type TextPart = {
type: "text"
text: string
synthetic?: boolean
ignored?: boolean
time?: {
start: number
end?: number
@@ -1015,6 +1016,10 @@ export type Config = {
* Disable providers that are loaded automatically
*/
disabled_providers?: Array<string>
/**
* When set, ONLY these providers will be enabled. All other providers will be ignored
*/
enabled_providers?: Array<string>
/**
* Model to use in the format of provider/model, eg anthropic/claude-2
*/
@@ -1096,6 +1101,8 @@ export type Config = {
}
}
}
whitelist?: Array<string>
blacklist?: Array<string>
options?: {
apiKey?: string
baseURL?: string
@@ -1233,6 +1240,7 @@ export type TextPartInput = {
type: "text"
text: string
synthetic?: boolean
ignored?: boolean
time?: {
start: number
end?: number
@@ -1331,6 +1339,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
@@ -1609,6 +1628,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
@@ -2482,6 +2519,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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.82",
"version": "1.0.85",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.82",
"version": "1.0.85",
"type": "module",
"exports": {
".": "./src/components/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.0.82",
"version": "1.0.85",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.82",
"version": "1.0.85",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.82",
"version": "1.0.85",
"publisher": "sst-dev",
"repository": {
"type": "git",