Compare commits

..
55 Commits
Author SHA1 Message Date
opencode 7c6b3f981e release: v1.1.14
Update Nix Hashes / update (push) Has been cancelled
2026-01-12 05:08:25 +00:00
Aiden Cline a5b6c57a76 tweak: make the subagent header have clickable nav 2026-01-11 22:43:09 -06:00
Eyal CherevatskyandGitHub 65724b693b docs: fix scroll_speed default value (#7867) 2026-01-11 21:40:23 -06:00
Andrey TaranovandGitHub 8b9a85b7e7 fix(mcp): support resource content type in MCP tool output (#7879) 2026-01-11 21:39:42 -06:00
Zeke SikelianosandGitHub 7cbec9a1a7 docs: fix typos in settings doc (#7892) 2026-01-11 21:20:19 -06:00
Prince Carlo JuguilonandGitHub c4ba5961c8 chore: update GitHub stars count to 60K (#7899) 2026-01-11 21:19:59 -06:00
indeep99andGitHub 82b432349e feat(tui): add mouse hover and click support to questions tool (#7905) 2026-01-11 22:01:48 -05:00
Dax Raad 68ed664a3f tui: fix prompt ref initialization to prevent undefined reference errors 2026-01-11 19:40:16 -05:00
Dax Raad 3a30773874 tui: refactor event streaming to use SDK instead of manual RPC subscription 2026-01-11 18:53:39 -05:00
OctaneandGitHub 0c0057a7de Fix: TUI single-line paste cursor position (#7277) 2026-01-12 00:50:27 +01:00
Dillon MulroyandGitHub fa79736b87 fix: check worktree for external_directory permission in subdirs (#7811) 2026-01-11 14:17:36 -06:00
Jacob GillespieandGitHub 2e0c2c9db7 chore(lander): fix spacing in desktop app banner (#7822) 2026-01-11 14:16:29 -06:00
Daniel PolitoandGitHub 3e9366487a feat(desktop): User Message Badges on Hover (#7835) 2026-01-11 14:14:26 -06:00
Daniel PolitoandGitHub 025ed04da0 feat(desktop): Image Preview support for Image Attachments (#7841) 2026-01-11 14:12:52 -06:00
Zeke SikelianosandGitHub b81eca4ebc docs: fix typos on the providers page (#7829) 2026-01-11 13:04:04 -06:00
Aiden Cline 20c18689c0 bump copilot plugin version 2026-01-11 12:58:14 -06:00
Kit LangtonandGitHub a803cf8aee feat(tui): add mouse hover and click support to autocomplete (#7820) 2026-01-11 12:45:20 -05:00
King'ori MainaandGitHub c526e2d908 fix(tui): copy oauth url when no device code (#7812) 2026-01-11 10:27:17 -06:00
GitHub Action bdbbcd8a0c chore: generate 2026-01-11 16:12:14 +00:00
Kit LangtonandGitHub 43c2da24d0 fix(tui): slash command autocomplete highlighted row jumping (#7815) 2026-01-11 11:11:40 -05:00
GitHub Action 3205db9c16 ignore: update download stats 2026-01-11 2026-01-11 12:04:38 +00:00
opencode efbab087df release: v1.1.13
Update Nix Hashes / update (push) Has been cancelled
2026-01-11 07:10:41 +00:00
Mani SundararajanandGitHub 9280db3297 fix(tui): move props.ref to onMount (#7702) 2026-01-11 00:20:04 -06:00
Aiden Cline 0cc3c3bc78 tweak: ensure codex built in plugin is always available 2026-01-10 22:59:39 -06:00
Aiden Cline ee8b38ab26 fix: ensure /connect works for openai business plans too (was missing field) 2026-01-10 22:48:28 -06:00
44fa3d5392 feat(acp): track file modified (#7723)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-10 21:46:53 -06:00
Kit LangtonandGitHub a457828a67 fix(opencode): command palette mouse hover highlights wrong item (#7721) 2026-01-10 19:52:17 -05:00
SpoonandGitHub 4752c83155 feat: pass sessionID to chat.system.transform (#7718) 2026-01-10 18:21:51 -06:00
Dax Raad f94ee5ce90 core: extract external directory validation to shared utility to reduce code duplication across tools 2026-01-10 18:49:42 -05:00
Daniel PolitoandGitHub 76386f5cfc feat(desktop): Fork Session (#7673) 2026-01-10 16:04:01 -06:00
Daniel PolitoandGitHub a9275def43 feat(desktop): Make mouse hover / click work on prompt input autocomplete (#7661) 2026-01-10 16:02:47 -06:00
Daniel PolitoandGitHub 50ed4c6b5d feat(deskop): Add Copy to Messages (#7658) 2026-01-10 16:01:23 -06:00
Jérôme BenoitandGitHub f882cca98a fix(desktop): add missing StoreExt import and fix unused mut warning (#7707) 2026-01-10 15:26:24 -06:00
Lekë DobrunaandGitHub 2d2a044961 fix(desktop): build failing - missing import (#7697) 2026-01-10 15:24:15 -06:00
Frank b41fbda68f wip: black 2026-01-10 13:57:49 -05:00
Alejandro ChinchillaandGitHub 794c5981a5 fix: exclude 'none' variant for gpt-5.2-codex (#7682) 2026-01-10 12:22:44 -06:00
KennyandGitHub d5738f542c fix(grep): follow symlinks by default in ripgrep searches (#7501) 2026-01-10 10:57:18 -06:00
Andrew ThalandGitHub 02b7eb59f8 feat: support configuring default server URL for desktop (#7363) 2026-01-10 08:56:48 -06:00
GitHub Action a8f23fb548 chore: generate 2026-01-10 14:55:55 +00:00
OpeOginniandGitHub 1a642a79a6 fix(desktop): remove split operation in serverDisplayName function (#7397) 2026-01-10 08:55:20 -06:00
zerone0xandGitHub b6b0097755 fix(ui): allow text editing shortcuts in search dialogs on macOS (#7419) 2026-01-10 08:53:52 -06:00
Daniel PolitoandGitHub afb1cad26d fix(desktop): Allow Selecting Card Errors (#7506) 2026-01-10 08:52:11 -06:00
GitHub Action 58186004db chore: generate 2026-01-10 14:44:47 +00:00
madflowandGitHub a593ed4c9b fix: disable telemetry in the php lsp server (#7649) 2026-01-10 08:44:12 -06:00
opencode 449270aacc release: v1.1.12
Update Nix Hashes / update (push) Has been cancelled
2026-01-10 13:56:12 +00:00
GitHub Action e20535655b chore: generate 2026-01-10 13:50:58 +00:00
Frédéric DE MATOSandGitHub 1662e149b3 fix: add ChatGPT-Account-Id header for organization subscriptions (#7603) 2026-01-10 07:50:24 -06:00
Vlad TemianandGitHub dfe3e79304 docs(ecosystem): add micode and octto plugins (#7327) 2026-01-10 07:44:21 -06:00
GitHub Action e92a2ec9db ignore: update download stats 2026-01-10 2026-01-10 12:04:24 +00:00
Github Action 7cba1ff793 Update Nix flake.lock and hashes 2026-01-10 03:10:45 +00:00
Adam 8c3cc0d447 chore: prep 2026-01-09 21:09:26 -06:00
Adam e30562d5f4 chore: prep 2026-01-09 20:18:42 -06:00
GitHub Action 7c06ef2477 chore: generate 2026-01-10 02:07:24 +00:00
Adam 58eccf7f53 chore: prep 2026-01-09 19:53:04 -06:00
Adam 6da60bd5d9 fix(app): more defensive dom access 2026-01-09 19:53:03 -06:00
91 changed files with 2265 additions and 297 deletions
+1
View File
@@ -24,3 +24,4 @@ target
# Local dev files
opencode-dev
logs/
*.bun-build
+2
View File
@@ -196,3 +196,5 @@
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
| 2026-01-09 | 2,443,565 (+170,935) | 1,469,451 (+36,971) | 3,913,016 (+207,906) |
| 2026-01-10 | 2,632,023 (+188,458) | 1,503,670 (+34,219) | 4,135,693 (+222,677) |
| 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) |
+65 -2
View File
@@ -4,8 +4,71 @@
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
= obj` just reference it as obj.a and obj.b. this preserves context
- AVOID `try`/`catch` where possible
- AVOID `else` statements
- AVOID using `any` type
- AVOID `let` statements
- PREFER single word variable names where possible
- Use as many bun apis as possible like Bun.file()
# Avoid let statements
we don't like let statements, especially combined with if/else statements.
prefer const
This is bad:
Good:
```ts
const foo = condition ? 1 : 2
```
Bad:
```ts
let foo
if (condition) foo = 1
else foo = 2
```
# Avoid else statements
Prefer early returns or even using `iife` to avoid else statements
Good:
```ts
function foo() {
if (condition) return 1
return 2
}
```
Bad:
```ts
function foo() {
if (condition) return 1
else return 2
}
```
# Prefer single word naming
Try your best to find a single word name for your variables, functions, etc.
Only use multiple words if you cannot.
Good:
```ts
const foo = 1
const bar = 2
const baz = 3
```
Bad:
```ts
const fooBar = 1
const barBaz = 2
const bazFoo = 3
```
+16 -15
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.11",
"version": "1.1.14",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.11",
"version": "1.1.14",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -87,6 +87,7 @@
"chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"solid-list": "0.3.0",
"vite": "catalog:",
"zod": "catalog:",
},
@@ -98,7 +99,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.11",
"version": "1.1.14",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -125,7 +126,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.11",
"version": "1.1.14",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -149,7 +150,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.11",
"version": "1.1.14",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -173,7 +174,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.11",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -202,7 +203,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.11",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -231,7 +232,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.11",
"version": "1.1.14",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -247,7 +248,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.11",
"version": "1.1.14",
"bin": {
"opencode": "./bin/opencode",
},
@@ -350,7 +351,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.11",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -370,7 +371,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.11",
"version": "1.1.14",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -381,7 +382,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.11",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -394,7 +395,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.11",
"version": "1.1.14",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -433,7 +434,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.11",
"version": "1.1.14",
"dependencies": {
"zod": "catalog:",
},
@@ -444,7 +445,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.11",
"version": "1.1.14",
"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": 1767364772,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
"lastModified": 1767966113,
"narHash": "sha256-mSTsvXa4WveSRJexsmCbm9dY17B1fKp7NLpJxllpQw4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
"rev": "5f02c91314c8ba4afe83b256b023756412218535",
"type": "github"
},
"original": {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-+QM5BDFxzrm1HY5ealjCm7jIO1t/rpW1q4GGLViPMmA="
"nodeModules": "sha256-aIP+P0hSnQ50+lhnKvfRzQ8DlOhLjrrwarckxI3LBmE="
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.11",
"version": "1.1.14",
"description": "",
"type": "module",
"exports": {
+3 -2
View File
@@ -33,13 +33,14 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean }
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean; serverUrl?: string }
}
}
const defaultServerUrl = iife(() => {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
if (window.__OPENCODE__?.serverUrl) return window.__OPENCODE__.serverUrl
if (window.__OPENCODE__?.port) return `http://127.0.0.1:${window.__OPENCODE__.port}`
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
@@ -0,0 +1,99 @@
import { Component, createMemo } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode"
interface ForkableMessage {
id: string
text: string
time: string
}
function formatTime(date: Date): string {
return date.toLocaleTimeString(undefined, { timeStyle: "short" })
}
export const DialogFork: Component = () => {
const params = useParams()
const navigate = useNavigate()
const sync = useSync()
const sdk = useSDK()
const prompt = usePrompt()
const dialog = useDialog()
const messages = createMemo((): ForkableMessage[] => {
const sessionID = params.id
if (!sessionID) return []
const msgs = sync.data.message[sessionID] ?? []
const result: ForkableMessage[] = []
for (const message of msgs) {
if (message.role !== "user") continue
const parts = sync.data.part[message.id] ?? []
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
if (!textPart) continue
result.push({
id: message.id,
text: textPart.text.replace(/\n/g, " ").slice(0, 200),
time: formatTime(new Date(message.time.created)),
})
}
return result.reverse()
})
const handleSelect = (item: ForkableMessage | undefined) => {
if (!item) return
const sessionID = params.id
if (!sessionID) return
const parts = sync.data.part[item.id] ?? []
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
dialog.close()
sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
if (!forked.data) return
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
requestAnimationFrame(() => {
prompt.set(restored)
})
})
}
return (
<Dialog title="Fork from message">
<List
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
search={{ placeholder: "Search", autofocus: true }}
emptyMessage="No messages to fork from"
key={(x) => x.id}
items={messages}
filterKeys={["text"]}
onSelect={handleSelect}
>
{(item) => (
<div class="w-full flex items-center gap-2">
<span class="truncate flex-1 min-w-0 text-left" style={{ "font-weight": "400" }}>
{item.text}
</span>
<span class="text-text-weak shrink-0" style={{ "font-weight": "400" }}>
{item.time}
</span>
</div>
)}
</List>
</Dialog>
)
}
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -35,6 +35,8 @@ export function DialogSelectServer() {
error: "",
status: {} as Record<string, ServerStatus | undefined>,
})
const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.())
const isDesktop = platform.platform === "desktop"
const items = createMemo(() => {
const current = server.url
@@ -173,6 +175,53 @@ export function DialogSelectServer() {
</div>
</form>
</div>
<Show when={isDesktop}>
<div class="mt-6 px-3 flex flex-col gap-1.5">
<div class="px-3">
<h3 class="text-14-regular text-text-weak">Default server</h3>
<p class="text-12-regular text-text-weak mt-1">
Connect to this server on app launch instead of starting a local server. Requires restart.
</p>
</div>
<div class="flex items-center gap-2 px-3 py-2">
<Show
when={defaultUrl()}
fallback={
<Show
when={server.url}
fallback={<span class="text-14-regular text-text-weak">No server selected</span>}
>
<Button
variant="secondary"
size="small"
onClick={async () => {
await platform.setDefaultServerUrl?.(server.url)
defaultUrlActions.refetch(server.url)
}}
>
Set current server as default
</Button>
</Show>
}
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span>
</div>
<Button
variant="ghost"
size="small"
onClick={async () => {
await platform.setDefaultServerUrl?.(null)
defaultUrlActions.refetch()
}}
>
Clear
</Button>
</Show>
</div>
</div>
</Show>
</div>
</Dialog>
)
+10 -1
View File
@@ -38,6 +38,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
@@ -386,6 +387,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const {
flat: atFlat,
active: atActive,
setActive: setAtActive,
onInput: atOnInput,
onKeyDown: atOnKeyDown,
} = useFilteredList<AtOption>({
@@ -452,6 +454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const {
flat: slashFlat,
active: slashActive,
setActive: setSlashActive,
onInput: slashOnInput,
onKeyDown: slashOnKeyDown,
refetch: slashRefetch,
@@ -1299,6 +1302,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
onMouseDown={(e) => e.preventDefault()}
>
<Switch>
<Match when={store.popover === "at"}>
@@ -1314,6 +1318,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"bg-surface-raised-base-hover": atActive() === atKey(item),
}}
onClick={() => handleAtSelect(item)}
onMouseEnter={() => setAtActive(atKey(item))}
>
<Show
when={item.type === "agent"}
@@ -1360,6 +1365,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"bg-surface-raised-base-hover": slashActive() === cmd.id,
}}
onClick={() => handleSlashSelect(cmd)}
onMouseEnter={() => setSlashActive(cmd.id)}
>
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
@@ -1479,7 +1485,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<img
src={attachment.dataUrl}
alt={attachment.filename}
class="size-16 rounded-md object-cover border border-border-base"
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
onClick={() =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
/>
</Show>
<button
+6
View File
@@ -37,6 +37,12 @@ export type Platform = {
/** Fetch override */
fetch?: typeof fetch
/** Get the configured default server URL (desktop only) */
getDefaultServerUrl?(): Promise<string | null>
/** Set the default server URL to use on app startup (desktop only) */
setDefaultServerUrl?(url: string | null): Promise<void>
}
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
+1 -4
View File
@@ -16,10 +16,7 @@ export function normalizeServerUrl(input: string) {
export function serverDisplayName(url: string) {
if (!url) return ""
return url
.replace(/^https?:\/\//, "")
.replace(/\/+$/, "")
.split("/")[0]
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
}
function projectsKey(url: string) {
+10
View File
@@ -31,6 +31,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { DialogFork } from "@/components/dialog-fork"
import { useCommand } from "@/context/command"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
@@ -645,6 +646,15 @@ export default function Page() {
})
},
},
{
id: "session.fork",
title: "Fork from message",
description: "Create a new session from a previous message",
category: "Session",
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
},
])
const handleKeyDown = (event: KeyboardEvent) => {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.11",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
@@ -26,6 +26,7 @@
"chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"solid-list": "0.3.0",
"vite": "catalog:",
"zod": "catalog:"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+2 -2
View File
@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "50K",
full: "50,000",
compact: "60K",
full: "60,000",
},
},
@@ -0,0 +1,221 @@
[data-page="black"] {
background: #000;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: stretch;
font-family: var(--font-mono);
color: #fff;
[data-component="header-gradient"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 288px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
}
[data-component="header"] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 40px;
flex-shrink: 0;
/* [data-component="header-logo"] { */
/* } */
}
[data-component="content"] {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
flex-grow: 1;
[data-slot="hero-black"] {
margin-top: 110px;
@media (min-width: 768px) {
margin-top: 150px;
}
}
[data-slot="cta"] {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
text-align: center;
margin-top: -18px;
@media (min-width: 768px) {
margin-top: 40px;
}
[data-slot="heading"] {
color: rgba(255, 255, 255, 0.92);
text-align: center;
font-size: 18px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 28.8px */
span {
display: inline-block;
}
}
[data-slot="subheading"] {
color: rgba(255, 255, 255, 0.59);
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 160%;
@media (min-width: 768px) {
font-size: 18px;
line-height: 160%;
}
}
[data-slot="button"] {
display: inline-flex;
height: 40px;
padding: 0 12px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
text-decoration: none;
color: #000;
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
&:hover {
background: #e0e0e0;
}
&:active {
transform: scale(0.98);
}
}
[data-slot="back-soon"] {
color: rgba(255, 255, 255, 0.59);
text-align: center;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 20.8px */
}
[data-slot="follow-us"] {
display: inline-flex;
height: 40px;
padding: 0 12px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.59);
font-family: "JetBrains Mono Nerd Font";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="fine-print"] {
color: rgba(255, 255, 255, 0.39);
text-align: center;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 20.8px */
}
}
}
[data-component="footer"] {
display: flex;
flex-direction: column;
width: 100%;
justify-content: center;
align-items: center;
gap: 24px;
flex-shrink: 0;
@media (min-width: 768px) {
height: 120px;
}
[data-slot="footer-content"] {
display: flex;
gap: 24px;
align-items: center;
justify-content: center;
@media (min-width: 768px) {
gap: 40px;
}
span,
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="github-stars"] {
color: rgba(255, 255, 255, 0.25);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
[data-slot="anomaly"] {
display: none;
@media (min-width: 768px) {
display: block;
}
}
}
[data-slot="anomaly-alt"] {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
margin-bottom: 24px;
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
@media (min-width: 768px) {
display: none;
}
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,214 @@
[data-page="black"] {
background: #000;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: stretch;
font-family: var(--font-mono);
color: #fff;
[data-component="header-gradient"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 288px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
}
[data-component="header"] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 40px;
flex-shrink: 0;
/* [data-component="header-logo"] { */
/* } */
}
[data-component="content"] {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
flex-grow: 1;
[data-slot="hero-black"] {
margin-top: 110px;
@media (min-width: 768px) {
margin-top: 150px;
}
}
[data-slot="select-workspace"] {
display: flex;
margin-top: -24px;
width: 100%;
max-width: 480px;
height: 305px;
padding: 32px 20px 0 20px;
flex-direction: column;
align-items: flex-start;
gap: 24px;
border: 1px solid #303030;
background: #0a0a0a;
box-shadow:
0 100px 80px 0 rgba(0, 0, 0, 0.04),
0 41.778px 33.422px 0 rgba(0, 0, 0, 0.05),
0 22.336px 17.869px 0 rgba(0, 0, 0, 0.06),
0 12.522px 10.017px 0 rgba(0, 0, 0, 0.08),
0 6.65px 5.32px 0 rgba(0, 0, 0, 0.09),
0 2.767px 2.214px 0 rgba(0, 0, 0, 0.13);
[data-slot="select-workspace-title"] {
flex-shrink: 0;
align-self: stretch;
color: rgba(255, 255, 255, 0.59);
text-align: center;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 25.6px */
}
[data-slot="workspaces"] {
width: 100%;
padding: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
outline: none;
overflow-y: auto;
flex: 1;
min-height: 0;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
[data-slot="workspace"] {
width: 100%;
display: flex;
padding: 8px 12px;
align-items: center;
gap: 8px;
align-self: stretch;
cursor: pointer;
[data-slot="selected-icon"] {
visibility: hidden;
color: rgba(255, 255, 255, 0.39);
font-family: "IBM Plex Mono";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 25.6px */
}
a {
color: rgba(255, 255, 255, 0.92);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 25.6px */
text-decoration: none;
}
}
[data-slot="workspace"]:hover,
[data-slot="workspace"][data-active="true"] {
background: #161616;
[data-slot="selected-icon"] {
visibility: visible;
}
}
}
}
}
[data-component="footer"] {
display: flex;
flex-direction: column;
width: 100%;
justify-content: center;
align-items: center;
gap: 24px;
flex-shrink: 0;
@media (min-width: 768px) {
height: 120px;
}
[data-slot="footer-content"] {
display: flex;
gap: 24px;
align-items: center;
justify-content: center;
@media (min-width: 768px) {
gap: 40px;
}
span,
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="github-stars"] {
color: rgba(255, 255, 255, 0.25);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
[data-slot="anomaly"] {
display: none;
@media (min-width: 768px) {
display: block;
}
}
}
[data-slot="anomaly-alt"] {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
margin-bottom: 24px;
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
@media (min-width: 768px) {
display: none;
}
}
}
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -522,7 +522,7 @@ body {
[data-slot="content"] {
display: flex;
align-items: center;
gap: 4px;
gap: 1ch;
}
[data-slot="text"] {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.11",
"version": "1.1.14",
"private": true,
"type": "module",
"license": "MIT",
+6 -2
View File
@@ -41,8 +41,8 @@ if (identifier.startsWith("wrk_")) {
subscribed: SubscriptionTable.timeCreated,
})
.from(UserTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
.innerJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
.rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
.where(eq(UserTable.accountID, accountID))
.then((rows) =>
rows.map((row) => ({
@@ -113,6 +113,8 @@ async function printWorkspace(workspaceID: string) {
.select({
balance: BillingTable.balance,
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
subscriptionCouponID: BillingTable.subscriptionCouponID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspace.id))
@@ -149,6 +151,7 @@ async function printWorkspace(workspaceID: string) {
),
)
/*
await printTable("Usage", (tx) =>
tx
.select({
@@ -174,6 +177,7 @@ async function printWorkspace(workspaceID: string) {
})),
),
)
*/
}
function formatMicroCents(value: number | null | undefined) {
@@ -0,0 +1,78 @@
import { Billing } from "../src/billing.js"
import { and, Database, eq } from "../src/drizzle/index.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
const workspaceID = process.argv[2]
if (!workspaceID) {
console.error("Usage: bun remove-black.ts <workspaceID>")
process.exit(1)
}
console.log(`Removing subscription from workspace ${workspaceID}`)
// Look up the workspace billing
const billing = await Database.use((tx) =>
tx
.select({
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (!billing) {
console.error(`Error: No billing record found for workspace ${workspaceID}`)
process.exit(1)
}
if (!billing.subscriptionID) {
console.error(`Error: Workspace ${workspaceID} does not have a subscription`)
process.exit(1)
}
console.log(` Customer ID: ${billing.customerID}`)
console.log(` Subscription ID: ${billing.subscriptionID}`)
// Clear workspaceID from Stripe customer metadata
if (billing.customerID) {
//await Billing.stripe().customers.update(billing.customerID, {
// metadata: {
// workspaceID: "",
// },
//})
//console.log(`Cleared workspaceID from Stripe customer metadata`)
}
await Database.transaction(async (tx) => {
// Clear subscription-related fields from billing table
await tx
.update(BillingTable)
.set({
// customerID: null,
subscriptionID: null,
subscriptionCouponID: null,
// paymentMethodID: null,
// paymentMethodLast4: null,
// paymentMethodType: null,
})
.where(eq(BillingTable.workspaceID, workspaceID))
// Delete from subscription table
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
// Delete from payments table
await tx
.delete(PaymentTable)
.where(
and(
eq(PaymentTable.workspaceID, workspaceID),
eq(PaymentTable.enrichment, { type: "subscription" }),
eq(PaymentTable.amount, 20000000000),
),
)
})
console.log(`Successfully removed subscription from workspace ${workspaceID}`)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.11",
"version": "1.1.14",
"$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.1.11",
"version": "1.1.14",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.11",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+1
View File
@@ -2795,6 +2795,7 @@ dependencies = [
"futures",
"gtk",
"listeners",
"reqwest",
"semver",
"serde",
"serde_json",
+1
View File
@@ -38,6 +38,7 @@ listeners = "0.3"
tauri-plugin-os = "2"
futures = "0.3.31"
semver = "1.0.27"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
+161 -21
View File
@@ -13,12 +13,17 @@ use tauri::{
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
WebviewWindow,
};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
use tauri_plugin_store::StoreExt;
use tokio::net::TcpSocket;
use crate::window_customizer::PinchZoomDisablePlugin;
const SETTINGS_STORE: &str = "opencode.settings.dat";
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
#[derive(Clone)]
struct ServerState {
child: Arc<Mutex<Option<CommandChild>>>,
@@ -88,6 +93,41 @@ async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), Stri
.map_err(|_| "Failed to get server status".to_string())?
}
#[tauri::command]
async fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
let value = store.get(DEFAULT_SERVER_URL_KEY);
match value {
Some(v) => Ok(v.as_str().map(String::from)),
None => Ok(None),
}
}
#[tauri::command]
async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
match url {
Some(u) => {
store.set(DEFAULT_SERVER_URL_KEY, serde_json::Value::String(u));
}
None => {
store.delete(DEFAULT_SERVER_URL_KEY);
}
}
store
.save()
.map_err(|e| format!("Failed to save settings: {}", e))?;
Ok(())
}
fn get_sidecar_port() -> u32 {
option_env!("OPENCODE_PORT")
.map(|s| s.to_string())
@@ -193,6 +233,30 @@ async fn is_server_running(port: u32) -> bool {
.is_ok()
}
async fn check_server_health(url: &str) -> bool {
let health_url = format!("{}/health", url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build();
let Ok(client) = client else {
return false;
};
client
.get(&health_url)
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
fn get_configured_server_url(app: &AppHandle) -> Option<String> {
let store = app.store(SETTINGS_STORE).ok()?;
let value = store.get(DEFAULT_SERVER_URL_KEY)?;
value.as_str().map(String::from)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
@@ -219,7 +283,9 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
kill_sidecar,
install_cli,
ensure_server_started
ensure_server_started,
get_default_server_url,
set_default_server_url
])
.setup(move |app| {
let app = app.handle().clone();
@@ -236,6 +302,7 @@ pub fn run() {
.unwrap_or(LogicalSize::new(1920, 1080));
// Create window immediately with serverReady = false
#[allow(unused_mut)]
let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.title("OpenCode")
@@ -266,41 +333,114 @@ pub fn run() {
{
let app = app.clone();
tauri::async_runtime::spawn(async move {
let should_spawn_sidecar = !is_server_running(port).await;
// Check for configured default server URL
let configured_url = get_configured_server_url(&app);
let (child, res) = if should_spawn_sidecar {
let child = spawn_sidecar(&app, port);
let (child, res, server_url) = if let Some(ref url) = configured_url {
println!("Configured default server URL: {}", url);
let timestamp = Instant::now();
let res = loop {
if timestamp.elapsed() > Duration::from_secs(7) {
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
// Try to connect to the configured server
let mut healthy = false;
let mut should_fallback = false;
loop {
if check_server_health(url).await {
healthy = true;
println!("Connected to configured server: {}", url);
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
let res = app.dialog()
.message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
.title("Connection Failed")
.buttons(MessageDialogButtons::OkCancelCustom("Retry".to_string(), "Start Local".to_string()))
.blocking_show_with_result();
match res {
MessageDialogResult::Custom(name) if name == "Retry" => {
continue;
},
_ => {
should_fallback = true;
break;
}
}
}
if healthy {
(None, Ok(()), Some(url.clone()))
} else if should_fallback {
// Fall back to spawning local sidecar
let child = spawn_sidecar(&app, port);
let timestamp = Instant::now();
let res = loop {
if timestamp.elapsed() > Duration::from_secs(7) {
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
}
if is_server_running(port).await {
// give the server a little bit more time to warm up
tokio::time::sleep(Duration::from_millis(10)).await;
break Ok(());
}
if is_server_running(port).await {
tokio::time::sleep(Duration::from_millis(10)).await;
break Ok(());
}
};
println!("Server ready after {:?}", timestamp.elapsed());
(Some(child), res, None)
} else {
(None, Err("User cancelled".to_string()), None)
}
} else {
// No configured URL, spawn local sidecar as before
let should_spawn_sidecar = !is_server_running(port).await;
let (child, res) = if should_spawn_sidecar {
let child = spawn_sidecar(&app, port);
let timestamp = Instant::now();
let res = loop {
if timestamp.elapsed() > Duration::from_secs(7) {
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
}
tokio::time::sleep(Duration::from_millis(10)).await;
if is_server_running(port).await {
tokio::time::sleep(Duration::from_millis(10)).await;
break Ok(());
}
};
println!("Server ready after {:?}", timestamp.elapsed());
(Some(child), res)
} else {
(None, Ok(()))
};
println!("Server ready after {:?}", timestamp.elapsed());
(Some(child), res)
} else {
(None, Ok(()))
(child, res, None)
};
app.state::<ServerState>().set_child(child);
if res.is_ok() {
let _ = window.eval("window.__OPENCODE__.serverReady = true;");
// If using a configured server URL, inject it
if let Some(url) = server_url {
let escaped_url = url.replace('\\', "\\\\").replace('"', "\\\"");
let _ = window.eval(format!(
"window.__OPENCODE__.serverUrl = \"{escaped_url}\";",
));
}
}
let _ = tx.send(res);
+9
View File
@@ -257,6 +257,15 @@ const platform: Platform = {
// @ts-expect-error
fetch: tauriFetch,
getDefaultServerUrl: async () => {
const result = await invoke<string | null>("get_default_server_url").catch(() => null)
return result
},
setDefaultServerUrl: async (url: string | null) => {
await invoke("set_default_server_url", { url })
},
}
createMenu()
+2 -3
View File
@@ -57,11 +57,10 @@ Example: `mintlify`
Hex color codes for your global theme
<Expandable title="Colors">
<ResponseField name="primary" type="string" required>
The primary color. Used for most often for highlighted content, section headers, accents, in light mode
The primary color. Used most often for highlighted content, section headers, accents, in light mode
</ResponseField>
<ResponseField name="light" type="string">
The primary color for dark mode. Used for most often for highlighted content, section headers, accents, in dark
mode
The primary color for dark mode. Used most often for highlighted content, section headers, accents, in dark mode
</ResponseField>
<ResponseField name="dark" type="string">
The primary color for important buttons
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.11",
"version": "1.1.14",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.11"
version = "1.1.14"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.11/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.11/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.11/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.11/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.11/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.11",
"version": "1.1.14",
"$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.1.11",
"version": "1.1.14",
"name": "opencode",
"type": "module",
"license": "MIT",
+26
View File
@@ -30,6 +30,7 @@ import { Todo } from "@/session/todo"
import { z } from "zod"
import { LoadAPIKeyError } from "ai"
import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
export namespace ACP {
const log = Log.create({ service: "acp-agent" })
@@ -105,6 +106,22 @@ export namespace ACP {
})
return
}
if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
const metadata = permission.metadata || {}
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
const content = await Bun.file(filepath).text()
const newContent = getNewContent(content, diff)
if (newContent) {
this.connection.writeTextFile({
sessionId: sessionId,
path: filepath,
content: newContent,
})
}
}
await this.config.sdk.permission.reply({
requestID: permission.id,
reply: res.outcome.optionId as "once" | "always" | "reject",
@@ -1095,4 +1112,13 @@ export namespace ACP {
}
}
}
function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
const result = applyPatch(fileOriginal, unifiedDiff)
if (result === false) {
log.error("Failed to apply unified diff (context mismatch)")
return undefined
}
return result
}
}
+1
View File
@@ -12,6 +12,7 @@ export namespace Auth {
refresh: z.string(),
access: z.string(),
expires: z.number(),
accountId: z.string().optional(),
enterpriseUrl: z.string().optional(),
})
.meta({ ref: "OAuth" })
@@ -112,8 +112,7 @@ function AutoMethod(props: AutoMethodProps) {
useKeyboard((evt) => {
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
const code =
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4}/)?.[0] ?? props.authorization.instructions
const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4}/)?.[0] ?? props.authorization.url
Clipboard.copy(code)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
@@ -1,7 +1,7 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
import fuzzysort from "fuzzysort"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, For, Show, createSignal } from "solid-js"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
@@ -686,7 +686,7 @@ export function Autocomplete(props: {
height={height()}
scrollbarOptions={{ visible: false }}
>
<For
<Index
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
@@ -698,20 +698,22 @@ export function Autocomplete(props: {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index() === store.selected ? theme.primary : undefined}
backgroundColor={index === store.selected ? theme.primary : undefined}
flexDirection="row"
onMouseOver={() => moveTo(index)}
onMouseUp={() => select()}
>
<text fg={index() === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option.display}
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option().display}
</text>
<Show when={option.description}>
<text fg={index() === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
{option.description}
<Show when={option().description}>
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
{option().description}
</text>
</Show>
</box>
)}
</For>
</Index>
</scrollbox>
</box>
)
@@ -89,7 +89,7 @@ export function Prompt(props: PromptProps) {
const fileStyleId = syntax().getStyleId("extmark.file")!
const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId: number
let promptPartTypeId = 0
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
input.insertText(evt.properties.text)
@@ -310,15 +310,44 @@ export function Prompt(props: PromptProps) {
]
})
const ref: PromptRef = {
get focused() {
return input.focused
},
get current() {
return store.prompt
},
focus() {
input.focus()
},
blur() {
input.blur()
},
set(prompt) {
input.setText(prompt.input)
setStore("prompt", prompt)
restoreExtmarksFromParts(prompt.parts)
input.gotoBufferEnd()
},
reset() {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
},
submit() {
submit()
},
}
createEffect(() => {
if (props.visible !== false) input?.focus()
if (props.visible === false) input?.blur()
})
onMount(() => {
promptPartTypeId = input.extmarks.registerType("prompt-part")
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
@@ -452,39 +481,6 @@ export function Prompt(props: PromptProps) {
},
])
props.ref?.({
get focused() {
return input.focused
},
get current() {
return store.prompt
},
focus() {
input.focus()
},
blur() {
input.blur()
},
set(prompt) {
input.setText(prompt.input)
setStore("prompt", prompt)
restoreExtmarksFromParts(prompt.parts)
input.gotoBufferEnd()
},
reset() {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
},
submit() {
submit()
},
})
async function submit() {
if (props.disabled) return
if (autocomplete?.visible) return
@@ -916,12 +912,15 @@ export function Prompt(props: PromptProps) {
// Force layout update and render for the pasted content
setTimeout(() => {
input.getLayoutNode().markDirty()
input.gotoBufferEnd()
renderer.requestRender()
}, 0)
}}
ref={(r: TextareaRenderable) => {
input = r
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
}
props.ref?.(ref)
setTimeout(() => {
input.cursorColor = theme.text
}, 0)
@@ -1,10 +1,11 @@
import { type Accessor, createMemo, Match, Show, Switch } from "solid-js"
import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind"
const Title = (props: { session: Accessor<Session> }) => {
@@ -59,6 +60,8 @@ export function Header() {
const { theme } = useTheme()
const keybind = useKeybind()
const command = useCommandDialog()
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
return (
<box flexShrink={0}>
@@ -79,15 +82,36 @@ export function Header() {
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
</text>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
</box>
<box flexGrow={1} flexShrink={1} />
<ContextInfo context={context} cost={cost} />
</box>
@@ -86,6 +86,38 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
setStore("answers", answers)
}
function moveTo(index: number) {
setStore("selected", index)
}
function selectTab(index: number) {
setStore("tab", index)
setStore("selected", 0)
}
function selectOption() {
if (other()) {
if (!multi()) {
setStore("editing", true)
return
}
const value = input()
if (value && customPicked()) {
toggle(value)
return
}
setStore("editing", true)
return
}
const opt = options()[store.selected]
if (!opt) return
if (multi()) {
toggle(opt.label)
return
}
pick(opt.label)
}
const dialog = useDialog()
useKeyboard((evt) => {
@@ -149,16 +181,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
if (evt.name === "left" || evt.name === "h") {
evt.preventDefault()
const next = (store.tab - 1 + tabs()) % tabs()
setStore("tab", next)
setStore("selected", 0)
selectTab((store.tab - 1 + tabs()) % tabs())
}
if (evt.name === "right" || evt.name === "l") {
evt.preventDefault()
const next = (store.tab + 1) % tabs()
setStore("tab", next)
setStore("selected", 0)
selectTab((store.tab + 1) % tabs())
}
if (confirm()) {
@@ -176,36 +204,17 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
if (evt.name === "up" || evt.name === "k") {
evt.preventDefault()
setStore("selected", (store.selected - 1 + total) % total)
moveTo((store.selected - 1 + total) % total)
}
if (evt.name === "down" || evt.name === "j") {
evt.preventDefault()
setStore("selected", (store.selected + 1) % total)
moveTo((store.selected + 1) % total)
}
if (evt.name === "return") {
evt.preventDefault()
if (other()) {
if (!multi()) {
setStore("editing", true)
return
}
const value = input()
if (value && customPicked()) {
toggle(value)
return
}
setStore("editing", true)
return
}
const opt = opts[store.selected]
if (!opt) return
if (multi()) {
toggle(opt.label)
return
}
pick(opt.label)
selectOption()
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
@@ -236,6 +245,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
paddingLeft={1}
paddingRight={1}
backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
onMouseUp={() => selectTab(index())}
>
<text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}>
{q.header}
@@ -244,7 +254,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
)
}}
</For>
<box paddingLeft={1} paddingRight={1} backgroundColor={confirm() ? theme.accent : theme.backgroundElement}>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={confirm() ? theme.accent : theme.backgroundElement}
onMouseUp={() => selectTab(questions().length)}
>
<text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text>
</box>
</box>
@@ -264,7 +279,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const active = () => i() === store.selected
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<box>
<box onMouseOver={() => moveTo(i())} onMouseUp={() => selectOption()}>
<box flexDirection="row" gap={1}>
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
@@ -280,7 +295,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
)
}}
</For>
<box>
<box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
<box flexDirection="row" gap={1}>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
+3 -12
View File
@@ -34,15 +34,9 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
return fn as typeof fetch
}
function createEventSource(client: RpcClient, directory: string): EventSource {
function createEventSource(client: RpcClient): EventSource {
return {
on: (handler) =>
client.on<Event>("event", (event) => {
handler(event)
if (event.type === "server.instance.disposed") {
client.call("subscribe", { directory }).catch(() => {})
}
}),
on: (handler) => client.on<Event>("event", handler),
}
}
@@ -131,9 +125,6 @@ export const TuiThreadCommand = cmd({
networkOpts.port !== 0 ||
networkOpts.hostname !== "127.0.0.1"
// Subscribe to events from worker
await client.call("subscribe", { directory: cwd })
let url: string
let customFetch: typeof fetch | undefined
let events: EventSource | undefined
@@ -146,7 +137,7 @@ export const TuiThreadCommand = cmd({
// Use direct RPC communication (no HTTP)
url = "http://opencode.internal"
customFetch = createWorkerFetch(client)
events = createEventSource(client, cwd)
events = createEventSource(client)
}
const tuiPromise = tui({
@@ -255,7 +255,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.onSelect?.(option)
}}
onMouseOver={() => {
const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
}}
+57 -15
View File
@@ -6,8 +6,8 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
await Log.init({
@@ -38,6 +38,61 @@ GlobalBus.on("event", (event) => {
let server: Bun.Server<BunWebSocketData> | undefined
const eventStream = {
abort: undefined as AbortController | undefined,
}
const startEventStream = (directory: string) => {
if (eventStream.abort) eventStream.abort.abort()
const abort = new AbortController()
eventStream.abort = abort
const signal = abort.signal
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
return Server.App().fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
directory,
fetch: fetchFn,
signal,
})
;(async () => {
while (!signal.aborted) {
const events = await Promise.resolve(
sdk.event.subscribe(
{},
{
signal,
},
),
).catch(() => undefined)
if (!events) {
await Bun.sleep(250)
continue
}
for await (const event of events.stream) {
Rpc.emit("event", event as Event)
}
if (!signal.aborted) {
await Bun.sleep(250)
}
}
})().catch((error) => {
Log.Default.error("event stream error", {
error: error instanceof Error ? error.message : error,
})
})
}
startEventStream(process.cwd())
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const request = new Request(input.url, {
@@ -58,20 +113,6 @@ export const rpc = {
server = Server.listen(input)
return { url: server.url.toString() }
},
async subscribe(input: { directory: string }) {
return Instance.provide({
directory: input.directory,
init: InstanceBootstrap,
fn: async () => {
Bus.subscribeAll((event) => {
Rpc.emit("event", event)
})
// Emit connected event
Rpc.emit("event", { type: "server.connected", properties: {} })
return { subscribed: true }
},
})
},
async checkUpgrade(input: { directory: string }) {
await Instance.provide({
directory: input.directory,
@@ -87,6 +128,7 @@ export const rpc = {
},
async shutdown() {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
await Instance.disposeAll()
if (server) server.stop(true)
},
+2 -2
View File
@@ -279,7 +279,7 @@ export namespace File {
// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Filesystem.contains(Instance.directory, full)) {
if (!Instance.containsPath(full)) {
throw new Error(`Access denied: path escapes project directory`)
}
@@ -339,7 +339,7 @@ export namespace File {
// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Filesystem.contains(Instance.directory, resolved)) {
if (!Instance.containsPath(resolved)) {
throw new Error(`Access denied: path escapes project directory`)
}
+8 -1
View File
@@ -367,8 +367,15 @@ export namespace Ripgrep {
return lines.join("\n")
}
export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) {
export async function search(input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
}) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
if (input.follow !== false) args.push("--follow")
if (input.glob) {
for (const g of input.glob) {
+1 -1
View File
@@ -33,7 +33,7 @@ await Promise.all([
fs.mkdir(Global.Path.bin, { recursive: true }),
])
const CACHE_VERSION = "16"
const CACHE_VERSION = "17"
const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text()
+5 -1
View File
@@ -1528,7 +1528,11 @@ export namespace LSPServer {
})
return {
process: proc,
initialization: {},
initialization: {
telemetry: {
enabled: false,
},
},
}
},
}
+60 -12
View File
@@ -42,6 +42,46 @@ function generateState(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
}
export interface IdTokenClaims {
chatgpt_account_id?: string
organizations?: Array<{ id: string }>
email?: string
"https://api.openai.com/auth"?: {
chatgpt_account_id?: string
}
}
export function parseJwtClaims(token: string): IdTokenClaims | undefined {
const parts = token.split(".")
if (parts.length !== 3) return undefined
try {
return JSON.parse(Buffer.from(parts[1], "base64url").toString())
} catch {
return undefined
}
}
export function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
return (
claims.chatgpt_account_id ||
claims["https://api.openai.com/auth"]?.chatgpt_account_id ||
claims.organizations?.[0]?.id
)
}
export function extractAccountId(tokens: TokenResponse): string | undefined {
if (tokens.id_token) {
const claims = parseJwtClaims(tokens.id_token)
const accountId = claims && extractAccountIdFromClaims(claims)
if (accountId) return accountId
}
if (tokens.access_token) {
const claims = parseJwtClaims(tokens.access_token)
return claims ? extractAccountIdFromClaims(claims) : undefined
}
return undefined
}
function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
const params = new URLSearchParams({
response_type: "code",
@@ -380,10 +420,14 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
const currentAuth = await getAuth()
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
// Cast to include accountId field
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
// Check if token needs refresh
if (!currentAuth.access || currentAuth.expires < Date.now()) {
log.info("refreshing codex access token")
const tokens = await refreshAccessToken(currentAuth.refresh)
const newAccountId = extractAccountId(tokens) || authWithAccount.accountId
await input.client.auth.set({
path: { id: "codex" },
body: {
@@ -391,9 +435,11 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
...(newAccountId && { accountId: newAccountId }),
},
})
currentAuth.access = tokens.access_token
authWithAccount.accountId = newAccountId
}
// Build headers
@@ -415,20 +461,20 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
// Set authorization header with access token
headers.set("authorization", `Bearer ${currentAuth.access}`)
// Rewrite URL to Codex endpoint
let url: URL
if (typeof requestInput === "string") {
url = new URL(requestInput)
} else if (requestInput instanceof URL) {
url = requestInput
} else {
url = new URL(requestInput.url)
// Set ChatGPT-Account-Id header for organization subscriptions
if (authWithAccount.accountId) {
headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
}
// If this is a messages/responses request, redirect to Codex endpoint
if (url.pathname.includes("/v1/responses") || url.pathname.includes("/chat/completions")) {
url = new URL(CODEX_API_ENDPOINT)
}
// Rewrite URL to Codex endpoint
const parsed =
requestInput instanceof URL
? requestInput
: new URL(typeof requestInput === "string" ? requestInput : requestInput.url)
const url =
parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions")
? new URL(CODEX_API_ENDPOINT)
: parsed
return fetch(url, {
...init,
@@ -456,11 +502,13 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
callback: async () => {
const tokens = await callbackPromise
stopOAuthServer()
const accountId = extractAccountId(tokens)
return {
type: "success" as const,
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
accountId,
}
},
}
+5 -8
View File
@@ -12,7 +12,7 @@ import { CodexAuthPlugin } from "./codex"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
const BUILTIN = ["opencode-copilot-auth@0.0.11", "opencode-anthropic-auth@0.0.8"]
const BUILTIN = ["opencode-copilot-auth@0.0.12", "opencode-anthropic-auth@0.0.8"]
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
@@ -34,13 +34,10 @@ export namespace Plugin {
$: Bun.$,
}
// Load internal plugins first
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
for (const plugin of INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name })
const init = await plugin(input)
hooks.push(init)
}
for (const plugin of INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name })
const init = await plugin(input)
hooks.push(init)
}
const plugins = [...(config.plugin ?? [])]
+13
View File
@@ -4,6 +4,7 @@ import { Project } from "./project"
import { State } from "./state"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { Filesystem } from "@/util/filesystem"
interface Context {
directory: string
@@ -46,6 +47,18 @@ export const Instance = {
get project() {
return context.use().project
},
/**
* Check if a path is within the project boundary.
* Returns true if path is inside Instance.directory OR Instance.worktree.
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/
containsPath(filepath: string) {
if (Filesystem.contains(Instance.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
if (Instance.worktree === "/") return false
return Filesystem.contains(Instance.worktree, filepath)
},
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
return State.create(() => Instance.directory, init, dispose)
},
+6 -2
View File
@@ -99,12 +99,16 @@ export namespace ProviderAuth {
})
}
if ("refresh" in result) {
await Auth.set(input.providerID, {
const info: Auth.Info = {
type: "oauth",
access: result.access,
refresh: result.refresh,
expires: result.expires,
})
}
if (result.accountId) {
info.accountId = result.accountId
}
await Auth.set(input.providerID, info)
}
return
}
+4 -1
View File
@@ -318,7 +318,10 @@ export namespace ProviderTransform {
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai
if (id === "gpt-5-pro") return {}
const openaiEfforts = iife(() => {
if (id.includes("codex") && !id.includes("5.2")) return WIDELY_SUPPORTED_EFFORTS
if (id.includes("codex")) {
if (id.includes("5.2")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
return WIDELY_SUPPORTED_EFFORTS
}
const arr = [...WIDELY_SUPPORTED_EFFORTS]
if (id.includes("gpt-5-") || id === "gpt-5") {
arr.unshift("minimal")
+1 -1
View File
@@ -73,7 +73,7 @@ export namespace LLM {
const header = system[0]
const original = clone(system)
await Plugin.trigger("experimental.chat.system.transform", {}, { system })
await Plugin.trigger("experimental.chat.system.transform", { sessionID: input.sessionID }, { system })
if (system.length === 0) {
system.push(...original)
}
+16 -1
View File
@@ -777,8 +777,23 @@ export namespace SessionPrompt {
mime: contentItem.mimeType,
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
})
} else if (contentItem.type === "resource") {
const { resource } = contentItem
if (resource.text) {
textParts.push(resource.text)
}
if (resource.blob) {
attachments.push({
id: Identifier.ascending("part"),
sessionID: input.session.id,
messageID: input.processor.message.id,
type: "file",
mime: resource.mimeType ?? "application/octet-stream",
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
filename: resource.uri,
})
}
}
// Add support for other types if needed
}
return {
+2 -2
View File
@@ -85,7 +85,7 @@ export const BashTool = Tool.define("bash", async () => {
throw new Error("Failed to parse command")
}
const directories = new Set<string>()
if (!Filesystem.contains(Instance.directory, cwd)) directories.add(cwd)
if (!Instance.containsPath(cwd)) directories.add(cwd)
const patterns = new Set<string>()
const always = new Set<string>()
@@ -124,7 +124,7 @@ export const BashTool = Tool.define("bash", async () => {
process.platform === "win32" && resolved.match(/^\/[a-z]\//)
? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\")
: resolved
if (!Filesystem.contains(Instance.directory, normalized)) directories.add(normalized)
if (!Instance.containsPath(normalized)) directories.add(normalized)
}
}
}
+2 -12
View File
@@ -15,6 +15,7 @@ import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectory } from "./external-directory"
const MAX_DIAGNOSTICS_PER_FILE = 20
@@ -40,18 +41,7 @@ export const EditTool = Tool.define("edit", {
}
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
if (!Filesystem.contains(Instance.directory, filePath)) {
const parentDir = path.dirname(filePath)
await ctx.ask({
permission: "external_directory",
patterns: [parentDir, path.join(parentDir, "*")],
always: [parentDir + "/*"],
metadata: {
filepath: filePath,
parentDir,
},
})
}
await assertExternalDirectory(ctx, filePath)
let diff = ""
let contentOld = ""
@@ -0,0 +1,32 @@
import path from "path"
import type { Tool } from "./tool"
import { Instance } from "../project/instance"
type Kind = "file" | "directory"
type Options = {
bypass?: boolean
kind?: Kind
}
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
if (!target) return
if (options?.bypass) return
if (Instance.containsPath(target)) return
const kind = options?.kind ?? "file"
const parentDir = kind === "directory" ? target : path.dirname(target)
const glob = path.join(parentDir, "*")
await ctx.ask({
permission: "external_directory",
patterns: [glob],
always: [glob],
metadata: {
filepath: target,
parentDir,
},
})
}
+2
View File
@@ -4,6 +4,7 @@ import { Tool } from "./tool"
import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
export const GlobTool = Tool.define("glob", {
description: DESCRIPTION,
@@ -29,6 +30,7 @@ export const GlobTool = Tool.define("glob", {
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
await assertExternalDirectory(ctx, search, { kind: "directory" })
const limit = 100
const files = []
+6 -2
View File
@@ -4,6 +4,8 @@ import { Ripgrep } from "../file/ripgrep"
import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
import path from "path"
import { assertExternalDirectory } from "./external-directory"
const MAX_LINE_LENGTH = 2000
@@ -30,10 +32,12 @@ export const GrepTool = Tool.define("grep", {
},
})
const searchPath = params.path || Instance.directory
let searchPath = params.path ?? Instance.directory
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
const rgPath = await Ripgrep.filepath()
const args = ["-nH", "--field-match-separator=|", "--regexp", params.pattern]
const args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern]
if (params.include) {
args.push("--glob", params.include)
}
+2
View File
@@ -4,6 +4,7 @@ import * as path from "path"
import DESCRIPTION from "./ls.txt"
import { Instance } from "../project/instance"
import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectory } from "./external-directory"
export const IGNORE_PATTERNS = [
"node_modules/",
@@ -42,6 +43,7 @@ export const ListTool = Tool.define("list", {
}),
async execute(params, ctx) {
const searchPath = path.resolve(Instance.directory, params.path || ".")
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
await ctx.ask({
permission: "list",
+4 -2
View File
@@ -5,6 +5,7 @@ import { LSP } from "../lsp"
import DESCRIPTION from "./lsp.txt"
import { Instance } from "../project/instance"
import { pathToFileURL } from "url"
import { assertExternalDirectory } from "./external-directory"
const operations = [
"goToDefinition",
@@ -27,14 +28,15 @@ export const LspTool = Tool.define("lsp", {
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
}),
execute: async (args, ctx) => {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
await assertExternalDirectory(ctx, file)
await ctx.ask({
permission: "lsp",
patterns: ["*"],
always: ["*"],
metadata: {},
})
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
const uri = pathToFileURL(file).href
const position = {
file,
+6 -15
View File
@@ -7,8 +7,8 @@ import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance"
import { Patch } from "../patch"
import { Filesystem } from "../util/filesystem"
import { createTwoFilesPatch } from "diff"
import { assertExternalDirectory } from "./external-directory"
const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
@@ -49,19 +49,7 @@ export const PatchTool = Tool.define("patch", {
for (const hunk of hunks) {
const filePath = path.resolve(Instance.directory, hunk.path)
if (!Filesystem.contains(Instance.directory, filePath)) {
const parentDir = path.dirname(filePath)
await ctx.ask({
permission: "external_directory",
patterns: [parentDir, path.join(parentDir, "*")],
always: [parentDir + "/*"],
metadata: {
filepath: filePath,
parentDir,
},
})
}
await assertExternalDirectory(ctx, filePath)
switch (hunk.type) {
case "add":
@@ -103,12 +91,15 @@ export const PatchTool = Tool.define("patch", {
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
await assertExternalDirectory(ctx, movePath)
fileChanges.push({
filePath,
oldContent,
newContent,
type: hunk.move_path ? "move" : "update",
movePath: hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined,
movePath,
})
totalDiff += diff + "\n"
+4 -13
View File
@@ -5,9 +5,9 @@ import { Tool } from "./tool"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import { assertExternalDirectory } from "./external-directory"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@@ -27,18 +27,9 @@ export const ReadTool = Tool.define("read", {
}
const title = path.relative(Instance.worktree, filepath)
if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) {
const parentDir = path.dirname(filepath)
await ctx.ask({
permission: "external_directory",
patterns: [parentDir],
always: [parentDir + "/*"],
metadata: {
filepath,
parentDir,
},
})
}
await assertExternalDirectory(ctx, filepath, {
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
})
await ctx.ask({
permission: "read",
+2 -6
View File
@@ -10,6 +10,7 @@ import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { trimDiff } from "./edit"
import { assertExternalDirectory } from "./external-directory"
const MAX_DIAGNOSTICS_PER_FILE = 20
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
@@ -22,12 +23,7 @@ export const WriteTool = Tool.define("write", {
}),
async execute(params, ctx) {
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
/* TODO
if (!Filesystem.contains(Instance.directory, filepath)) {
const parentDir = path.dirname(filepath)
...
}
*/
await assertExternalDirectory(ctx, filepath)
const file = Bun.file(filepath)
const exists = await file.exists()
@@ -1,5 +1,6 @@
import { test, expect, describe } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../src/util/filesystem"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
@@ -113,3 +114,85 @@ describe("File.list path traversal protection", () => {
})
})
})
describe("Instance.containsPath", () => {
test("returns true for path inside directory", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: () => {
expect(Instance.containsPath(path.join(tmp.path, "foo.txt"))).toBe(true)
expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"))).toBe(true)
},
})
})
test("returns true for path inside worktree but outside directory (monorepo subdirectory scenario)", async () => {
await using tmp = await tmpdir({ git: true })
const subdir = path.join(tmp.path, "packages", "lib")
await fs.mkdir(subdir, { recursive: true })
await Instance.provide({
directory: subdir,
fn: () => {
// .opencode at worktree root, but we're running from packages/lib
expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"))).toBe(true)
// sibling package should also be accessible
expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"))).toBe(true)
// worktree root itself
expect(Instance.containsPath(tmp.path)).toBe(true)
},
})
})
test("returns false for path outside both directory and worktree", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: () => {
expect(Instance.containsPath("/etc/passwd")).toBe(false)
expect(Instance.containsPath("/tmp/other-project")).toBe(false)
},
})
})
test("returns false for path with .. escaping worktree", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: () => {
expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"))).toBe(false)
},
})
})
test("handles directory === worktree (running from repo root)", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: () => {
expect(Instance.directory).toBe(Instance.worktree)
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
expect(Instance.containsPath("/etc/passwd")).toBe(false)
},
})
})
test("non-git project does not allow arbitrary paths via worktree='/'", async () => {
await using tmp = await tmpdir() // no git: true
await Instance.provide({
directory: tmp.path,
fn: () => {
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
expect(Instance.containsPath("/etc/passwd")).toBe(false)
expect(Instance.containsPath("/tmp/other")).toBe(false)
},
})
})
})
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, test } from "bun:test"
import {
parseJwtClaims,
extractAccountIdFromClaims,
extractAccountId,
type IdTokenClaims,
} from "../../src/plugin/codex"
function createTestJwt(payload: object): string {
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
const body = Buffer.from(JSON.stringify(payload)).toString("base64url")
return `${header}.${body}.sig`
}
describe("plugin.codex", () => {
describe("parseJwtClaims", () => {
test("parses valid JWT with claims", () => {
const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" }
const jwt = createTestJwt(payload)
const claims = parseJwtClaims(jwt)
expect(claims).toEqual(payload)
})
test("returns undefined for JWT with less than 3 parts", () => {
expect(parseJwtClaims("invalid")).toBeUndefined()
expect(parseJwtClaims("only.two")).toBeUndefined()
})
test("returns undefined for invalid base64", () => {
expect(parseJwtClaims("a.!!!invalid!!!.b")).toBeUndefined()
})
test("returns undefined for invalid JSON payload", () => {
const header = Buffer.from("{}").toString("base64url")
const invalidJson = Buffer.from("not json").toString("base64url")
expect(parseJwtClaims(`${header}.${invalidJson}.sig`)).toBeUndefined()
})
})
describe("extractAccountIdFromClaims", () => {
test("extracts chatgpt_account_id from root", () => {
const claims: IdTokenClaims = { chatgpt_account_id: "acc-root" }
expect(extractAccountIdFromClaims(claims)).toBe("acc-root")
})
test("extracts chatgpt_account_id from nested https://api.openai.com/auth", () => {
const claims: IdTokenClaims = {
"https://api.openai.com/auth": { chatgpt_account_id: "acc-nested" },
}
expect(extractAccountIdFromClaims(claims)).toBe("acc-nested")
})
test("prefers root over nested", () => {
const claims: IdTokenClaims = {
chatgpt_account_id: "acc-root",
"https://api.openai.com/auth": { chatgpt_account_id: "acc-nested" },
}
expect(extractAccountIdFromClaims(claims)).toBe("acc-root")
})
test("extracts from organizations array as fallback", () => {
const claims: IdTokenClaims = {
organizations: [{ id: "org-123" }, { id: "org-456" }],
}
expect(extractAccountIdFromClaims(claims)).toBe("org-123")
})
test("returns undefined when no accountId found", () => {
const claims: IdTokenClaims = { email: "test@example.com" }
expect(extractAccountIdFromClaims(claims)).toBeUndefined()
})
})
describe("extractAccountId", () => {
test("extracts from id_token first", () => {
const idToken = createTestJwt({ chatgpt_account_id: "from-id-token" })
const accessToken = createTestJwt({ chatgpt_account_id: "from-access-token" })
expect(
extractAccountId({
id_token: idToken,
access_token: accessToken,
refresh_token: "rt",
}),
).toBe("from-id-token")
})
test("falls back to access_token when id_token has no accountId", () => {
const idToken = createTestJwt({ email: "test@example.com" })
const accessToken = createTestJwt({
"https://api.openai.com/auth": { chatgpt_account_id: "from-access" },
})
expect(
extractAccountId({
id_token: idToken,
access_token: accessToken,
refresh_token: "rt",
}),
).toBe("from-access")
})
test("returns undefined when no tokens have accountId", () => {
const token = createTestJwt({ email: "test@example.com" })
expect(
extractAccountId({
id_token: token,
access_token: token,
refresh_token: "rt",
}),
).toBeUndefined()
})
test("handles missing id_token", () => {
const accessToken = createTestJwt({ chatgpt_account_id: "acc-123" })
expect(
extractAccountId({
id_token: "",
access_token: accessToken,
refresh_token: "rt",
}),
).toBe("acc-123")
})
})
})
@@ -0,0 +1,126 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { assertExternalDirectory } from "../../src/tool/external-directory"
import type { PermissionNext } from "../../src/permission/next"
const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: "test",
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
}
describe("tool.assertExternalDirectory", () => {
test("no-ops for empty target", async () => {
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
await Instance.provide({
directory: "/tmp",
fn: async () => {
await assertExternalDirectory(ctx)
},
})
expect(requests.length).toBe(0)
})
test("no-ops for paths inside Instance.directory", async () => {
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
await Instance.provide({
directory: "/tmp/project",
fn: async () => {
await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt"))
},
})
expect(requests.length).toBe(0)
})
test("asks with a single canonical glob", async () => {
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
const directory = "/tmp/project"
const target = "/tmp/outside/file.txt"
const expected = path.join(path.dirname(target), "*")
await Instance.provide({
directory,
fn: async () => {
await assertExternalDirectory(ctx, target)
},
})
const req = requests.find((r) => r.permission === "external_directory")
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
})
test("uses target directory when kind=directory", async () => {
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
const directory = "/tmp/project"
const target = "/tmp/outside"
const expected = path.join(target, "*")
await Instance.provide({
directory,
fn: async () => {
await assertExternalDirectory(ctx, target, { kind: "directory" })
},
})
const req = requests.find((r) => r.permission === "external_directory")
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
})
test("skips prompting when bypass=true", async () => {
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
await Instance.provide({
directory: "/tmp/project",
fn: async () => {
await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true })
},
})
expect(requests.length).toBe(0)
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.11",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+3 -1
View File
@@ -114,6 +114,7 @@ export type AuthOuathResult = { url: string; instructions: string } & (
refresh: string
access: string
expires: number
accountId?: string
}
| { key: string }
))
@@ -133,6 +134,7 @@ export type AuthOuathResult = { url: string; instructions: string } & (
refresh: string
access: string
expires: number
accountId?: string
}
| { key: string }
))
@@ -193,7 +195,7 @@ export interface Hooks {
},
) => Promise<void>
"experimental.chat.system.transform"?: (
input: {},
input: { sessionID: string },
output: {
system: string[]
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.11",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+1
View File
@@ -2062,6 +2062,7 @@ export type OAuth = {
refresh: string
access: string
expires: number
accountId?: string
enterpriseUrl?: string
}
+3
View File
@@ -10463,6 +10463,9 @@
"expires": {
"type": "number"
},
"accountId": {
"type": "string"
},
"enterpriseUrl": {
"type": "string"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.11",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.11",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -67,7 +67,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
if (!props.current) return
const key = props.key(props.current)
requestAnimationFrame(() => {
const element = scrollRef()!.querySelector(`[data-key="${key}"]`)
const element = scrollRef()?.querySelector(`[data-key="${key}"]`)
element?.scrollIntoView({ block: "center" })
})
})
+15 -5
View File
@@ -40,10 +40,6 @@
border-color: var(--border-strong-base);
}
&[data-clickable="true"] {
cursor: pointer;
}
&[data-type="image"] {
width: 48px;
height: 48px;
@@ -76,12 +72,25 @@
}
[data-slot="user-message-text"] {
position: relative;
white-space: pre-wrap;
word-break: break-word;
overflow: hidden;
background: var(--surface-base);
padding: 8px 12px;
border-radius: 4px;
[data-slot="user-message-copy-wrapper"] {
position: absolute;
top: 7px;
right: 7px;
opacity: 0;
transition: opacity 0.15s ease;
}
&:hover [data-slot="user-message-copy-wrapper"] {
opacity: 1;
}
}
.text-text-strong {
@@ -387,7 +396,8 @@
[data-component="edit-content"],
[data-component="write-content"],
[data-component="todos"],
[data-component="diagnostics"] {
[data-component="diagnostics"],
.error-card {
-webkit-user-select: text;
user-select: text;
}
+16 -1
View File
@@ -38,6 +38,8 @@ import { Markdown } from "./markdown"
import { ImagePreview } from "./image-preview"
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { createAutoScroll } from "../hooks"
interface Diagnostic {
@@ -278,6 +280,7 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
const dialog = useDialog()
const [copied, setCopied] = createSignal(false)
const textPart = createMemo(
() => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
@@ -307,6 +310,14 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
dialog.show(() => <ImagePreview src={url} alt={alt} />)
}
const handleCopy = async () => {
const content = text()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div data-component="user-message">
<Show when={attachments().length > 0}>
@@ -316,7 +327,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
<div
data-slot="user-message-attachment"
data-type={file.mime.startsWith("image/") ? "image" : "file"}
data-clickable={file.mime.startsWith("image/") && !!file.url}
onClick={() => {
if (file.mime.startsWith("image/") && file.url) {
openImagePreview(file.url, file.filename)
@@ -341,6 +351,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
<Show when={text()}>
<div data-slot="user-message-text">
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
<div data-slot="user-message-copy-wrapper">
<Tooltip value={copied() ? "Copied!" : "Copy"} placement="top" gutter={8}>
<IconButton icon={copied() ? "check" : "copy"} variant="secondary" onClick={handleCopy} />
</Tooltip>
</div>
</div>
</Show>
</div>
+47 -1
View File
@@ -30,6 +30,35 @@
overflow-anchor: none;
}
[data-slot="session-turn-user-badges"] {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 6px;
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
&[data-visible="true"] {
opacity: 1;
pointer-events: auto;
}
}
[data-slot="session-turn-badge"] {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-family-mono);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-normal);
white-space: nowrap;
color: var(--text-base);
background: var(--surface-raised-base);
}
[data-slot="session-turn-sticky-title"] {
width: 100%;
position: sticky;
@@ -52,7 +81,8 @@
[data-slot="session-turn-message-header"] {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
gap: 12px;
align-self: stretch;
height: 32px;
}
@@ -225,6 +255,22 @@
}
}
[data-slot="session-turn-summary-section"] {
position: relative;
[data-slot="session-turn-summary-copy"] {
position: absolute;
top: 0;
right: 0;
opacity: 0;
transition: opacity 0.15s ease;
}
&:hover [data-slot="session-turn-summary-copy"] {
opacity: 1;
}
}
[data-slot="session-turn-accordion"] {
width: 100%;
}
+34 -1
View File
@@ -5,13 +5,14 @@ import {
type PermissionRequest,
TextPart,
ToolPart,
UserMessage,
} from "@opencode-ai/sdk/v2/client"
import { useData } from "../context"
import { useDiffComponent } from "../context/diff"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Binary } from "@opencode-ai/util/binary"
import { createEffect, createMemo, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DiffChanges } from "./diff-changes"
import { Typewriter } from "./typewriter"
@@ -21,6 +22,8 @@ import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { IconButton } from "./icon-button"
import { Tooltip } from "./tooltip"
import { Card } from "./card"
import { Dynamic } from "solid-js/web"
import { Button } from "./button"
@@ -328,6 +331,15 @@ export function SessionTurn(
const hasDiffs = createMemo(() => message()?.summary?.diffs?.length)
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
const [responseCopied, setResponseCopied] = createSignal(false)
const handleCopyResponse = async () => {
const content = response()
if (!content) return
await navigator.clipboard.writeText(content)
setResponseCopied(true)
setTimeout(() => setResponseCopied(false), 2000)
}
function duration() {
const msg = message()
if (!msg) return ""
@@ -362,6 +374,7 @@ export function SessionTurn(
diffLimit: diffInit,
status: rawStatus(),
duration: duration(),
userMessageHovered: false,
})
createEffect(
@@ -462,6 +475,8 @@ export function SessionTurn(
data-slot="session-turn-message-container"
class={props.classes?.container}
style={{ "--sticky-header-height": `${store.stickyHeaderHeight}px` }}
onMouseEnter={() => setStore("userMessageHovered", true)}
onMouseLeave={() => setStore("userMessageHovered", false)}
>
<Switch>
<Match when={isShellMode()}>
@@ -481,6 +496,15 @@ export function SessionTurn(
</Match>
</Switch>
</div>
<div data-slot="session-turn-user-badges" data-visible={store.userMessageHovered}>
<Show when={(msg() as UserMessage).agent}>
<span data-slot="session-turn-badge">{(msg() as UserMessage).agent}</span>
</Show>
<Show when={(msg() as UserMessage).model?.modelID}>
<span data-slot="session-turn-badge">{(msg() as UserMessage).model?.modelID}</span>
</Show>
<span data-slot="session-turn-badge">{(msg() as UserMessage).variant || "default"}</span>
</div>
</div>
</div>
{/* User Message */}
@@ -556,6 +580,15 @@ export function SessionTurn(
{/* Response */}
<Show when={!working() && (response() || hasDiffs())}>
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-copy">
<Tooltip value={responseCopied() ? "Copied!" : "Copy"} placement="top" gutter={8}>
<IconButton
icon={responseCopied() ? "check" : "copy"}
variant="secondary"
onClick={handleCopyResponse}
/>
</Tooltip>
</div>
<div data-slot="session-turn-summary-header">
<h2 data-slot="session-turn-summary-title">Response</h2>
<Markdown
@@ -82,6 +82,8 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
const selected = flat()[selectedIndex]
if (selected) props.onSelect?.(selected, selectedIndex)
} else {
// Skip list navigation for text editing shortcuts (e.g., Option+Arrow, Option+Backspace on macOS)
if (event.altKey || event.metaKey) return
list.onKeyDown(event)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.1.11",
"version": "1.1.14",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.1.11",
"version": "1.1.14",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -168,7 +168,7 @@ You can configure TUI-specific settings through the `tui` option.
Available options:
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.**
- `scroll_speed` - Custom scroll speed multiplier (default: `1`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
[Learn more about using the TUI here](/docs/tui).
@@ -40,6 +40,8 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing |
| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control |
| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax |
| [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity |
| [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms |
---
+3 -4
View File
@@ -6,7 +6,7 @@ description: Using any LLM provider in OpenCode.
import config from "../../../config.mjs"
export const console = config.console
OpenCode uses the [AI SDK](https://ai-sdk.dev/) and [Models.dev](https://models.dev) to support for **75+ LLM providers** and it supports running local models.
OpenCode uses the [AI SDK](https://ai-sdk.dev/) and [Models.dev](https://models.dev) to support **75+ LLM providers** and it supports running local models.
To add a provider you need to:
@@ -80,8 +80,7 @@ If you are new, we recommend starting with OpenCode Zen.
/models
```
It works like any other provider in OpenCode. And is completely optional to use
it.
It works like any other provider in OpenCode and is completely optional to use.
---
@@ -226,7 +225,7 @@ We recommend signing up for [Claude Pro](https://www.anthropic.com/news/claude-p
```
3. Now all the the Anthropic models should be available when you use the `/models` command.
3. Now all the Anthropic models should be available when you use the `/models` command.
```txt
/models
+1 -1
View File
@@ -358,7 +358,7 @@ You can customize TUI behavior through your OpenCode config file.
### Options
- `scroll_acceleration` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `1`). Defaults to `1` on Unix and `3` on Windows. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `1`). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
---
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.1.11",
"version": "1.1.14",
"publisher": "sst-dev",
"repository": {
"type": "git",