Compare commits

..

21 Commits

Author SHA1 Message Date
opencode 7c6b3f981e release: v1.1.14 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 Cherevatsky 65724b693b docs: fix scroll_speed default value (#7867) 2026-01-11 21:40:23 -06:00
Andrey Taranov 8b9a85b7e7 fix(mcp): support resource content type in MCP tool output (#7879) 2026-01-11 21:39:42 -06:00
Zeke Sikelianos 7cbec9a1a7 docs: fix typos in settings doc (#7892) 2026-01-11 21:20:19 -06:00
Prince Carlo Juguilon c4ba5961c8 chore: update GitHub stars count to 60K (#7899) 2026-01-11 21:19:59 -06:00
indeep99 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
Octane 0c0057a7de Fix: TUI single-line paste cursor position (#7277) 2026-01-12 00:50:27 +01:00
Dillon Mulroy fa79736b87 fix: check worktree for external_directory permission in subdirs (#7811) 2026-01-11 14:17:36 -06:00
Jacob Gillespie 2e0c2c9db7 chore(lander): fix spacing in desktop app banner (#7822) 2026-01-11 14:16:29 -06:00
Daniel Polito 3e9366487a feat(desktop): User Message Badges on Hover (#7835) 2026-01-11 14:14:26 -06:00
Daniel Polito 025ed04da0 feat(desktop): Image Preview support for Image Attachments (#7841) 2026-01-11 14:12:52 -06:00
Zeke Sikelianos 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 Langton a803cf8aee feat(tui): add mouse hover and click support to autocomplete (#7820) 2026-01-11 12:45:20 -05:00
King'ori Maina 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 Langton 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
46 changed files with 471 additions and 184 deletions
+1
View File
@@ -197,3 +197,4 @@
| 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
```
+15 -15
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.13",
"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.13",
"version": "1.1.14",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -99,7 +99,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -126,7 +126,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -150,7 +150,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -174,7 +174,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -232,7 +232,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -248,7 +248,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.13",
"version": "1.1.14",
"bin": {
"opencode": "./bin/opencode",
},
@@ -351,7 +351,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -371,7 +371,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.13",
"version": "1.1.14",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -382,7 +382,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -395,7 +395,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -434,7 +434,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"zod": "catalog:",
},
@@ -445,7 +445,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.13",
"version": "1.1.14",
"description": "",
"type": "module",
"exports": {
+5 -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"
@@ -1484,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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+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",
},
},
+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.13",
"version": "1.1.14",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.13",
"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.13",
"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.13",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+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.13",
"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.13"
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.13/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.13/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.13/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.13/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.13/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.13",
"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.13",
"version": "1.1.14",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -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,47 +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")
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()
},
})
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
@@ -915,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({
+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`)
}
+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()
+1 -1
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]
+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)
},
+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)
}
}
}
@@ -1,6 +1,5 @@
import path from "path"
import type { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
type Kind = "file" | "directory"
@@ -15,7 +14,7 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
if (options?.bypass) return
if (Filesystem.contains(Instance.directory, target)) return
if (Instance.containsPath(target)) return
const kind = options?.kind ?? "file"
const parentDir = kind === "directory" ? target : path.dirname(target)
@@ -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)
},
})
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"exports": {
@@ -40,10 +40,6 @@
border-color: var(--border-strong-base);
}
&[data-clickable="true"] {
cursor: pointer;
}
&[data-type="image"] {
width: 48px;
height: 48px;
@@ -327,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)
+31 -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;
}
@@ -5,6 +5,7 @@ import {
type PermissionRequest,
TextPart,
ToolPart,
UserMessage,
} from "@opencode-ai/sdk/v2/client"
import { useData } from "../context"
import { useDiffComponent } from "../context/diff"
@@ -373,6 +374,7 @@ export function SessionTurn(
diffLimit: diffInit,
status: rawStatus(),
duration: duration(),
userMessageHovered: false,
})
createEffect(
@@ -473,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()}>
@@ -492,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 */}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.1.13",
"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.13",
"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).
+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.13",
"version": "1.1.14",
"publisher": "sst-dev",
"repository": {
"type": "git",