+
{language.t("settings.models.title")}
@@ -125,6 +131,6 @@ export const SettingsModels: Component = () => {
-
+
)
}
diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx
index dcc597139e..2460534c05 100644
--- a/packages/app/src/components/settings-providers.tsx
+++ b/packages/app/src/components/settings-providers.tsx
@@ -12,6 +12,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
+import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderMeta = { source?: ProviderSource }
@@ -115,7 +116,12 @@ export const SettingsProviders: Component = () => {
}
return (
-
+
)
}
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index 8ca05cdfeb..e3831e23c4 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/packages/app/src/i18n/ar.ts
@@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
"prompt.slash.badge.custom": "مخصص",
+ "prompt.slash.badge.skill": "مهارة",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "نشط",
"prompt.context.includeActiveFile": "تضمين الملف النشط",
"prompt.context.removeActiveFile": "إزالة الملف النشط من السياق",
@@ -432,6 +434,7 @@ export const dict = {
"session.review.noChanges": "لا توجد تغييرات",
"session.files.selectToOpen": "اختر ملفًا لفتحه",
"session.files.all": "كل الملفات",
+ "session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
"session.messages.renderEarlier": "عرض الرسائل السابقة",
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
"session.messages.loadEarlier": "تحميل الرسائل السابقة",
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts
index ad0772cd8b..f930a66aff 100644
--- a/packages/app/src/i18n/br.ts
+++ b/packages/app/src/i18n/br.ts
@@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Nenhum comando correspondente",
"prompt.dropzone.label": "Solte imagens ou PDFs aqui",
"prompt.slash.badge.custom": "personalizado",
+ "prompt.slash.badge.skill": "skill",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "ativo",
"prompt.context.includeActiveFile": "Incluir arquivo ativo",
"prompt.context.removeActiveFile": "Remover arquivo ativo do contexto",
@@ -433,6 +435,7 @@ export const dict = {
"session.review.noChanges": "Sem alterações",
"session.files.selectToOpen": "Selecione um arquivo para abrir",
"session.files.all": "Todos os arquivos",
+ "session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)",
"session.messages.renderEarlier": "Renderizar mensagens anteriores",
"session.messages.loadingEarlier": "Carregando mensagens anteriores...",
"session.messages.loadEarlier": "Carregar mensagens anteriores",
diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts
index 031d92d4b9..2b7d77456d 100644
--- a/packages/app/src/i18n/da.ts
+++ b/packages/app/src/i18n/da.ts
@@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Ingen matchende kommandoer",
"prompt.dropzone.label": "Slip billeder eller PDF'er her",
"prompt.slash.badge.custom": "brugerdefineret",
+ "prompt.slash.badge.skill": "skill",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "aktiv",
"prompt.context.includeActiveFile": "Inkluder aktiv fil",
"prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst",
@@ -434,6 +436,7 @@ export const dict = {
"session.review.noChanges": "Ingen ændringer",
"session.files.selectToOpen": "Vælg en fil at åbne",
"session.files.all": "Alle filer",
+ "session.files.binaryContent": "Binær fil (indhold kan ikke vises)",
"session.messages.renderEarlier": "Vis tidligere beskeder",
"session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
"session.messages.loadEarlier": "Indlæs tidligere beskeder",
diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts
index 9febfcff1e..4648ad9c41 100644
--- a/packages/app/src/i18n/de.ts
+++ b/packages/app/src/i18n/de.ts
@@ -214,6 +214,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Keine passenden Befehle",
"prompt.dropzone.label": "Bilder oder PDFs hier ablegen",
"prompt.slash.badge.custom": "benutzerdefiniert",
+ "prompt.slash.badge.skill": "skill",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "aktiv",
"prompt.context.includeActiveFile": "Aktive Datei einbeziehen",
"prompt.context.removeActiveFile": "Aktive Datei aus dem Kontext entfernen",
@@ -442,6 +444,7 @@ export const dict = {
"session.review.noChanges": "Keine Änderungen",
"session.files.selectToOpen": "Datei zum Öffnen auswählen",
"session.files.all": "Alle Dateien",
+ "session.files.binaryContent": "Binärdatei (Inhalt kann nicht angezeigt werden)",
"session.messages.renderEarlier": "Frühere Nachrichten rendern",
"session.messages.loadingEarlier": "Lade frühere Nachrichten...",
"session.messages.loadEarlier": "Frühere Nachrichten laden",
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index a6a50506a0..12ddcb4cd8 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -216,6 +216,8 @@ export const dict = {
"prompt.popover.emptyCommands": "No matching commands",
"prompt.dropzone.label": "Drop images or PDFs here",
"prompt.slash.badge.custom": "custom",
+ "prompt.slash.badge.skill": "skill",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "active",
"prompt.context.includeActiveFile": "Include active file",
"prompt.context.removeActiveFile": "Remove active file from context",
@@ -441,6 +443,7 @@ export const dict = {
"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",
+ "session.files.binaryContent": "Binary file (content cannot be displayed)",
"session.messages.renderEarlier": "Render earlier messages",
"session.messages.loadingEarlier": "Loading earlier messages...",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index ee75a143df..5d396f0b4f 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Sin comandos coincidentes",
"prompt.dropzone.label": "Suelta imágenes o PDFs aquí",
"prompt.slash.badge.custom": "personalizado",
+ "prompt.slash.badge.skill": "skill",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "activo",
"prompt.context.includeActiveFile": "Incluir archivo activo",
"prompt.context.removeActiveFile": "Eliminar archivo activo del contexto",
@@ -436,6 +438,7 @@ export const dict = {
"session.review.noChanges": "Sin cambios",
"session.files.selectToOpen": "Selecciona un archivo para abrir",
"session.files.all": "Todos los archivos",
+ "session.files.binaryContent": "Archivo binario (el contenido no puede ser mostrado)",
"session.messages.renderEarlier": "Renderizar mensajes anteriores",
"session.messages.loadingEarlier": "Cargando mensajes anteriores...",
"session.messages.loadEarlier": "Cargar mensajes anteriores",
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index f0652a9814..4226d0c7e2 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/packages/app/src/i18n/fr.ts
@@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Aucune commande correspondante",
"prompt.dropzone.label": "Déposez des images ou des PDF ici",
"prompt.slash.badge.custom": "personnalisé",
+ "prompt.slash.badge.skill": "skill",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "actif",
"prompt.context.includeActiveFile": "Inclure le fichier actif",
"prompt.context.removeActiveFile": "Retirer le fichier actif du contexte",
@@ -441,6 +443,7 @@ export const dict = {
"session.review.noChanges": "Aucune modification",
"session.files.selectToOpen": "Sélectionnez un fichier à ouvrir",
"session.files.all": "Tous les fichiers",
+ "session.files.binaryContent": "Fichier binaire (le contenu ne peut pas être affiché)",
"session.messages.renderEarlier": "Afficher les messages précédents",
"session.messages.loadingEarlier": "Chargement des messages précédents...",
"session.messages.loadEarlier": "Charger les messages précédents",
diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts
index ffe5368142..28a925a0d3 100644
--- a/packages/app/src/i18n/ja.ts
+++ b/packages/app/src/i18n/ja.ts
@@ -209,6 +209,8 @@ export const dict = {
"prompt.popover.emptyCommands": "一致するコマンドがありません",
"prompt.dropzone.label": "画像またはPDFをここにドロップ",
"prompt.slash.badge.custom": "カスタム",
+ "prompt.slash.badge.skill": "スキル",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "アクティブ",
"prompt.context.includeActiveFile": "アクティブなファイルを含める",
"prompt.context.removeActiveFile": "コンテキストからアクティブなファイルを削除",
@@ -433,6 +435,7 @@ export const dict = {
"session.review.noChanges": "変更なし",
"session.files.selectToOpen": "開くファイルを選択",
"session.files.all": "すべてのファイル",
+ "session.files.binaryContent": "バイナリファイル(内容を表示できません)",
"session.messages.renderEarlier": "以前のメッセージを表示",
"session.messages.loadingEarlier": "以前のメッセージを読み込み中...",
"session.messages.loadEarlier": "以前のメッセージを読み込む",
diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts
index 6c30e0123d..1be4e1eb4b 100644
--- a/packages/app/src/i18n/ko.ts
+++ b/packages/app/src/i18n/ko.ts
@@ -213,6 +213,8 @@ export const dict = {
"prompt.popover.emptyCommands": "일치하는 명령어 없음",
"prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요",
"prompt.slash.badge.custom": "사용자 지정",
+ "prompt.slash.badge.skill": "스킬",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "활성",
"prompt.context.includeActiveFile": "활성 파일 포함",
"prompt.context.removeActiveFile": "컨텍스트에서 활성 파일 제거",
@@ -435,6 +437,7 @@ export const dict = {
"session.review.noChanges": "변경 없음",
"session.files.selectToOpen": "열 파일을 선택하세요",
"session.files.all": "모든 파일",
+ "session.files.binaryContent": "바이너리 파일 (내용을 표시할 수 없음)",
"session.messages.renderEarlier": "이전 메시지 렌더링",
"session.messages.loadingEarlier": "이전 메시지 로드 중...",
"session.messages.loadEarlier": "이전 메시지 로드",
diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts
index 132c0b6c1f..0a3b398856 100644
--- a/packages/app/src/i18n/no.ts
+++ b/packages/app/src/i18n/no.ts
@@ -213,6 +213,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Ingen matchende kommandoer",
"prompt.dropzone.label": "Slipp bilder eller PDF-er her",
"prompt.slash.badge.custom": "egendefinert",
+ "prompt.slash.badge.skill": "skill",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "aktiv",
"prompt.context.includeActiveFile": "Inkluder aktiv fil",
"prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst",
@@ -436,6 +438,7 @@ export const dict = {
"session.review.noChanges": "Ingen endringer",
"session.files.selectToOpen": "Velg en fil å åpne",
"session.files.all": "Alle filer",
+ "session.files.binaryContent": "Binær fil (innhold kan ikke vises)",
"session.messages.renderEarlier": "Vis tidligere meldinger",
"session.messages.loadingEarlier": "Laster inn tidligere meldinger...",
"session.messages.loadEarlier": "Last inn tidligere meldinger",
diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts
index efed3eeb15..f4457c6acf 100644
--- a/packages/app/src/i18n/pl.ts
+++ b/packages/app/src/i18n/pl.ts
@@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Brak pasujących poleceń",
"prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj",
"prompt.slash.badge.custom": "własne",
+ "prompt.slash.badge.skill": "skill",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "aktywny",
"prompt.context.includeActiveFile": "Dołącz aktywny plik",
"prompt.context.removeActiveFile": "Usuń aktywny plik z kontekstu",
@@ -435,6 +437,7 @@ export const dict = {
"session.review.noChanges": "Brak zmian",
"session.files.selectToOpen": "Wybierz plik do otwarcia",
"session.files.all": "Wszystkie pliki",
+ "session.files.binaryContent": "Plik binarny (zawartość nie może być wyświetlona)",
"session.messages.renderEarlier": "Renderuj wcześniejsze wiadomości",
"session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...",
"session.messages.loadEarlier": "Załaduj wcześniejsze wiadomości",
diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts
index 0728c4a342..d5a4014d36 100644
--- a/packages/app/src/i18n/ru.ts
+++ b/packages/app/src/i18n/ru.ts
@@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Нет совпадающих команд",
"prompt.dropzone.label": "Перетащите изображения или PDF сюда",
"prompt.slash.badge.custom": "своё",
+ "prompt.slash.badge.skill": "навык",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "активно",
"prompt.context.includeActiveFile": "Включить активный файл",
"prompt.context.removeActiveFile": "Удалить активный файл из контекста",
@@ -437,6 +439,7 @@ export const dict = {
"session.review.noChanges": "Нет изменений",
"session.files.selectToOpen": "Выберите файл, чтобы открыть",
"session.files.all": "Все файлы",
+ "session.files.binaryContent": "Двоичный файл (содержимое не может быть отображено)",
"session.messages.renderEarlier": "Показать предыдущие сообщения",
"session.messages.loadingEarlier": "Загрузка предыдущих сообщений...",
"session.messages.loadEarlier": "Загрузить предыдущие сообщения",
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index 9ccb61ac76..1914b8e5bd 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -215,6 +215,8 @@ export const dict = {
"prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน",
"prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่",
"prompt.slash.badge.custom": "กำหนดเอง",
+ "prompt.slash.badge.skill": "skill",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "ใช้งานอยู่",
"prompt.context.includeActiveFile": "รวมไฟล์ที่ใช้งานอยู่",
"prompt.context.removeActiveFile": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท",
@@ -322,20 +324,20 @@ export const dict = {
"context.usage.clickToView": "คลิกเพื่อดูบริบท",
"context.usage.view": "ดูการใช้บริบท",
- "language.en": "อังกฤษ",
- "language.zh": "จีนตัวย่อ",
- "language.zht": "จีนตัวเต็ม",
- "language.ko": "เกาหลี",
- "language.de": "เยอรมัน",
- "language.es": "สเปน",
- "language.fr": "ฝรั่งเศส",
- "language.da": "เดนมาร์ก",
- "language.ja": "ญี่ปุ่น",
- "language.pl": "โปแลนด์",
- "language.ru": "รัสเซีย",
- "language.ar": "อาหรับ",
- "language.no": "นอร์เวย์",
- "language.br": "โปรตุเกส (บราซิล)",
+ "language.en": "English",
+ "language.zh": "简体中文",
+ "language.zht": "繁體中文",
+ "language.ko": "한국어",
+ "language.de": "Deutsch",
+ "language.es": "Español",
+ "language.fr": "Français",
+ "language.da": "Dansk",
+ "language.ja": "日本語",
+ "language.pl": "Polski",
+ "language.ru": "Русский",
+ "language.ar": "العربية",
+ "language.no": "Norsk",
+ "language.br": "Português (Brasil)",
"language.th": "ไทย",
"toast.language.title": "ภาษา",
@@ -438,6 +440,7 @@ export const dict = {
"session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด",
"session.files.all": "ไฟล์ทั้งหมด",
+ "session.files.binaryContent": "ไฟล์ไบนารี (ไม่สามารถแสดงเนื้อหาได้)",
"session.messages.renderEarlier": "แสดงข้อความก่อนหน้า",
"session.messages.loadingEarlier": "กำลังโหลดข้อความก่อนหน้า...",
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index 2266c109b0..b9d5395730 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/packages/app/src/i18n/zh.ts
@@ -214,6 +214,8 @@ export const dict = {
"prompt.popover.emptyCommands": "没有匹配的命令",
"prompt.dropzone.label": "将图片或 PDF 拖到这里",
"prompt.slash.badge.custom": "自定义",
+ "prompt.slash.badge.skill": "技能",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "当前",
"prompt.context.includeActiveFile": "包含当前文件",
"prompt.context.removeActiveFile": "从上下文移除活动文件",
@@ -434,6 +436,7 @@ export const dict = {
"session.review.noChanges": "无更改",
"session.files.selectToOpen": "选择要打开的文件",
"session.files.all": "所有文件",
+ "session.files.binaryContent": "二进制文件(无法显示内容)",
"session.messages.renderEarlier": "显示更早的消息",
"session.messages.loadingEarlier": "正在加载更早的消息...",
"session.messages.loadEarlier": "加载更早的消息",
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index 30837e56fb..23d3d80e13 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/packages/app/src/i18n/zht.ts
@@ -211,6 +211,8 @@ export const dict = {
"prompt.popover.emptyCommands": "沒有符合的命令",
"prompt.dropzone.label": "將圖片或 PDF 拖到這裡",
"prompt.slash.badge.custom": "自訂",
+ "prompt.slash.badge.skill": "技能",
+ "prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "作用中",
"prompt.context.includeActiveFile": "包含作用中檔案",
"prompt.context.removeActiveFile": "從上下文移除目前檔案",
@@ -431,6 +433,7 @@ export const dict = {
"session.review.noChanges": "沒有變更",
"session.files.selectToOpen": "選取要開啟的檔案",
"session.files.all": "所有檔案",
+ "session.files.binaryContent": "二進位檔案(無法顯示內容)",
"session.messages.renderEarlier": "顯示更早的訊息",
"session.messages.loadingEarlier": "正在載入更早的訊息...",
"session.messages.loadEarlier": "載入更早的訊息",
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index f049dc3bcc..845a4fc834 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -2114,12 +2114,20 @@ export default function Layout(props: ParentProps) {
>
-
+
+
{header()}
}
@@ -2146,6 +2154,8 @@ export default function Layout(props: ParentProps) {
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md"
+ data-action="workspace-menu"
+ data-workspace={base64Encode(props.directory)}
aria-label={language.t("common.moreOptions")}
/>
@@ -2592,6 +2602,8 @@ export default function Layout(props: ParentProps) {
{language.t("common.edit")}
{
const enabled = layout.sidebar.workspaces(p.worktree)()
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index b346fa6928..d3e74072a8 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -2342,6 +2342,7 @@ export default function Page() {
const c = state()?.content
return c?.mimeType === "image/svg+xml"
})
+ const isBinary = createMemo(() => state()?.content?.type === "binary")
const svgContent = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
@@ -2794,6 +2795,19 @@ export default function Page() {
+
+
+
+
+
+ {path()?.split("/").pop()}
+
+
+ {language.t("session.files.binaryContent")}
+
+
+
+
{renderCode(contents(), "pb-40")}
{language.t("common.loading")}...
diff --git a/packages/console/app/src/routes/zen/index.tsx b/packages/console/app/src/routes/zen/index.tsx
index 5708c238cd..accc8d67c9 100644
--- a/packages/console/app/src/routes/zen/index.tsx
+++ b/packages/console/app/src/routes/zen/index.tsx
@@ -18,7 +18,7 @@ import { Legal } from "~/component/legal"
import { Footer } from "~/component/footer"
import { Header } from "~/component/header"
import { getLastSeenWorkspaceID } from "../workspace/common"
-import { IconGemini, IconZai } from "~/component/icon"
+import { IconGemini, IconMiniMax, IconZai } from "~/component/icon"
const checkLoggedIn = query(async () => {
"use server"
@@ -98,14 +98,7 @@ export default function Home() {
Get started with Zen
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index 1d90a4c365..72e7f8985d 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -37,6 +37,7 @@ export namespace Agent {
providerID: z.string(),
})
.optional(),
+ variant: z.string().optional(),
prompt: z.string().optional(),
options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(),
@@ -214,6 +215,7 @@ export namespace Agent {
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
+ item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts
index bbaecfd8c7..34e2269d0c 100644
--- a/packages/opencode/src/cli/cmd/auth.ts
+++ b/packages/opencode/src/cli/cmd/auth.ts
@@ -307,7 +307,7 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(provider)) throw new UI.CancelledError()
- const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
+ const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
if (handled) return
@@ -323,7 +323,7 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(provider)) throw new UI.CancelledError()
// Check if a plugin provides auth for this custom provider
- const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
+ const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider)
if (handled) return
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx
new file mode 100644
index 0000000000..1ca109f232
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx
@@ -0,0 +1,34 @@
+import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
+import { createResource, createMemo } from "solid-js"
+import { useDialog } from "@tui/ui/dialog"
+import { useSDK } from "@tui/context/sdk"
+
+export type DialogSkillProps = {
+ onSelect: (skill: string) => void
+}
+
+export function DialogSkill(props: DialogSkillProps) {
+ const dialog = useDialog()
+ const sdk = useSDK()
+
+ const [skills] = createResource(async () => {
+ const result = await sdk.client.app.skills()
+ return result.data ?? []
+ })
+
+ const options = createMemo[]>(() => {
+ const list = skills() ?? []
+ return list.map((skill) => ({
+ title: skill.name,
+ description: skill.description,
+ value: skill.name,
+ category: "Skills",
+ onSelect: () => {
+ props.onSelect(skill.name)
+ dialog.clear()
+ },
+ }))
+ })
+
+ return
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
index bd000e2ab0..5f66dc822a 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
@@ -345,7 +345,8 @@ export function Autocomplete(props: {
const results: AutocompleteOption[] = [...command.slashes()]
for (const serverCommand of sync.data.command) {
- const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : ""
+ if (serverCommand.source === "skill") continue
+ const label = serverCommand.source === "mcp" ? ":mcp" : ""
results.push({
display: "/" + serverCommand.name + label,
description: serverCommand.description,
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index caa1303229..8576dd5763 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -31,6 +31,7 @@ import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
+import { DialogSkill } from "../dialog-skill"
export type PromptProps = {
sessionID?: string
@@ -315,6 +316,28 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = Bun.stringWidth(content)
},
},
+ {
+ title: "Skills",
+ value: "prompt.skills",
+ category: "Prompt",
+ slash: {
+ name: "skills",
+ },
+ onSelect: () => {
+ dialog.replace(() => (
+ {
+ input.setText(`/${skill} `)
+ setStore("prompt", {
+ input: `/${skill} `,
+ parts: [],
+ })
+ input.gotoBufferEnd()
+ }}
+ />
+ ))
+ },
+ },
]
})
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 693043450c..cbfeb67b2d 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -43,6 +43,7 @@ import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
+import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
@@ -1447,6 +1448,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
+
+
+
@@ -1636,7 +1640,9 @@ function Bash(props: ToolProps) {
>
$ {props.input.command}
- {limited()}
+
+ {limited()}
+
{expanded() ? "Click to collapse" : "Click to expand"}
@@ -1701,7 +1707,9 @@ function Glob(props: ToolProps) {
return (
Glob "{props.input.pattern}" in {normalizePath(props.input.path)}
- ({props.metadata.count} matches)
+
+ ({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
+
)
}
@@ -1737,7 +1745,9 @@ function Grep(props: ToolProps) {
return (
Grep "{props.input.pattern}" in {normalizePath(props.input.path)}
- ({props.metadata.matches} matches)
+
+ ({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
+
)
}
@@ -1795,7 +1805,7 @@ function Task(props: ToolProps) {
return (
-
+
) {
>
- {props.input.description} ({props.metadata.summary?.length} toolcalls)
+ {props.input.description} ({props.metadata.summary?.length ?? 0} toolcalls)
@@ -1816,22 +1826,17 @@ function Task(props: ToolProps) {
-
- {keybind.print("session_child_cycle")}
- view subagents
-
+
+
+ {keybind.print("session_child_cycle")}
+ view subagents
+
+
-
- {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "
- {props.input.description}"
+
+ {props.input.subagent_type} Task {props.input.description}
@@ -2036,6 +2041,14 @@ function Question(props: ToolProps) {
)
}
+function Skill(props: ToolProps) {
+ return (
+
+ Skill "{props.input.name}"
+
+ )
+}
+
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
index bd1de7d4de..56d8453c93 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
@@ -228,7 +228,7 @@ export function DialogSelect(props: DialogSelectProps) {
esc
-
+
{
batch(() => {
diff --git a/packages/opencode/src/cli/cmd/tui/util/transcript.ts b/packages/opencode/src/cli/cmd/tui/util/transcript.ts
index 8f986c3379..420c9dde1b 100644
--- a/packages/opencode/src/cli/cmd/tui/util/transcript.ts
+++ b/packages/opencode/src/cli/cmd/tui/util/transcript.ts
@@ -80,17 +80,17 @@ export function formatPart(part: Part, options: TranscriptOptions): string {
}
if (part.type === "tool") {
- let result = `\`\`\`\nTool: ${part.tool}\n`
+ let result = `**Tool: ${part.tool}**\n`
if (options.toolDetails && part.state.input) {
- result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
+ result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n`
}
if (options.toolDetails && part.state.status === "completed" && part.state.output) {
- result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
+ result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n`
}
if (options.toolDetails && part.state.status === "error" && part.state.error) {
- result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
+ result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n`
}
- result += `\n\`\`\`\n\n`
+ result += `\n`
return result
}
diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts
index 14dbeb6794..dce7ac8bbc 100644
--- a/packages/opencode/src/command/index.ts
+++ b/packages/opencode/src/command/index.ts
@@ -63,6 +63,7 @@ export namespace Command {
[Default.INIT]: {
name: Default.INIT,
description: "create/update AGENTS.md",
+ source: "command",
get template() {
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
},
@@ -71,6 +72,7 @@ export namespace Command {
[Default.REVIEW]: {
name: Default.REVIEW,
description: "review changes [commit|branch|pr], defaults to uncommitted",
+ source: "command",
get template() {
return PROMPT_REVIEW.replace("${path}", Instance.worktree)
},
@@ -85,6 +87,7 @@ export namespace Command {
agent: command.agent,
model: command.model,
description: command.description,
+ source: "command",
get template() {
return command.template
},
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 7969e30795..98970ba392 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -593,6 +593,10 @@ export namespace Config {
export const Agent = z
.object({
model: z.string().optional(),
+ variant: z
+ .string()
+ .optional()
+ .describe("Default model variant for this agent (applies only when using the agent's configured model)."),
temperature: z.number().optional(),
top_p: z.number().optional(),
prompt: z.string().optional(),
@@ -624,6 +628,7 @@ export namespace Config {
const knownKeys = new Set([
"name",
"model",
+ "variant",
"prompt",
"description",
"temperature",
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index dfa6356a27..32465015e9 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -44,7 +44,7 @@ export namespace File {
export const Content = z
.object({
- type: z.literal("text"),
+ type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
@@ -73,6 +73,174 @@ export namespace File {
})
export type Content = z.infer
+ const binaryExtensions = new Set([
+ "exe",
+ "dll",
+ "pdb",
+ "bin",
+ "so",
+ "dylib",
+ "o",
+ "a",
+ "lib",
+ "wav",
+ "mp3",
+ "ogg",
+ "oga",
+ "ogv",
+ "ogx",
+ "flac",
+ "aac",
+ "wma",
+ "m4a",
+ "weba",
+ "mp4",
+ "avi",
+ "mov",
+ "wmv",
+ "flv",
+ "webm",
+ "mkv",
+ "zip",
+ "tar",
+ "gz",
+ "gzip",
+ "bz",
+ "bz2",
+ "bzip",
+ "bzip2",
+ "7z",
+ "rar",
+ "xz",
+ "lz",
+ "z",
+ "pdf",
+ "doc",
+ "docx",
+ "ppt",
+ "pptx",
+ "xls",
+ "xlsx",
+ "dmg",
+ "iso",
+ "img",
+ "vmdk",
+ "ttf",
+ "otf",
+ "woff",
+ "woff2",
+ "eot",
+ "sqlite",
+ "db",
+ "mdb",
+ "apk",
+ "ipa",
+ "aab",
+ "xapk",
+ "app",
+ "pkg",
+ "deb",
+ "rpm",
+ "snap",
+ "flatpak",
+ "appimage",
+ "msi",
+ "msp",
+ "jar",
+ "war",
+ "ear",
+ "class",
+ "kotlin_module",
+ "dex",
+ "vdex",
+ "odex",
+ "oat",
+ "art",
+ "wasm",
+ "wat",
+ "bc",
+ "ll",
+ "s",
+ "ko",
+ "sys",
+ "drv",
+ "efi",
+ "rom",
+ "com",
+ "bat",
+ "cmd",
+ "ps1",
+ "sh",
+ "bash",
+ "zsh",
+ "fish",
+ ])
+
+ const imageExtensions = new Set([
+ "png",
+ "jpg",
+ "jpeg",
+ "gif",
+ "bmp",
+ "webp",
+ "ico",
+ "tif",
+ "tiff",
+ "svg",
+ "svgz",
+ "avif",
+ "apng",
+ "jxl",
+ "heic",
+ "heif",
+ "raw",
+ "cr2",
+ "nef",
+ "arw",
+ "dng",
+ "orf",
+ "raf",
+ "pef",
+ "x3f",
+ ])
+
+ function isImageByExtension(filepath: string): boolean {
+ const ext = path.extname(filepath).toLowerCase().slice(1)
+ return imageExtensions.has(ext)
+ }
+
+ function getImageMimeType(filepath: string): string {
+ const ext = path.extname(filepath).toLowerCase().slice(1)
+ const mimeTypes: Record = {
+ png: "image/png",
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ gif: "image/gif",
+ bmp: "image/bmp",
+ webp: "image/webp",
+ ico: "image/x-icon",
+ tif: "image/tiff",
+ tiff: "image/tiff",
+ svg: "image/svg+xml",
+ svgz: "image/svg+xml",
+ avif: "image/avif",
+ apng: "image/apng",
+ jxl: "image/jxl",
+ heic: "image/heic",
+ heif: "image/heif",
+ }
+ return mimeTypes[ext] || "image/" + ext
+ }
+
+ function isBinaryByExtension(filepath: string): boolean {
+ const ext = path.extname(filepath).toLowerCase().slice(1)
+ return binaryExtensions.has(ext)
+ }
+
+ function isImage(mimeType: string): boolean {
+ return mimeType.startsWith("image/")
+ }
+
async function shouldEncode(file: BunFile): Promise {
const type = file.type?.toLowerCase()
log.info("shouldEncode", { type })
@@ -83,30 +251,10 @@ export namespace File {
const parts = type.split("/", 2)
const top = parts[0]
- const rest = parts[1] ?? ""
- const sub = rest.split(";", 1)[0]
const tops = ["image", "audio", "video", "font", "model", "multipart"]
if (tops.includes(top)) return true
- const bins = [
- "zip",
- "gzip",
- "bzip",
- "compressed",
- "binary",
- "pdf",
- "msword",
- "powerpoint",
- "excel",
- "ogg",
- "exe",
- "dmg",
- "iso",
- "rar",
- ]
- if (bins.some((mark) => sub.includes(mark))) return true
-
return false
}
@@ -287,6 +435,22 @@ export namespace File {
throw new Error(`Access denied: path escapes project directory`)
}
+ // Fast path: check extension before any filesystem operations
+ if (isImageByExtension(file)) {
+ const bunFile = Bun.file(full)
+ if (await bunFile.exists()) {
+ const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
+ const content = Buffer.from(buffer).toString("base64")
+ const mimeType = getImageMimeType(file)
+ return { type: "text", content, mimeType, encoding: "base64" }
+ }
+ return { type: "text", content: "" }
+ }
+
+ if (isBinaryByExtension(file)) {
+ return { type: "binary", content: "" }
+ }
+
const bunFile = Bun.file(full)
if (!(await bunFile.exists())) {
@@ -294,11 +458,15 @@ export namespace File {
}
const encode = await shouldEncode(bunFile)
+ const mimeType = bunFile.type || "application/octet-stream"
+
+ if (encode && !isImage(mimeType)) {
+ return { type: "binary", content: "", mimeType }
+ }
if (encode) {
const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
const content = Buffer.from(buffer).toString("base64")
- const mimeType = bunFile.type || "application/octet-stream"
return { type: "text", content, mimeType, encoding: "base64" }
}
diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts
index dd94cc6097..463a9fb362 100644
--- a/packages/opencode/src/file/ripgrep.ts
+++ b/packages/opencode/src/file/ripgrep.ts
@@ -215,7 +215,7 @@ export namespace Ripgrep {
const args = [await filepath(), "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
- if (input.hidden) args.push("--hidden")
+ if (input.hidden !== false) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
diff --git a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts
index 642d7145fe..d6f7cb34bb 100644
--- a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts
+++ b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts
@@ -100,7 +100,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
break
}
case "reasoning": {
- reasoningText = part.text
+ if (part.text) reasoningText = part.text
break
}
case "tool-call": {
@@ -122,7 +122,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
role: "assistant",
content: text || null,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
- reasoning_text: reasoningText,
+ reasoning_text: reasoningOpaque ? reasoningText : undefined,
reasoning_opaque: reasoningOpaque,
...metadata,
})
diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts
index 94641e640e..c85d3f3d17 100644
--- a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts
+++ b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts
@@ -219,7 +219,13 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
// text content:
const text = choice.message.content
if (text != null && text.length > 0) {
- content.push({ type: "text", text })
+ content.push({
+ type: "text",
+ text,
+ providerMetadata: choice.message.reasoning_opaque
+ ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
+ : undefined,
+ })
}
// reasoning content (Copilot uses reasoning_text):
@@ -243,6 +249,9 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments!,
+ providerMetadata: choice.message.reasoning_opaque
+ ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
+ : undefined,
})
}
}
@@ -478,7 +487,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
}
if (!isActiveText) {
- controller.enqueue({ type: "text-start", id: "txt-0" })
+ controller.enqueue({
+ type: "text-start",
+ id: "txt-0",
+ providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
+ })
isActiveText = true
}
@@ -559,6 +572,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments,
+ providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
})
toolCall.hasFinished = true
}
@@ -601,6 +615,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments,
+ providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
})
toolCall.hasFinished = true
}
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index f5fe419db9..ded416e66d 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -179,7 +179,7 @@ export namespace ProviderTransform {
cacheControl: { type: "ephemeral" },
},
bedrock: {
- cachePoint: { type: "ephemeral" },
+ cachePoint: { type: "default" },
},
openaiCompatible: {
cache_control: { type: "ephemeral" },
@@ -190,7 +190,8 @@ export namespace ProviderTransform {
}
for (const msg of unique([...system, ...final])) {
- const shouldUseContentOptions = providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0
+ const useMessageLevelOptions = providerID === "anthropic" || providerID.includes("bedrock")
+ const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
if (shouldUseContentOptions) {
const lastContent = msg.content[msg.content.length - 1]
@@ -394,31 +395,6 @@ export namespace ProviderTransform {
case "@ai-sdk/deepinfra":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra
case "@ai-sdk/openai-compatible":
- // When using openai-compatible SDK with Claude/Anthropic models,
- // we must use snake_case (budget_tokens) as the SDK doesn't convert parameter names
- // and the OpenAI-compatible API spec uses snake_case
- if (
- model.providerID === "anthropic" ||
- model.api.id.includes("anthropic") ||
- model.api.id.includes("claude") ||
- model.id.includes("anthropic") ||
- model.id.includes("claude")
- ) {
- return {
- high: {
- thinking: {
- type: "enabled",
- budget_tokens: 16000,
- },
- },
- max: {
- thinking: {
- type: "enabled",
- budget_tokens: 31999,
- },
- },
- }
- }
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
case "@ai-sdk/azure":
@@ -718,21 +694,9 @@ export namespace ProviderTransform {
const modelCap = modelLimit || globalLimit
const standardLimit = Math.min(modelCap, globalLimit)
- // Handle thinking mode for @ai-sdk/anthropic, @ai-sdk/google-vertex/anthropic (budgetTokens)
- // and @ai-sdk/openai-compatible with Claude (budget_tokens)
- if (
- npm === "@ai-sdk/anthropic" ||
- npm === "@ai-sdk/google-vertex/anthropic" ||
- npm === "@ai-sdk/openai-compatible"
- ) {
+ if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
const thinking = options?.["thinking"]
- // Support both camelCase (for @ai-sdk/anthropic) and snake_case (for openai-compatible)
- const budgetTokens =
- typeof thinking?.["budgetTokens"] === "number"
- ? thinking["budgetTokens"]
- : typeof thinking?.["budget_tokens"] === "number"
- ? thinking["budget_tokens"]
- : 0
+ const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0
const enabled = thinking?.["type"] === "enabled"
if (enabled && budgetTokens > 0) {
// Return text tokens so that text + thinking <= model cap, preferring 32k text when possible.
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index b2cd7246a5..020bca1964 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -185,12 +185,15 @@ export namespace Server {
},
)
.use(async (c, next) => {
- let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
- try {
- directory = decodeURIComponent(directory)
- } catch {
- // fallback to original value
- }
+ if (c.req.path === "/log") return next()
+ const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
+ const directory = (() => {
+ try {
+ return decodeURIComponent(raw)
+ } catch {
+ return raw
+ }
+ })()
return Instance.provide({
directory,
init: InstanceBootstrap,
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index e87e4e4e44..34b0e57b3a 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -505,17 +505,23 @@ export namespace Session {
export function* list() {
const project = Instance.project
- const rows = Database.use((db) =>
- db.select().from(SessionTable).where(eq(SessionTable.project_id, project.id)).all(),
- )
- for (const row of rows) {
- yield fromRow(row)
+ for (const item of await Storage.list(["session", project.id])) {
+ const session = await Storage.read(item).catch(() => undefined)
+ if (!session) continue
+ yield session
}
}
export const children = fn(Identifier.schema("session"), async (parentID) => {
- const rows = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.parent_id, parentID)).all())
- return rows.map((row) => fromRow(row))
+ const project = Instance.project
+ const result = [] as Session.Info[]
+ for (const item of await Storage.list(["session", project.id])) {
+ const session = await Storage.read(item).catch(() => undefined)
+ if (!session) continue
+ if (session.parentID !== parentID) continue
+ result.push(session)
+ }
+ return result
})
export const remove = fn(Identifier.schema("session"), async (sessionID) => {
diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts
index 723439a3fd..65ca1e9bb2 100644
--- a/packages/opencode/src/session/instruction.ts
+++ b/packages/opencode/src/session/instruction.ts
@@ -75,7 +75,9 @@ export namespace InstructionPrompt {
for (const file of FILES) {
const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
if (matches.length > 0) {
- matches.forEach((p) => paths.add(path.resolve(p)))
+ matches.forEach((p) => {
+ paths.add(path.resolve(p))
+ })
break
}
}
@@ -103,7 +105,9 @@ export namespace InstructionPrompt {
}),
).catch(() => [])
: await resolveRelative(instruction)
- matches.forEach((p) => paths.add(path.resolve(p)))
+ matches.forEach((p) => {
+ paths.add(path.resolve(p))
+ })
}
}
@@ -168,12 +172,14 @@ export namespace InstructionPrompt {
const already = loaded(messages)
const results: { filepath: string; content: string }[] = []
- let current = path.dirname(path.resolve(filepath))
+ const target = path.resolve(filepath)
+ let current = path.dirname(target)
const root = path.resolve(Instance.directory)
- while (current.startsWith(root)) {
+ while (current.startsWith(root) && current !== root) {
const found = await find(current)
- if (found && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
+
+ if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
claim(messageID, found)
const content = await Bun.file(found)
.text()
@@ -182,7 +188,6 @@ export namespace InstructionPrompt {
results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
}
}
- if (current === root) break
current = path.dirname(current)
}
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index befa46fe4a..4be6e2538f 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -233,19 +233,12 @@ export namespace LLM {
},
maxRetries: input.retries ?? 0,
messages: [
- ...(isCodex
- ? [
- {
- role: "user",
- content: system.join("\n\n"),
- } as ModelMessage,
- ]
- : system.map(
- (x): ModelMessage => ({
- role: "system",
- content: x,
- }),
- )),
+ ...system.map(
+ (x): ModelMessage => ({
+ role: "system",
+ content: x,
+ }),
+ ),
...input.messages,
],
model: wrapLanguageModel({
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 826d0842cc..e533ca0283 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -180,6 +180,14 @@ export namespace SessionProcessor {
case "tool-result": {
const match = toolcalls[value.toolCallId]
if (match && match.state.status === "running") {
+ const attachments = value.output.attachments?.map(
+ (attachment: Omit) => ({
+ ...attachment,
+ id: Identifier.ascending("part"),
+ messageID: match.messageID,
+ sessionID: match.sessionID,
+ }),
+ )
await Session.updatePart({
...match,
state: {
@@ -192,7 +200,7 @@ export namespace SessionProcessor {
start: match.state.time.start,
end: Date.now(),
},
- attachments: value.output.attachments,
+ attachments,
},
})
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index f050c43e97..222cff8242 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -185,13 +185,17 @@ export namespace SessionPrompt {
text: template,
},
]
- const files = ConfigMarkdown.files(template)
+ const matches = ConfigMarkdown.files(template)
const seen = new Set()
- await Promise.all(
- files.map(async (match) => {
- const name = match[1]
- if (seen.has(name)) return
+ const names = matches
+ .map((match) => match[1])
+ .filter((name) => {
+ if (seen.has(name)) return false
seen.add(name)
+ return true
+ })
+ const resolved = await Promise.all(
+ names.map(async (name) => {
const filepath = name.startsWith("~/")
? path.join(os.homedir(), name.slice(2))
: path.resolve(Instance.worktree, name)
@@ -199,33 +203,34 @@ export namespace SessionPrompt {
const stats = await fs.stat(filepath).catch(() => undefined)
if (!stats) {
const agent = await Agent.get(name)
- if (agent) {
- parts.push({
- type: "agent",
- name: agent.name,
- })
- }
- return
+ if (!agent) return undefined
+ return {
+ type: "agent",
+ name: agent.name,
+ } satisfies PromptInput["parts"][number]
}
if (stats.isDirectory()) {
- parts.push({
+ return {
type: "file",
url: `file://${filepath}`,
filename: name,
mime: "application/x-directory",
- })
- return
+ } satisfies PromptInput["parts"][number]
}
- parts.push({
+ return {
type: "file",
url: `file://${filepath}`,
filename: name,
mime: "text/plain",
- })
+ } satisfies PromptInput["parts"][number]
}),
)
+ for (const item of resolved) {
+ if (!item) continue
+ parts.push(item)
+ }
return parts
}
@@ -422,6 +427,12 @@ export namespace SessionPrompt {
assistantMessage.time.completed = Date.now()
await Session.updateMessage(assistantMessage)
if (result && part.state.status === "running") {
+ const attachments = result.attachments?.map((attachment) => ({
+ ...attachment,
+ id: Identifier.ascending("part"),
+ messageID: assistantMessage.id,
+ sessionID: assistantMessage.sessionID,
+ }))
await Session.updatePart({
...part,
state: {
@@ -430,7 +441,7 @@ export namespace SessionPrompt {
title: result.title,
metadata: result.metadata,
output: result.output,
- attachments: result.attachments,
+ attachments,
time: {
...part.state.time,
end: Date.now(),
@@ -769,16 +780,13 @@ export namespace SessionPrompt {
)
const textParts: string[] = []
- const attachments: MessageV2.FilePart[] = []
+ const attachments: Omit[] = []
for (const contentItem of result.content) {
if (contentItem.type === "text") {
textParts.push(contentItem.text)
} else if (contentItem.type === "image") {
attachments.push({
- id: Identifier.ascending("part"),
- sessionID: input.session.id,
- messageID: input.processor.message.id,
type: "file",
mime: contentItem.mimeType,
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
@@ -790,9 +798,6 @@ export namespace SessionPrompt {
}
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}`,
@@ -825,6 +830,17 @@ export namespace SessionPrompt {
async function createUserMessage(input: PromptInput) {
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
+
+ const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
+ const variant =
+ input.variant ??
+ (agent.variant &&
+ agent.model &&
+ model.providerID === agent.model.providerID &&
+ model.modelID === agent.model.modelID
+ ? agent.variant
+ : undefined)
+
const info: MessageV2.Info = {
id: input.messageID ?? Identifier.ascending("message"),
role: "user",
@@ -834,9 +850,9 @@ export namespace SessionPrompt {
},
tools: input.tools,
agent: agent.name,
- model: input.model ?? agent.model ?? (await lastModel(input.sessionID)),
+ model,
system: input.system,
- variant: input.variant,
+ variant,
}
using _ = defer(() => InstructionPrompt.clear(info.id))
@@ -1030,6 +1046,7 @@ export namespace SessionPrompt {
pieces.push(
...result.attachments.map((attachment) => ({
...attachment,
+ id: Identifier.ascending("part"),
synthetic: true,
filename: attachment.filename ?? part.filename,
messageID: info.id,
@@ -1167,7 +1184,18 @@ export namespace SessionPrompt {
},
]
}),
- ).then((x) => x.flat())
+ )
+ .then((x) => x.flat())
+ .then((drafts) =>
+ drafts.map(
+ (part): MessageV2.Part => ({
+ ...part,
+ id: Identifier.ascending("part"),
+ messageID: info.id,
+ sessionID: input.sessionID,
+ }),
+ ),
+ )
await Plugin.trigger(
"chat.message",
diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts
index ba34eb48f5..b5c3ad0a12 100644
--- a/packages/opencode/src/tool/batch.ts
+++ b/packages/opencode/src/tool/batch.ts
@@ -77,6 +77,12 @@ export const BatchTool = Tool.define("batch", async () => {
})
const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
+ const attachments = result.attachments?.map((attachment) => ({
+ ...attachment,
+ id: Identifier.ascending("part"),
+ messageID: ctx.messageID,
+ sessionID: ctx.sessionID,
+ }))
await Session.updatePart({
id: partID,
@@ -91,7 +97,7 @@ export const BatchTool = Tool.define("batch", async () => {
output: result.output,
title: result.title,
metadata: result.metadata,
- attachments: result.attachments,
+ attachments,
time: {
start: callStartTime,
end: Date.now(),
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index f230cdf44c..13236d44dd 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -6,7 +6,6 @@ import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance"
-import { Identifier } from "../id/id"
import { assertExternalDirectory } from "./external-directory"
import { InstructionPrompt } from "../session/instruction"
@@ -79,9 +78,6 @@ export const ReadTool = Tool.define("read", {
},
attachments: [
{
- id: Identifier.ascending("part"),
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
type: "file",
mime,
url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`,
diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts
index 3d17ea192d..0e78ba665c 100644
--- a/packages/opencode/src/tool/tool.ts
+++ b/packages/opencode/src/tool/tool.ts
@@ -36,7 +36,7 @@ export namespace Tool {
title: string
metadata: M
output: string
- attachments?: MessageV2.FilePart[]
+ attachments?: Omit[]
}>
formatValidationError?(error: z.ZodError): string
}>
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index e4c72264cf..24f5dd7c3e 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -220,6 +220,13 @@ export namespace Worktree {
return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
}
+ async function canonical(input: string) {
+ const abs = path.resolve(input)
+ const real = await fs.realpath(abs).catch(() => abs)
+ const normalized = path.normalize(real)
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized
+ }
+
async function candidate(root: string, base?: string) {
for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
@@ -376,7 +383,7 @@ export namespace Worktree {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
- const directory = path.resolve(input.directory)
+ const directory = await canonical(input.directory)
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
@@ -399,7 +406,13 @@ export namespace Worktree {
return acc
}, [])
- const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
+ const entry = await (async () => {
+ for (const item of entries) {
+ if (!item.path) continue
+ const key = await canonical(item.path)
+ if (key === directory) return item
+ }
+ })()
if (!entry?.path) {
throw new RemoveFailedError({ message: "Worktree not found" })
}
@@ -425,8 +438,9 @@ export namespace Worktree {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
- const directory = path.resolve(input.directory)
- if (directory === path.resolve(Instance.worktree)) {
+ const directory = await canonical(input.directory)
+ const primary = await canonical(Instance.worktree)
+ if (directory === primary) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
@@ -452,7 +466,13 @@ export namespace Worktree {
return acc
}, [])
- const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
+ const entry = await (async () => {
+ for (const item of entries) {
+ if (!item.path) continue
+ const key = await canonical(item.path)
+ if (key === directory) return item
+ }
+ })()
if (!entry?.path) {
throw new ResetFailedError({ message: "Worktree not found" })
}
diff --git a/packages/opencode/test/cli/tui/transcript.test.ts b/packages/opencode/test/cli/tui/transcript.test.ts
index 2cb29e1a89..7a5fa6b8f1 100644
--- a/packages/opencode/test/cli/tui/transcript.test.ts
+++ b/packages/opencode/test/cli/tui/transcript.test.ts
@@ -119,13 +119,38 @@ describe("transcript", () => {
},
}
const result = formatPart(part, options)
- expect(result).toContain("Tool: bash")
+ expect(result).toContain("**Tool: bash**")
expect(result).toContain("**Input:**")
expect(result).toContain('"command": "ls"')
expect(result).toContain("**Output:**")
expect(result).toContain("file1.txt")
})
+ test("formats tool output containing triple backticks without breaking markdown", () => {
+ const part: Part = {
+ id: "part_1",
+ sessionID: "ses_123",
+ messageID: "msg_123",
+ type: "tool",
+ callID: "call_1",
+ tool: "bash",
+ state: {
+ status: "completed",
+ input: { command: "echo '```hello```'" },
+ output: "```hello```",
+ title: "Echo backticks",
+ metadata: {},
+ time: { start: 1000, end: 1100 },
+ },
+ }
+ const result = formatPart(part, options)
+ // The tool header should not be inside a code block
+ expect(result).toStartWith("**Tool: bash**\n")
+ // Input and output should each be in their own code blocks
+ expect(result).toContain("**Input:**\n```json")
+ expect(result).toContain("**Output:**\n```\n```hello```\n```")
+ })
+
test("formats tool part without details when disabled", () => {
const part: Part = {
id: "part_1",
@@ -144,7 +169,7 @@ describe("transcript", () => {
},
}
const result = formatPart(part, { ...options, toolDetails: false })
- expect(result).toContain("Tool: bash")
+ expect(result).toContain("**Tool: bash**")
expect(result).not.toContain("**Input:**")
expect(result).not.toContain("**Output:**")
})
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 1752e22e01..8611d82969 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -255,6 +255,37 @@ test("handles agent configuration", async () => {
})
})
+test("treats agent variant as model-scoped setting (not provider option)", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await writeConfig(dir, {
+ $schema: "https://opencode.ai/config.json",
+ agent: {
+ test_agent: {
+ model: "openai/gpt-5.2",
+ variant: "xhigh",
+ max_tokens: 123,
+ },
+ },
+ })
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ const agent = config.agent?.["test_agent"]
+
+ expect(agent?.variant).toBe("xhigh")
+ expect(agent?.options).toMatchObject({
+ max_tokens: 123,
+ })
+ expect(agent?.options).not.toHaveProperty("variant")
+ },
+ })
+})
+
test("handles command configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts
new file mode 100644
index 0000000000..ac46f1131b
--- /dev/null
+++ b/packages/opencode/test/file/ripgrep.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { tmpdir } from "../fixture/fixture"
+import { Ripgrep } from "../../src/file/ripgrep"
+
+describe("file.ripgrep", () => {
+ test("defaults to include hidden", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "visible.txt"), "hello")
+ await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
+ await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
+ },
+ })
+
+ const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path }))
+ const hasVisible = files.includes("visible.txt")
+ const hasHidden = files.includes(path.join(".opencode", "thing.json"))
+ expect(hasVisible).toBe(true)
+ expect(hasHidden).toBe(true)
+ })
+
+ test("hidden false excludes hidden", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "visible.txt"), "hello")
+ await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
+ await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
+ },
+ })
+
+ const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, hidden: false }))
+ const hasVisible = files.includes("visible.txt")
+ const hasHidden = files.includes(path.join(".opencode", "thing.json"))
+ expect(hasVisible).toBe(true)
+ expect(hasHidden).toBe(false)
+ })
+})
diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts
new file mode 100644
index 0000000000..d8f8ea4551
--- /dev/null
+++ b/packages/opencode/test/plugin/auth-override.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { tmpdir } from "../fixture/fixture"
+import { Instance } from "../../src/project/instance"
+import { ProviderAuth } from "../../src/provider/auth"
+
+describe("plugin.auth-override", () => {
+ test("user plugin overrides built-in github-copilot auth", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const pluginDir = path.join(dir, ".opencode", "plugin")
+ await fs.mkdir(pluginDir, { recursive: true })
+
+ await Bun.write(
+ path.join(pluginDir, "custom-copilot-auth.ts"),
+ [
+ "export default async () => ({",
+ " auth: {",
+ ' provider: "github-copilot",',
+ " methods: [",
+ ' { type: "api", label: "Test Override Auth" },',
+ " ],",
+ " loader: async () => ({ access: 'test-token' }),",
+ " },",
+ "})",
+ "",
+ ].join("\n"),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const methods = await ProviderAuth.methods()
+ const copilot = methods["github-copilot"]
+ expect(copilot).toBeDefined()
+ expect(copilot.length).toBe(1)
+ expect(copilot[0].label).toBe("Test Override Auth")
+ },
+ })
+ }, 30000) // Increased timeout for plugin installation
+})
diff --git a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts
index ffc7469115..9f305123af 100644
--- a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts
+++ b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts
@@ -354,7 +354,7 @@ describe("tool calls", () => {
})
describe("reasoning (copilot-specific)", () => {
- test("should include reasoning_text from reasoning part", () => {
+ test("should omit reasoning_text without reasoning_opaque", () => {
const result = convertToCopilotMessages([
{
role: "assistant",
@@ -370,7 +370,7 @@ describe("reasoning (copilot-specific)", () => {
role: "assistant",
content: "The answer is 42.",
tool_calls: undefined,
- reasoning_text: "Let me think about this...",
+ reasoning_text: undefined,
reasoning_opaque: undefined,
},
])
@@ -404,6 +404,33 @@ describe("reasoning (copilot-specific)", () => {
])
})
+ test("should include reasoning_opaque from text part providerOptions", () => {
+ const result = convertToCopilotMessages([
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "text",
+ text: "Done!",
+ providerOptions: {
+ copilot: { reasoningOpaque: "opaque-text-456" },
+ },
+ },
+ ],
+ },
+ ])
+
+ expect(result).toEqual([
+ {
+ role: "assistant",
+ content: "Done!",
+ tool_calls: undefined,
+ reasoning_text: undefined,
+ reasoning_opaque: "opaque-text-456",
+ },
+ ])
+ })
+
test("should handle reasoning-only assistant message", () => {
const result = convertToCopilotMessages([
{
diff --git a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts b/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts
index 0b82c18684..562da4507d 100644
--- a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts
+++ b/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts
@@ -65,6 +65,12 @@ const FIXTURES = {
`data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"code\\":\\"1 + 1\\"}","name":"project_eval"},"id":"call_MHw3RDhmT1J5Z3B6WlhpVjlveTc","index":0,"type":"function"}],"reasoning_opaque":"ytGNWFf2doK38peANDvm7whkLPKrd+Fv6/k34zEPBF6Qwitj4bTZT0FBXleydLb6"}}],"created":1766068644,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":12,"prompt_tokens":8677,"prompt_tokens_details":{"cached_tokens":3692},"total_tokens":8768,"reasoning_tokens":79},"model":"gemini-3-pro-preview"}`,
`data: [DONE]`,
],
+
+ reasoningOpaqueWithToolCallsNoReasoningText: [
+ `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only","index":0,"type":"function"}],"reasoning_opaque":"opaque-xyz"}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`,
+ `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only_2","index":1,"type":"function"}]}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":12,"prompt_tokens":123,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":135,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`,
+ `data: [DONE]`,
+ ],
}
function createMockFetch(chunks: string[]) {
@@ -447,6 +453,35 @@ describe("doStream", () => {
})
})
+ test("should attach reasoning_opaque to tool calls without reasoning_text", async () => {
+ const mockFetch = createMockFetch(FIXTURES.reasoningOpaqueWithToolCallsNoReasoningText)
+ const model = createModel(mockFetch)
+
+ const { stream } = await model.doStream({
+ prompt: TEST_PROMPT,
+ includeRawChunks: false,
+ })
+
+ const parts = await convertReadableStreamToArray(stream)
+ const reasoningParts = parts.filter(
+ (p) => p.type === "reasoning-start" || p.type === "reasoning-delta" || p.type === "reasoning-end",
+ )
+
+ expect(reasoningParts).toHaveLength(0)
+
+ const toolCall = parts.find((p) => p.type === "tool-call" && p.toolCallId === "call_reasoning_only")
+ expect(toolCall).toMatchObject({
+ type: "tool-call",
+ toolCallId: "call_reasoning_only",
+ toolName: "read_file",
+ providerMetadata: {
+ copilot: {
+ reasoningOpaque: "opaque-xyz",
+ },
+ },
+ })
+ })
+
test("should include response metadata from first chunk", async () => {
const mockFetch = createMockFetch(FIXTURES.basicText)
const model = createModel(mockFetch)
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index 0973e61585..8e28f1209e 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -267,76 +267,6 @@ describe("ProviderTransform.maxOutputTokens", () => {
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
})
-
- describe("openai-compatible with thinking options (snake_case)", () => {
- test("returns 32k when budget_tokens + 32k <= modelLimit", () => {
- const modelLimit = 100000
- const options = {
- thinking: {
- type: "enabled",
- budget_tokens: 10000,
- },
- }
- const result = ProviderTransform.maxOutputTokens(
- "@ai-sdk/openai-compatible",
- options,
- modelLimit,
- OUTPUT_TOKEN_MAX,
- )
- expect(result).toBe(OUTPUT_TOKEN_MAX)
- })
-
- test("returns modelLimit - budget_tokens when budget_tokens + 32k > modelLimit", () => {
- const modelLimit = 50000
- const options = {
- thinking: {
- type: "enabled",
- budget_tokens: 30000,
- },
- }
- const result = ProviderTransform.maxOutputTokens(
- "@ai-sdk/openai-compatible",
- options,
- modelLimit,
- OUTPUT_TOKEN_MAX,
- )
- expect(result).toBe(20000)
- })
-
- test("returns 32k when thinking type is not enabled", () => {
- const modelLimit = 100000
- const options = {
- thinking: {
- type: "disabled",
- budget_tokens: 10000,
- },
- }
- const result = ProviderTransform.maxOutputTokens(
- "@ai-sdk/openai-compatible",
- options,
- modelLimit,
- OUTPUT_TOKEN_MAX,
- )
- expect(result).toBe(OUTPUT_TOKEN_MAX)
- })
-
- test("returns 32k when budget_tokens is 0", () => {
- const modelLimit = 100000
- const options = {
- thinking: {
- type: "enabled",
- budget_tokens: 0,
- },
- }
- const result = ProviderTransform.maxOutputTokens(
- "@ai-sdk/openai-compatible",
- options,
- modelLimit,
- OUTPUT_TOKEN_MAX,
- )
- expect(result).toBe(OUTPUT_TOKEN_MAX)
- })
- })
})
describe("ProviderTransform.schema - gemini array items", () => {
@@ -1166,7 +1096,7 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile"
expect(result[0].providerOptions?.bedrock).toEqual(
expect.objectContaining({
cachePoint: {
- type: "ephemeral",
+ type: "default",
},
}),
)
@@ -1564,67 +1494,6 @@ describe("ProviderTransform.variants", () => {
expect(result.low).toEqual({ reasoningEffort: "low" })
expect(result.high).toEqual({ reasoningEffort: "high" })
})
-
- test("Claude via LiteLLM returns thinking with snake_case budget_tokens", () => {
- const model = createMockModel({
- id: "anthropic/claude-sonnet-4-5",
- providerID: "anthropic",
- api: {
- id: "claude-sonnet-4-5-20250929",
- url: "http://localhost:4000",
- npm: "@ai-sdk/openai-compatible",
- },
- })
- const result = ProviderTransform.variants(model)
- expect(Object.keys(result)).toEqual(["high", "max"])
- expect(result.high).toEqual({
- thinking: {
- type: "enabled",
- budget_tokens: 16000,
- },
- })
- expect(result.max).toEqual({
- thinking: {
- type: "enabled",
- budget_tokens: 31999,
- },
- })
- })
-
- test("Claude model (by model.id) via openai-compatible uses snake_case", () => {
- const model = createMockModel({
- id: "litellm/claude-3-opus",
- providerID: "litellm",
- api: {
- id: "claude-3-opus-20240229",
- url: "http://localhost:4000",
- npm: "@ai-sdk/openai-compatible",
- },
- })
- const result = ProviderTransform.variants(model)
- expect(Object.keys(result)).toEqual(["high", "max"])
- expect(result.high).toEqual({
- thinking: {
- type: "enabled",
- budget_tokens: 16000,
- },
- })
- })
-
- test("Anthropic model (by model.api.id) via openai-compatible uses snake_case", () => {
- const model = createMockModel({
- id: "custom/my-model",
- providerID: "custom",
- api: {
- id: "anthropic.claude-sonnet",
- url: "http://localhost:4000",
- npm: "@ai-sdk/openai-compatible",
- },
- })
- const result = ProviderTransform.variants(model)
- expect(Object.keys(result)).toEqual(["high", "max"])
- expect(result.high.thinking.budget_tokens).toBe(16000)
- })
})
describe("@ai-sdk/azure", () => {
diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts
index 67719fa339..4d57e92a25 100644
--- a/packages/opencode/test/session/instruction.test.ts
+++ b/packages/opencode/test/session/instruction.test.ts
@@ -47,4 +47,24 @@ describe("InstructionPrompt.resolve", () => {
},
})
})
+
+ test("doesn't reload AGENTS.md when reading it directly", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
+ await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
+ const system = await InstructionPrompt.systemPaths()
+ expect(system.has(filepath)).toBe(false)
+
+ const results = await InstructionPrompt.resolve([], filepath, "test-message-2")
+ expect(results).toEqual([])
+ },
+ })
+ })
})
diff --git a/packages/opencode/test/session/prompt-variant.test.ts b/packages/opencode/test/session/prompt-variant.test.ts
new file mode 100644
index 0000000000..16e8a22444
--- /dev/null
+++ b/packages/opencode/test/session/prompt-variant.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, test } from "bun:test"
+import { Instance } from "../../src/project/instance"
+import { Session } from "../../src/session"
+import { SessionPrompt } from "../../src/session/prompt"
+import { tmpdir } from "../fixture/fixture"
+
+describe("session.prompt agent variant", () => {
+ test("applies agent variant only when using agent model", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ config: {
+ agent: {
+ build: {
+ model: "openai/gpt-5.2",
+ variant: "xhigh",
+ },
+ },
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const session = await Session.create({})
+
+ const other = await SessionPrompt.prompt({
+ sessionID: session.id,
+ agent: "build",
+ model: { providerID: "opencode", modelID: "kimi-k2.5-free" },
+ noReply: true,
+ parts: [{ type: "text", text: "hello" }],
+ })
+ if (other.info.role !== "user") throw new Error("expected user message")
+ expect(other.info.variant).toBeUndefined()
+
+ const match = await SessionPrompt.prompt({
+ sessionID: session.id,
+ agent: "build",
+ noReply: true,
+ parts: [{ type: "text", text: "hello again" }],
+ })
+ if (match.info.role !== "user") throw new Error("expected user message")
+ expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
+ expect(match.info.variant).toBe("xhigh")
+
+ const override = await SessionPrompt.prompt({
+ sessionID: session.id,
+ agent: "build",
+ noReply: true,
+ variant: "high",
+ parts: [{ type: "text", text: "hello third" }],
+ })
+ if (override.info.role !== "user") throw new Error("expected user message")
+ expect(override.info.variant).toBe("high")
+
+ await Session.remove(session.id)
+ },
+ })
+ })
+})
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
new file mode 100644
index 0000000000..e778bfe514
--- /dev/null
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -0,0 +1,62 @@
+import path from "path"
+import { describe, expect, test } from "bun:test"
+import { Session } from "../../src/session"
+import { SessionPrompt } from "../../src/session/prompt"
+import { MessageV2 } from "../../src/session/message-v2"
+import { Instance } from "../../src/project/instance"
+import { Log } from "../../src/util/log"
+import { tmpdir } from "../fixture/fixture"
+
+Log.init({ print: false })
+
+describe("SessionPrompt ordering", () => {
+ test("keeps @file order with read output parts", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "a.txt"), "28\n")
+ await Bun.write(path.join(dir, "b.txt"), "42\n")
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const session = await Session.create({})
+ const template = "What numbers are written in files @a.txt and @b.txt ?"
+ const parts = await SessionPrompt.resolvePromptParts(template)
+ const fileParts = parts.filter((part) => part.type === "file")
+
+ expect(fileParts.map((part) => part.filename)).toStrictEqual(["a.txt", "b.txt"])
+
+ const message = await SessionPrompt.prompt({
+ sessionID: session.id,
+ parts,
+ noReply: true,
+ })
+ const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
+ const items = stored.parts
+ const aPath = path.join(tmp.path, "a.txt")
+ const bPath = path.join(tmp.path, "b.txt")
+ const sequence = items.flatMap((part) => {
+ if (part.type === "text") {
+ if (part.text.includes(aPath)) return ["input:a"]
+ if (part.text.includes(bPath)) return ["input:b"]
+ if (part.text.includes("00001| 28")) return ["output:a"]
+ if (part.text.includes("00001| 42")) return ["output:b"]
+ return []
+ }
+ if (part.type === "file") {
+ if (part.filename === "a.txt") return ["file:a"]
+ if (part.filename === "b.txt") return ["file:b"]
+ }
+ return []
+ })
+
+ expect(sequence).toStrictEqual(["input:a", "output:a", "file:a", "input:b", "output:b", "file:b"])
+
+ await Session.remove(session.id)
+ },
+ })
+ })
+})
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 5a4afbae43..160ce6a826 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -9,8 +9,14 @@
"build": "tsc"
},
"exports": {
- ".": "./src/index.ts",
- "./tool": "./src/tool.ts"
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./tool": {
+ "types": "./dist/tool.d.ts",
+ "import": "./dist/tool.js"
+ }
},
"files": [
"dist"
diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts
index 2d991ff0c3..496bdede2d 100644
--- a/packages/script/src/index.ts
+++ b/packages/script/src/index.ts
@@ -46,6 +46,20 @@ const VERSION = await (async () => {
return `${major}.${minor}.${patch + 1}`
})()
+const team = [
+ "actions-user",
+ "opencode",
+ "rekram1-node",
+ "thdxr",
+ "kommander",
+ "jayair",
+ "fwang",
+ "adamdotdevin",
+ "iamdavidhill",
+ "opencode-agent[bot]",
+ "R44VC0RP",
+]
+
export const Script = {
get channel() {
return CHANNEL
@@ -59,5 +73,8 @@ export const Script = {
get release() {
return env.OPENCODE_RELEASE
},
+ get team() {
+ return team
+ },
}
console.log(`opencode script`, JSON.stringify(Script, null, 2))
diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts
index ca13e5e93c..8eefe5bfe9 100644
--- a/packages/sdk/js/src/gen/types.gen.ts
+++ b/packages/sdk/js/src/gen/types.gen.ts
@@ -1554,7 +1554,7 @@ export type FileNode = {
}
export type FileContent = {
- type: "text"
+ type: "text" | "binary"
content: string
diff?: string
patch?: {
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 2ce9731be5..3dbb94de0b 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1378,6 +1378,10 @@ export type PermissionConfig =
export type AgentConfig = {
model?: string
+ /**
+ * Default model variant for this agent (applies only when using the agent's configured model).
+ */
+ variant?: string
temperature?: number
top_p?: number
prompt?: string
@@ -2049,7 +2053,7 @@ export type FileNode = {
}
export type FileContent = {
- type: "text"
+ type: "text" | "binary"
content: string
diff?: string
patch?: {
@@ -2143,6 +2147,7 @@ export type Agent = {
modelID: string
providerID: string
}
+ variant?: string
prompt?: string
options: {
[key: string]: unknown
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 66df1739e6..4608bd60cc 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -9044,6 +9044,10 @@
"model": {
"type": "string"
},
+ "variant": {
+ "description": "Default model variant for this agent (applies only when using the agent's configured model).",
+ "type": "string"
+ },
"temperature": {
"type": "number"
},
@@ -10591,7 +10595,7 @@
"properties": {
"type": {
"type": "string",
- "const": "text"
+ "enum": ["text", "binary"]
},
"content": {
"type": "string"
@@ -10869,6 +10873,9 @@
},
"required": ["modelID", "providerID"]
},
+ "variant": {
+ "type": "string"
+ },
"prompt": {
"type": "string"
},
diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css
index d9b3459230..3e5d21d1de 100644
--- a/packages/ui/src/components/button.css
+++ b/packages/ui/src/components/button.css
@@ -9,7 +9,13 @@
user-select: none;
cursor: default;
outline: none;
+ padding: 4px 8px;
white-space: nowrap;
+ transition-property: background-color, border-color, color, box-shadow, opacity;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
+ outline: none;
+ line-height: 20px;
&[data-variant="primary"] {
background-color: var(--button-primary-base);
@@ -94,7 +100,6 @@
&:active:not(:disabled) {
background-color: var(--button-secondary-base);
scale: 0.99;
- transition: all 150ms ease-out;
}
&:disabled {
border-color: var(--border-disabled);
@@ -109,34 +114,31 @@
}
&[data-size="small"] {
- height: 22px;
- padding: 0 8px;
+ padding: 4px 8px;
&[data-icon] {
- padding: 0 12px 0 4px;
+ padding: 4px 12px 4px 4px;
}
- font-size: var(--font-size-small);
- line-height: var(--line-height-large);
gap: 4px;
/* text-12-medium */
font-family: var(--font-family-sans);
- font-size: var(--font-size-small);
+ font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-medium);
- line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
}
&[data-size="normal"] {
- height: 24px;
- line-height: 24px;
- padding: 0 6px;
+ padding: 4px 6px;
&[data-icon] {
- padding: 0 12px 0 4px;
+ padding: 4px 12px 4px 4px;
+ }
+
+ &[aria-haspopup] {
+ padding: 4px 6px 4px 8px;
}
- font-size: var(--font-size-small);
gap: 6px;
/* text-12-medium */
@@ -148,7 +150,6 @@
}
&[data-size="large"] {
- height: 32px;
padding: 6px 12px;
&[data-icon] {
diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx
index 7f974b2f76..b2d2004d3c 100644
--- a/packages/ui/src/components/button.tsx
+++ b/packages/ui/src/components/button.tsx
@@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon"
export interface ButtonProps
extends ComponentProps,
- Pick, "class" | "classList" | "children"> {
+ Pick, "class" | "classList" | "children" | "style"> {
size?: "small" | "normal" | "large"
variant?: "primary" | "secondary" | "ghost"
icon?: IconProps["name"]
diff --git a/packages/ui/src/components/cycle-label.css b/packages/ui/src/components/cycle-label.css
new file mode 100644
index 0000000000..3c98fcd261
--- /dev/null
+++ b/packages/ui/src/components/cycle-label.css
@@ -0,0 +1,49 @@
+.cycle-label {
+ --c-duration: 200ms;
+ --c-stagger: 30ms;
+ --c-opacity-start: 0;
+ --c-opacity-end: 1;
+ --c-blur-start: 0px;
+ --c-blur-end: 0px;
+ --c-skew: 10deg;
+
+ display: inline-flex;
+ position: relative;
+
+ transform-style: preserve-3d;
+ perspective: 500px;
+ transition: width var(--transition-duration) var(--transition-easing);
+ will-change: width;
+ overflow: hidden;
+
+ .cycle-char {
+ display: inline-block;
+ transform-style: preserve-3d;
+ min-width: 0.25em;
+ backface-visibility: hidden;
+
+ transition-property: transform, opacity, filter;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
+ transition-delay: calc(var(--i, 0) * var(--c-stagger));
+
+ &.enter {
+ opacity: var(--c-opacity-end);
+ filter: blur(var(--c-blur-end));
+ transform: translateY(0) rotateX(0) skewX(0);
+ }
+
+ &.exit {
+ opacity: var(--c-opacity-start);
+ filter: blur(var(--c-blur-start));
+ transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew));
+ }
+
+ &.pre {
+ opacity: var(--c-opacity-start);
+ filter: blur(var(--c-blur-start));
+ transition: none;
+ transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1));
+ }
+ }
+}
diff --git a/packages/ui/src/components/cycle-label.tsx b/packages/ui/src/components/cycle-label.tsx
new file mode 100644
index 0000000000..dc12bd75c8
--- /dev/null
+++ b/packages/ui/src/components/cycle-label.tsx
@@ -0,0 +1,135 @@
+import "./cycle-label.css"
+import { createEffect, createSignal, JSX, on } from "solid-js"
+
+export interface CycleLabelProps extends JSX.HTMLAttributes {
+ value: string
+ onValueChange?: (value: string) => void
+ duration?: number | ((value: string) => number)
+ stagger?: number
+ opacity?: [number, number]
+ blur?: [number, number]
+ skewX?: number
+ onAnimationStart?: () => void
+ onAnimationEnd?: () => void
+}
+
+const segmenter =
+ typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null
+
+const getChars = (text: string): string[] =>
+ segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("")
+
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
+
+export function CycleLabel(props: CycleLabelProps) {
+ const getDuration = (text: string) => {
+ const d =
+ props.duration ??
+ Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ??
+ 200
+ return typeof d === "function" ? d(text) : d
+ }
+ const stagger = () => props?.stagger ?? 30
+ const opacity = () => props?.opacity ?? [0, 1]
+ const blur = () => props?.blur ?? [0, 0]
+ const skewX = () => props?.skewX ?? 10
+
+ let containerRef: HTMLSpanElement | undefined
+ let isAnimating = false
+ const [currentText, setCurrentText] = createSignal(props.value)
+
+ const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => {
+ el.innerHTML = ""
+ const chars = getChars(text)
+ chars.forEach((char, i) => {
+ const span = document.createElement("span")
+ span.textContent = char === " " ? "\u00A0" : char
+ span.className = `cycle-char ${state}`
+ span.style.setProperty("--i", String(i))
+ el.appendChild(span)
+ })
+ }
+
+ const animateToText = async (newText: string) => {
+ if (!containerRef || isAnimating) return
+ if (newText === currentText()) return
+
+ isAnimating = true
+ props.onAnimationStart?.()
+
+ const dur = getDuration(newText)
+ const stag = stagger()
+
+ containerRef.style.width = containerRef.offsetWidth + "px"
+
+ const oldChars = containerRef.querySelectorAll(".cycle-char")
+ oldChars.forEach((c) => c.classList.replace("enter", "exit"))
+
+ const clone = containerRef.cloneNode(false) as HTMLElement
+ Object.assign(clone.style, {
+ position: "absolute",
+ visibility: "hidden",
+ width: "auto",
+ transition: "none",
+ })
+ setChars(clone, newText)
+ document.body.appendChild(clone)
+ const nextWidth = clone.offsetWidth
+ clone.remove()
+
+ const exitTime = oldChars.length * stag + dur
+ await wait(exitTime * 0.3)
+
+ containerRef.style.width = nextWidth + "px"
+
+ const widthDur = 200
+ await wait(widthDur * 0.3)
+
+ setChars(containerRef, newText, "pre")
+ containerRef.offsetWidth
+
+ Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter"))
+ setCurrentText(newText)
+ props.onValueChange?.(newText)
+
+ const enterTime = getChars(newText).length * stag + dur
+ await wait(enterTime)
+
+ containerRef.style.width = ""
+ isAnimating = false
+ props.onAnimationEnd?.()
+ }
+
+ createEffect(
+ on(
+ () => props.value,
+ (newValue) => {
+ if (newValue !== currentText()) {
+ animateToText(newValue)
+ }
+ },
+ ),
+ )
+
+ const initRef = (el: HTMLSpanElement) => {
+ containerRef = el
+ setChars(el, props.value)
+ }
+
+ return (
+
+ )
+}
diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css
index cba041613e..18266ac1a1 100644
--- a/packages/ui/src/components/dropdown-menu.css
+++ b/packages/ui/src/components/dropdown-menu.css
@@ -2,26 +2,29 @@
[data-component="dropdown-menu-sub-content"] {
min-width: 8rem;
overflow: hidden;
+ border: none;
border-radius: var(--radius-md);
- border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
+ box-shadow: var(--shadow-xs-border);
background-clip: padding-box;
background-color: var(--surface-raised-stronger-non-alpha);
padding: 4px;
- box-shadow: var(--shadow-md);
- z-index: 50;
+ z-index: 100;
transform-origin: var(--kb-menu-content-transform-origin);
- &:focus,
- &:focus-visible {
+ &:focus-within,
+ &:focus {
outline: none;
}
- &[data-closed] {
- animation: dropdown-menu-close 0.15s ease-out;
+ animation: dropdownMenuContentHide var(--transition-duration) var(--transition-easing) forwards;
+
+ @starting-style {
+ animation: none;
}
&[data-expanded] {
- animation: dropdown-menu-open 0.15s ease-out;
+ pointer-events: auto;
+ animation: dropdownMenuContentShow var(--transition-duration) var(--transition-easing) forwards;
}
}
@@ -38,18 +41,22 @@
padding: 4px 8px;
border-radius: var(--radius-sm);
cursor: default;
- user-select: none;
outline: none;
font-family: var(--font-family-sans);
- font-size: var(--font-size-small);
+ font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
- &[data-highlighted] {
- background: var(--surface-raised-base-hover);
+ transition-property: background-color, color;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
+ user-select: none;
+
+ &:hover {
+ background-color: var(--surface-raised-base-hover);
}
&[data-disabled] {
@@ -61,6 +68,8 @@
[data-slot="dropdown-menu-sub-trigger"] {
&[data-expanded] {
background: var(--surface-raised-base-hover);
+ outline: none;
+ border: none;
}
}
@@ -102,24 +111,24 @@
}
}
-@keyframes dropdown-menu-open {
+@keyframes dropdownMenuContentShow {
from {
opacity: 0;
- transform: scale(0.96);
+ transform: scaleY(0.95);
}
to {
opacity: 1;
- transform: scale(1);
+ transform: scaleY(1);
}
}
-@keyframes dropdown-menu-close {
+@keyframes dropdownMenuContentHide {
from {
opacity: 1;
- transform: scale(1);
+ transform: scaleY(1);
}
to {
opacity: 0;
- transform: scale(0.96);
+ transform: scaleY(0.95);
}
}
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 544c6abdd2..97488a42f0 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -80,13 +80,16 @@ const icons = {
export interface IconProps extends ComponentProps<"svg"> {
name: keyof typeof icons
- size?: "small" | "normal" | "medium" | "large"
+ size?: "small" | "normal" | "medium" | "large" | number
}
export function Icon(props: IconProps) {
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
return (
-
+
-
+
0 || showAdd()}
fallback={
@@ -339,7 +340,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
-
+
)
}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 7aad01acea..b8a7ce0b50 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -42,13 +42,13 @@ import { Checkbox } from "./checkbox"
import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown"
import { ImagePreview } from "./image-preview"
-import { findLast } from "@opencode-ai/util/array"
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"
import { createResizeObserver } from "@solid-primitives/resize-observer"
+import { MorphChevron } from "./morph-chevron"
interface Diagnostic {
range: {
@@ -415,7 +415,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
toggleExpanded()
}}
>
-
+
m.id === perm.tool!.messageID)
+ const message = messages.findLast((m) => m.id === perm.tool!.messageID)
if (!message) return undefined
const parts = data.store.part[message.id] ?? []
for (const part of parts) {
diff --git a/packages/ui/src/components/morph-chevron.css b/packages/ui/src/components/morph-chevron.css
new file mode 100644
index 0000000000..f6edb3f649
--- /dev/null
+++ b/packages/ui/src/components/morph-chevron.css
@@ -0,0 +1,10 @@
+[data-slot="morph-chevron-svg"] {
+ width: 16px;
+ height: 16px;
+ display: block;
+ fill: none;
+ stroke-width: 1.5;
+ stroke: currentcolor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
diff --git a/packages/ui/src/components/morph-chevron.tsx b/packages/ui/src/components/morph-chevron.tsx
new file mode 100644
index 0000000000..280aeb7e34
--- /dev/null
+++ b/packages/ui/src/components/morph-chevron.tsx
@@ -0,0 +1,73 @@
+import { createEffect, createUniqueId, on } from "solid-js"
+
+export interface MorphChevronProps {
+ expanded: boolean
+ class?: string
+}
+
+const COLLAPSED = "M4 6L8 10L12 6"
+const EXPANDED = "M4 10L8 6L12 10"
+
+export function MorphChevron(props: MorphChevronProps) {
+ const id = createUniqueId()
+ let path: SVGPathElement | undefined
+ let expandAnim: SVGAnimateElement | undefined
+ let collapseAnim: SVGAnimateElement | undefined
+
+ createEffect(
+ on(
+ () => props.expanded,
+ (expanded, prev) => {
+ if (prev === undefined) {
+ // Set initial state without animation
+ path?.setAttribute("d", expanded ? EXPANDED : COLLAPSED)
+ return
+ }
+ if (expanded) {
+ expandAnim?.beginElement()
+ } else {
+ collapseAnim?.beginElement()
+ }
+ },
+ ),
+ )
+
+ return (
+
+ )
+}
diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css
index b49542afd9..d200fe8b24 100644
--- a/packages/ui/src/components/popover.css
+++ b/packages/ui/src/components/popover.css
@@ -15,16 +15,35 @@
transform-origin: var(--kb-popover-content-transform-origin);
- &:focus-within {
- outline: none;
- }
+ animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards;
- &[data-closed] {
- animation: popover-close 0.15s ease-out;
+ @starting-style {
+ animation: none;
}
&[data-expanded] {
- animation: popover-open 0.15s ease-out;
+ pointer-events: auto;
+ animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards;
+ }
+
+ [data-origin-top-right] {
+ transform-origin: top right;
+ }
+
+ [data-origin-top-left] {
+ transform-origin: top left;
+ }
+
+ [data-origin-bottom-right] {
+ transform-origin: bottom right;
+ }
+
+ [data-origin-bottom-left] {
+ transform-origin: bottom left;
+ }
+
+ &:focus-within {
+ outline: none;
}
[data-slot="popover-header"] {
@@ -75,24 +94,39 @@
}
}
-@keyframes popover-open {
+@keyframes popoverContentShow {
from {
opacity: 0;
- transform: scale(0.96);
+ transform: scaleY(0.95);
}
to {
opacity: 1;
- transform: scale(1);
+ transform: scaleY(1);
}
}
-@keyframes popover-close {
+@keyframes popoverContentHide {
from {
opacity: 1;
- transform: scale(1);
+ transform: scaleY(1);
}
to {
opacity: 0;
- transform: scale(0.96);
+ transform: scaleY(0.95);
+ }
+}
+
+[data-component="model-popover-content"] {
+ transform-origin: var(--kb-popper-content-transform-origin);
+ pointer-events: none;
+ animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards;
+
+ @starting-style {
+ animation: none;
+ }
+
+ &[data-expanded] {
+ pointer-events: auto;
+ animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards;
}
}
diff --git a/packages/ui/src/components/reasoning-icon.css b/packages/ui/src/components/reasoning-icon.css
new file mode 100644
index 0000000000..26fbc01448
--- /dev/null
+++ b/packages/ui/src/components/reasoning-icon.css
@@ -0,0 +1,9 @@
+[data-component="reasoning-icon"] {
+ color: var(--icon-strong-base);
+
+ [data-slot="reasoning-icon-percentage"] {
+ transition: clip-path 200ms cubic-bezier(0.25, 0, 0.5, 1);
+ clip-path: inset(calc(100% - var(--reasoning-icon-percentage) * 100%) 0 0 0);
+ opacity: calc(var(--reasoning-icon-percentage) * 0.75);
+ }
+}
diff --git a/packages/ui/src/components/reasoning-icon.tsx b/packages/ui/src/components/reasoning-icon.tsx
new file mode 100644
index 0000000000..7bac49ffd2
--- /dev/null
+++ b/packages/ui/src/components/reasoning-icon.tsx
@@ -0,0 +1,46 @@
+import { type ComponentProps, splitProps } from "solid-js"
+
+export interface ReasoningIconProps extends Pick, "class" | "classList"> {
+ percentage: number
+ size?: number
+ strokeWidth?: number
+}
+
+export function ReasoningIcon(props: ReasoningIconProps) {
+ const [split, rest] = splitProps(props, ["percentage", "size", "strokeWidth", "class", "classList"])
+
+ const size = () => split.size || 16
+ const strokeWidth = () => split.strokeWidth || 1.25
+
+ return (
+
+ )
+}
diff --git a/packages/ui/src/components/scroll-fade.css b/packages/ui/src/components/scroll-fade.css
new file mode 100644
index 0000000000..ede5fabec4
--- /dev/null
+++ b/packages/ui/src/components/scroll-fade.css
@@ -0,0 +1,82 @@
+[data-component="scroll-fade"] {
+ overflow: auto;
+ overscroll-behavior: contain;
+ scrollbar-width: none;
+ box-sizing: border-box;
+ color: inherit;
+ font: inherit;
+ -ms-overflow-style: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ &[data-direction="horizontal"] {
+ overflow-x: auto;
+ overflow-y: hidden;
+
+ /* Both fades */
+ &[data-fade-start][data-fade-end] {
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ black var(--scroll-fade-start),
+ black calc(100% - var(--scroll-fade-end)),
+ transparent
+ );
+ -webkit-mask-image: linear-gradient(
+ to right,
+ transparent,
+ black var(--scroll-fade-start),
+ black calc(100% - var(--scroll-fade-end)),
+ transparent
+ );
+ }
+
+ /* Only start fade */
+ &[data-fade-start]:not([data-fade-end]) {
+ mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%);
+ -webkit-mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%);
+ }
+
+ /* Only end fade */
+ &:not([data-fade-start])[data-fade-end] {
+ mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent);
+ -webkit-mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent);
+ }
+ }
+
+ &[data-direction="vertical"] {
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ &[data-fade-start][data-fade-end] {
+ mask-image: linear-gradient(
+ to bottom,
+ transparent,
+ black var(--scroll-fade-start),
+ black calc(100% - var(--scroll-fade-end)),
+ transparent
+ );
+ -webkit-mask-image: linear-gradient(
+ to bottom,
+ transparent,
+ black var(--scroll-fade-start),
+ black calc(100% - var(--scroll-fade-end)),
+ transparent
+ );
+ }
+
+ /* Only start fade */
+ &[data-fade-start]:not([data-fade-end]) {
+ mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%);
+ -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%);
+ }
+
+ /* Only end fade */
+ &:not([data-fade-start])[data-fade-end] {
+ mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent);
+ -webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent);
+ }
+ }
+}
diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx
new file mode 100644
index 0000000000..97f0339e82
--- /dev/null
+++ b/packages/ui/src/components/scroll-fade.tsx
@@ -0,0 +1,206 @@
+import { type JSX, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
+
+export interface ScrollFadeProps extends JSX.HTMLAttributes {
+ direction?: "horizontal" | "vertical"
+ fadeStartSize?: number
+ fadeEndSize?: number
+ trackTransformSelector?: string
+ ref?: (el: HTMLDivElement) => void
+}
+
+export function ScrollFade(props: ScrollFadeProps) {
+ const [local, others] = splitProps(props, [
+ "children",
+ "direction",
+ "fadeStartSize",
+ "fadeEndSize",
+ "trackTransformSelector",
+ "class",
+ "style",
+ "ref",
+ ])
+
+ const direction = () => local.direction ?? "vertical"
+ const fadeStartSize = () => local.fadeStartSize ?? 20
+ const fadeEndSize = () => local.fadeEndSize ?? 20
+
+ const getTransformOffset = (element: Element): number => {
+ const style = getComputedStyle(element)
+ const transform = style.transform
+ if (!transform || transform === "none") return 0
+
+ const match = transform.match(/matrix(?:3d)?\(([^)]+)\)/)
+ if (!match) return 0
+
+ const values = match[1].split(",").map((v) => parseFloat(v.trim()))
+ const isHorizontal = direction() === "horizontal"
+
+ if (transform.startsWith("matrix3d")) {
+ return isHorizontal ? -(values[12] || 0) : -(values[13] || 0)
+ } else {
+ return isHorizontal ? -(values[4] || 0) : -(values[5] || 0)
+ }
+ }
+
+ let containerRef: HTMLDivElement | undefined
+
+ const [fadeStart, setFadeStart] = createSignal(0)
+ const [fadeEnd, setFadeEnd] = createSignal(0)
+ const [isScrollable, setIsScrollable] = createSignal(false)
+
+ let lastScrollPos = 0
+ let lastTransformPos = 0
+ let lastScrollSize = 0
+ let lastClientSize = 0
+
+ const updateFade = () => {
+ if (!containerRef) return
+
+ const isHorizontal = direction() === "horizontal"
+ const scrollPos = isHorizontal ? containerRef.scrollLeft : containerRef.scrollTop
+ const scrollSize = isHorizontal ? containerRef.scrollWidth : containerRef.scrollHeight
+ const clientSize = isHorizontal ? containerRef.clientWidth : containerRef.clientHeight
+
+ let transformPos = 0
+ if (local.trackTransformSelector) {
+ const transformElement = containerRef.querySelector(local.trackTransformSelector)
+ if (transformElement) {
+ transformPos = getTransformOffset(transformElement)
+ }
+ }
+
+ const effectiveScrollPos = Math.max(scrollPos, transformPos)
+
+ if (
+ effectiveScrollPos === lastScrollPos &&
+ transformPos === lastTransformPos &&
+ scrollSize === lastScrollSize &&
+ clientSize === lastClientSize
+ ) {
+ return
+ }
+
+ lastScrollPos = effectiveScrollPos
+ lastTransformPos = transformPos
+ lastScrollSize = scrollSize
+ lastClientSize = clientSize
+
+ const maxScroll = scrollSize - clientSize
+ const canScroll = maxScroll > 1
+
+ setIsScrollable(canScroll)
+
+ if (!canScroll) {
+ setFadeStart(0)
+ setFadeEnd(0)
+ return
+ }
+
+ const progress = maxScroll > 0 ? effectiveScrollPos / maxScroll : 0
+
+ const startProgress = Math.min(progress / 0.1, 1)
+ setFadeStart(startProgress * fadeStartSize())
+
+ const endProgress = progress > 0.9 ? (1 - progress) / 0.1 : 1
+ setFadeEnd(Math.max(0, endProgress) * fadeEndSize())
+ }
+
+ onMount(() => {
+ if (!containerRef) return
+
+ updateFade()
+
+ let rafId: number | undefined
+ let isPolling = false
+ let pollTimeout: ReturnType | undefined
+
+ const startPolling = () => {
+ if (isPolling) return
+ isPolling = true
+
+ const pollScroll = () => {
+ updateFade()
+ rafId = requestAnimationFrame(pollScroll)
+ }
+ rafId = requestAnimationFrame(pollScroll)
+ }
+
+ const stopPolling = () => {
+ if (!isPolling) return
+ isPolling = false
+ if (rafId !== undefined) {
+ cancelAnimationFrame(rafId)
+ rafId = undefined
+ }
+ }
+
+ const schedulePollingStop = () => {
+ if (pollTimeout !== undefined) clearTimeout(pollTimeout)
+ pollTimeout = setTimeout(stopPolling, 1000)
+ }
+
+ const onActivity = () => {
+ updateFade()
+ if (local.trackTransformSelector) {
+ startPolling()
+ schedulePollingStop()
+ }
+ }
+
+ containerRef.addEventListener("scroll", onActivity, { passive: true })
+
+ const resizeObserver = new ResizeObserver(() => {
+ lastScrollSize = 0
+ lastClientSize = 0
+ onActivity()
+ })
+ resizeObserver.observe(containerRef)
+
+ const mutationObserver = new MutationObserver(() => {
+ lastScrollSize = 0
+ lastClientSize = 0
+ requestAnimationFrame(onActivity)
+ })
+ mutationObserver.observe(containerRef, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ })
+
+ onCleanup(() => {
+ containerRef?.removeEventListener("scroll", onActivity)
+ resizeObserver.disconnect()
+ mutationObserver.disconnect()
+ stopPolling()
+ if (pollTimeout !== undefined) clearTimeout(pollTimeout)
+ })
+ })
+
+ createEffect(() => {
+ local.children
+ requestAnimationFrame(updateFade)
+ })
+
+ return (
+ {
+ containerRef = el
+ local.ref?.(el)
+ }}
+ data-component="scroll-fade"
+ data-direction={direction()}
+ data-scrollable={isScrollable() || undefined}
+ data-fade-start={fadeStart() > 0 || undefined}
+ data-fade-end={fadeEnd() > 0 || undefined}
+ class={local.class}
+ style={{
+ ...(typeof local.style === "object" ? local.style : {}),
+ "--scroll-fade-start": `${fadeStart()}px`,
+ "--scroll-fade-end": `${fadeEnd()}px`,
+ }}
+ {...others}
+ >
+ {local.children}
+
+ )
+}
diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx
new file mode 100644
index 0000000000..6e5072dc81
--- /dev/null
+++ b/packages/ui/src/components/scroll-reveal.tsx
@@ -0,0 +1,141 @@
+import { type JSX, onCleanup, splitProps } from "solid-js"
+import { ScrollFade, type ScrollFadeProps } from "./scroll-fade"
+
+const SCROLL_SPEED = 60
+const PAUSE_DURATION = 800
+
+type ScrollAnimationState = {
+ rafId: number | null
+ startTime: number
+ running: boolean
+}
+
+const startScrollAnimation = (containerEl: HTMLElement): ScrollAnimationState | null => {
+ containerEl.offsetHeight
+
+ const extraWidth = containerEl.scrollWidth - containerEl.clientWidth
+
+ if (extraWidth <= 0) {
+ return null
+ }
+
+ const scrollDuration = (extraWidth / SCROLL_SPEED) * 1000
+ const totalDuration = PAUSE_DURATION + scrollDuration + PAUSE_DURATION + scrollDuration + PAUSE_DURATION
+
+ const state: ScrollAnimationState = {
+ rafId: null,
+ startTime: performance.now(),
+ running: true,
+ }
+
+ const animate = (currentTime: number) => {
+ if (!state.running) return
+
+ const elapsed = currentTime - state.startTime
+ const progress = (elapsed % totalDuration) / totalDuration
+
+ const pausePercent = PAUSE_DURATION / totalDuration
+ const scrollPercent = scrollDuration / totalDuration
+
+ const pauseEnd1 = pausePercent
+ const scrollEnd1 = pauseEnd1 + scrollPercent
+ const pauseEnd2 = scrollEnd1 + pausePercent
+ const scrollEnd2 = pauseEnd2 + scrollPercent
+
+ let scrollPos = 0
+
+ if (progress < pauseEnd1) {
+ scrollPos = 0
+ } else if (progress < scrollEnd1) {
+ const scrollProgress = (progress - pauseEnd1) / scrollPercent
+ scrollPos = scrollProgress * extraWidth
+ } else if (progress < pauseEnd2) {
+ scrollPos = extraWidth
+ } else if (progress < scrollEnd2) {
+ const scrollProgress = (progress - pauseEnd2) / scrollPercent
+ scrollPos = extraWidth * (1 - scrollProgress)
+ } else {
+ scrollPos = 0
+ }
+
+ containerEl.scrollLeft = scrollPos
+ state.rafId = requestAnimationFrame(animate)
+ }
+
+ state.rafId = requestAnimationFrame(animate)
+ return state
+}
+
+const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: HTMLElement) => {
+ if (state) {
+ state.running = false
+ if (state.rafId !== null) {
+ cancelAnimationFrame(state.rafId)
+ }
+ }
+ if (containerEl) {
+ containerEl.scrollLeft = 0
+ }
+}
+
+export interface ScrollRevealProps extends Omit {
+ hoverDelay?: number
+}
+
+export function ScrollReveal(props: ScrollRevealProps) {
+ const [local, others] = splitProps(props, ["children", "hoverDelay", "ref"])
+
+ const hoverDelay = () => local.hoverDelay ?? 300
+
+ let containerRef: HTMLDivElement | undefined
+ let hoverTimeout: ReturnType | undefined
+ let scrollAnimationState: ScrollAnimationState | null = null
+
+ const handleMouseEnter: JSX.EventHandler = () => {
+ hoverTimeout = setTimeout(() => {
+ if (!containerRef) return
+
+ containerRef.offsetHeight
+
+ const isScrollable = containerRef.scrollWidth > containerRef.clientWidth + 1
+
+ if (isScrollable) {
+ stopScrollAnimation(scrollAnimationState, containerRef)
+ scrollAnimationState = startScrollAnimation(containerRef)
+ }
+ }, hoverDelay())
+ }
+
+ const handleMouseLeave: JSX.EventHandler = () => {
+ if (hoverTimeout) {
+ clearTimeout(hoverTimeout)
+ hoverTimeout = undefined
+ }
+ stopScrollAnimation(scrollAnimationState, containerRef)
+ scrollAnimationState = null
+ }
+
+ onCleanup(() => {
+ if (hoverTimeout) {
+ clearTimeout(hoverTimeout)
+ }
+ stopScrollAnimation(scrollAnimationState, containerRef)
+ })
+
+ return (
+ {
+ containerRef = el
+ local.ref?.(el)
+ }}
+ fadeStartSize={8}
+ fadeEndSize={8}
+ direction="horizontal"
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
+ {...others}
+ >
+ {local.children}
+
+ )
+}
diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css
index 25dd2eb40b..eaba6fd6d2 100644
--- a/packages/ui/src/components/select.css
+++ b/packages/ui/src/components/select.css
@@ -1,7 +1,13 @@
[data-component="select"] {
[data-slot="select-select-trigger"] {
- padding: 0 4px 0 8px;
+ display: flex;
+ padding: 4px 8px !important;
+ align-items: center;
+ justify-content: space-between;
box-shadow: none;
+ transition-property: background-color;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
[data-slot="select-select-trigger-value"] {
overflow: hidden;
@@ -15,10 +21,10 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
- color: var(--text-weak);
- transition: transform 0.1s ease-in-out;
+ color: var(--icon-base);
}
+ &:hover,
&[data-expanded] {
&[data-variant="secondary"] {
background-color: var(--button-secondary-hover);
@@ -30,13 +36,13 @@
background-color: var(--icon-strong-active);
}
}
-
+ &:not([data-expanded]):focus,
&:not([data-expanded]):focus-visible {
&[data-variant="secondary"] {
background-color: var(--button-secondary-base);
}
&[data-variant="ghost"] {
- background-color: var(--surface-raised-base-hover);
+ background-color: transparent;
}
&[data-variant="primary"] {
background-color: var(--icon-strong-base);
@@ -46,10 +52,10 @@
&[data-trigger-style="settings"] {
[data-slot="select-select-trigger"] {
- padding: 6px 6px 6px 12px;
+ padding: 6px 6px 6px 10px;
box-shadow: none;
border-radius: 6px;
- min-width: 160px;
+ field-sizing: content;
height: 32px;
justify-content: flex-end;
gap: 12px;
@@ -61,6 +67,7 @@
white-space: nowrap;
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
+ padding: 4px 8px 4px 4px;
}
[data-slot="select-select-trigger-icon"] {
width: 16px;
@@ -91,17 +98,26 @@
}
[data-component="select-content"] {
- min-width: 104px;
+ min-width: 8rem;
max-width: 23rem;
overflow: hidden;
border-radius: var(--radius-md);
background-color: var(--surface-raised-stronger-non-alpha);
padding: 4px;
box-shadow: var(--shadow-xs-border);
- z-index: 60;
+ z-index: 50;
+ transform-origin: var(--kb-popper-content-transform-origin);
+ pointer-events: none;
+
+ animation: selectContentHide var(--transition-duration) var(--transition-easing) forwards;
+
+ @starting-style {
+ animation: none;
+ }
&[data-expanded] {
- animation: select-open 0.15s ease-out;
+ pointer-events: auto;
+ animation: selectContentShow var(--transition-duration) var(--transition-easing) forwards;
}
[data-slot="select-select-content-list"] {
@@ -111,43 +127,38 @@
overflow-x: hidden;
display: flex;
flex-direction: column;
-
&:focus {
outline: none;
}
-
> *:not([role="presentation"]) + *:not([role="presentation"]) {
margin-top: 2px;
}
}
-
[data-slot="select-select-item"] {
position: relative;
display: flex;
align-items: center;
- padding: 2px 8px;
+ padding: 4px 8px;
gap: 12px;
- border-radius: 4px;
- cursor: default;
+ border-radius: var(--radius-sm);
/* text-12-medium */
font-family: var(--font-family-sans);
- font-size: var(--font-size-small);
+ font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
-
color: var(--text-strong);
- transition:
- background-color 0.2s ease-in-out,
- color 0.2s ease-in-out;
+ transition-property: background-color, color;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
outline: none;
user-select: none;
- &[data-highlighted] {
- background: var(--surface-raised-base-hover);
+ &:hover {
+ background-color: var(--surface-raised-base-hover);
}
&[data-disabled] {
background-color: var(--surface-raised-base);
@@ -160,6 +171,11 @@
margin-left: auto;
width: 16px;
height: 16px;
+ color: var(--icon-strong-base);
+
+ svg {
+ color: var(--icon-strong-base);
+ }
}
&:focus {
outline: none;
@@ -171,13 +187,9 @@
}
[data-component="select-content"][data-trigger-style="settings"] {
- min-width: 160px;
+ field-sizing: content;
border-radius: 8px;
- padding: 0;
-
- [data-slot="select-select-content-list"] {
- padding: 4px;
- }
+ padding: 0 0 0 4px;
[data-slot="select-select-item"] {
/* text-14-regular */
@@ -190,13 +202,24 @@
}
}
-@keyframes select-open {
+@keyframes selectContentShow {
from {
opacity: 0;
- transform: scale(0.95);
+ transform: scaleY(0.95);
}
to {
opacity: 1;
- transform: scale(1);
+ transform: scaleY(1);
+ }
+}
+
+@keyframes selectContentHide {
+ from {
+ opacity: 1;
+ transform: scaleY(1);
+ }
+ to {
+ opacity: 0;
+ transform: scaleY(0.95);
}
}
diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx
index 0386c329ec..fef00500a7 100644
--- a/packages/ui/src/components/select.tsx
+++ b/packages/ui/src/components/select.tsx
@@ -1,8 +1,10 @@
import { Select as Kobalte } from "@kobalte/core/select"
-import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js"
+import { createMemo, createSignal, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js"
import { pipe, groupBy, entries, map } from "remeda"
+import { Show } from "solid-js"
import { Button, ButtonProps } from "./button"
import { Icon } from "./icon"
+import { MorphChevron } from "./morph-chevron"
export type SelectProps = Omit>, "value" | "onSelect" | "children"> & {
placeholder?: string
@@ -38,6 +40,8 @@ export function Select(props: SelectProps & Omit)
"triggerVariant",
])
+ const [isOpen, setIsOpen] = createSignal(false)
+
const state = {
key: undefined as string | undefined,
cleanup: undefined as (() => void) | void,
@@ -85,7 +89,7 @@ export function Select(props: SelectProps & Omit)
data-component="select"
data-trigger-style={local.triggerVariant}
placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"}
- gutter={4}
+ gutter={8}
value={local.current}
options={grouped()}
optionValue={(x) => (local.value ? local.value(x) : (x as string))}
@@ -115,7 +119,7 @@ export function Select(props: SelectProps & Omit)
: (itemProps.item.rawValue as string)}
-
+
)}
@@ -124,6 +128,7 @@ export function Select(props: SelectProps & Omit)
stop()
}}
onOpenChange={(open) => {
+ setIsOpen(open)
local.onOpenChange?.(open)
if (!open) stop()
}}
@@ -149,7 +154,12 @@ export function Select(props: SelectProps & Omit)
}}
-
+
+
+
+
+
+
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index f1c62c0aff..48d6337edb 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -398,6 +398,8 @@ export function SessionTurn(
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
const retry = createMemo(() => {
+ // session_status is session-scoped; only show retry on the active (last) turn
+ if (!isLastUserMessage()) return
const s = status()
if (s.type !== "retry") return
return s
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index 3ed0310ef2..2a8171f98c 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -40,6 +40,7 @@
@import "../components/select.css" layer(components);
@import "../components/spinner.css" layer(components);
@import "../components/switch.css" layer(components);
+@import "../components/scroll-fade.css" layer(components);
@import "../components/session-review.css" layer(components);
@import "../components/session-turn.css" layer(components);
@import "../components/sticky-accordion-header.css" layer(components);
@@ -48,6 +49,8 @@
@import "../components/toast.css" layer(components);
@import "../components/tooltip.css" layer(components);
@import "../components/typewriter.css" layer(components);
+@import "../components/morph-chevron.css" layer(components);
+@import "../components/reasoning-icon.css" layer(components);
@import "./utilities.css" layer(utilities);
@import "./animations.css" layer(utilities);
diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css
index 8c954f1fe4..82a913c883 100644
--- a/packages/ui/src/styles/utilities.css
+++ b/packages/ui/src/styles/utilities.css
@@ -1,6 +1,17 @@
:root {
interpolate-size: allow-keywords;
+ /* Transition tokens */
+ --transition-duration: 200ms;
+ --transition-easing: cubic-bezier(0.25, 0, 0.5, 1);
+ --transition-fast: 150ms;
+ --transition-slow: 300ms;
+
+ /* Allow height transitions from 0 to auto */
+ @supports (interpolate-size: allow-keywords) {
+ interpolate-size: allow-keywords;
+ }
+
[data-popper-positioner] {
pointer-events: none;
}
@@ -129,3 +140,34 @@
line-height: var(--line-height-x-large); /* 120% */
letter-spacing: var(--letter-spacing-tightest);
}
+
+/* Transition utility classes */
+.transition-colors {
+ transition-property: background-color, border-color, color, fill, stroke;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
+}
+
+.transition-opacity {
+ transition-property: opacity;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
+}
+
+.transition-transform {
+ transition-property: transform;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
+}
+
+.transition-shadow {
+ transition-property: box-shadow;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
+}
+
+.transition-interactive {
+ transition-property: background-color, border-color, color, box-shadow, opacity;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
+}
diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs
index eed1b87fd7..acaaf12bee 100644
--- a/packages/web/astro.config.mjs
+++ b/packages/web/astro.config.mjs
@@ -85,6 +85,7 @@ export default defineConfig({
"network",
"enterprise",
"troubleshooting",
+ "windows-wsl",
"1-0",
{
label: "Usage",
diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx
index 07110dc1b5..9f84c6af17 100644
--- a/packages/web/src/content/docs/ecosystem.mdx
+++ b/packages/web/src/content/docs/ecosystem.mdx
@@ -15,38 +15,38 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
## Plugins
-| Name | Description |
-| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
-| [opencode-daytona](https://github.com/jamesmurdza/daytona/tree/main/libs/opencode-plugin) | Automatically run OpenCode sessions in isolated Daytona sandboxes with git sync and live previews |
-| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping |
-| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
-| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits |
-| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing |
-| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing |
-| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) | Multi-branch devcontainer isolation with shallow clones and auto-assigned ports |
-| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling |
-| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs |
-| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style |
-| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. |
-| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
-| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
-| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
-| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
-| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
-| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
-| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events |
-| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context |
-| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection |
-| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory |
-| [@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 |
-| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence |
-| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | Native OS notifications for OpenCode – know when tasks complete |
-| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness – 16 components, one install |
-| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode |
+| Name | Description |
+| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
+| [opencode-daytona](https://github.com/jamesmurdza/daytona/blob/main/guides/typescript/opencode/README.md) | Automatically run OpenCode sessions in isolated Daytona sandboxes with git sync and live previews |
+| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping |
+| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
+| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits |
+| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing |
+| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing |
+| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) | Multi-branch devcontainer isolation with shallow clones and auto-assigned ports |
+| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling |
+| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs |
+| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style |
+| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. |
+| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
+| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
+| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
+| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
+| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
+| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
+| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events |
+| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context |
+| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection |
+| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory |
+| [@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 |
+| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence |
+| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | Native OS notifications for OpenCode – know when tasks complete |
+| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness – 16 components, one install |
+| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode |
---
diff --git a/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/index.mdx
index 8b3d3a9c82..bb3b8cb5d0 100644
--- a/packages/web/src/content/docs/index.mdx
+++ b/packages/web/src/content/docs/index.mdx
@@ -89,6 +89,10 @@ You can also install it with the following commands:
#### Windows
+:::tip[Recommended: Use WSL]
+For the best experience on Windows, we recommend using [Windows Subsystem for Linux (WSL)](/docs/windows-wsl). It provides better performance and full compatibility with OpenCode's features.
+:::
+
- **Using Chocolatey**
```bash
diff --git a/packages/web/src/content/docs/troubleshooting.mdx b/packages/web/src/content/docs/troubleshooting.mdx
index 7137d88fae..40ac70b9eb 100644
--- a/packages/web/src/content/docs/troubleshooting.mdx
+++ b/packages/web/src/content/docs/troubleshooting.mdx
@@ -136,6 +136,12 @@ On Windows, OpenCode Desktop requires the Microsoft Edge **WebView2 Runtime**. I
---
+### Windows: General performance issues
+
+If you're experiencing slow performance, file access issues, or terminal problems on Windows, try using [WSL (Windows Subsystem for Linux)](/docs/windows-wsl). WSL provides a Linux environment that works more seamlessly with OpenCode's features.
+
+---
+
### Notifications not showing
OpenCode Desktop only shows system notifications when:
diff --git a/packages/web/src/content/docs/web.mdx b/packages/web/src/content/docs/web.mdx
index fa3d071090..1013712f3a 100644
--- a/packages/web/src/content/docs/web.mdx
+++ b/packages/web/src/content/docs/web.mdx
@@ -21,6 +21,10 @@ This starts a local server on `127.0.0.1` with a random available port and autom
If `OPENCODE_SERVER_PASSWORD` is not set, the server will be unsecured. This is fine for local use but should be set for network access.
:::
+:::tip[Windows Users]
+For the best experience, run `opencode web` from [WSL](/docs/windows-wsl) rather than PowerShell. This ensures proper file system access and terminal integration.
+:::
+
---
## Configuration
diff --git a/packages/web/src/content/docs/windows-wsl.mdx b/packages/web/src/content/docs/windows-wsl.mdx
new file mode 100644
index 0000000000..ebc35d0d9e
--- /dev/null
+++ b/packages/web/src/content/docs/windows-wsl.mdx
@@ -0,0 +1,113 @@
+---
+title: Windows (WSL)
+description: Run OpenCode on Windows using WSL for the best experience.
+---
+
+import { Steps } from "@astrojs/starlight/components"
+
+While OpenCode can run directly on Windows, we recommend using [Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install) for the best experience. WSL provides a Linux environment that works seamlessly with OpenCode's features.
+
+:::tip[Why WSL?]
+WSL offers better file system performance, full terminal support, and compatibility with development tools that OpenCode relies on.
+:::
+
+---
+
+## Setup
+
+
+
+1. **Install WSL**
+
+ If you haven't already, [install WSL](https://learn.microsoft.com/en-us/windows/wsl/install) using the official Microsoft guide.
+
+2. **Install OpenCode in WSL**
+
+ Once WSL is set up, open your WSL terminal and install OpenCode using one of the [installation methods](/docs/).
+
+ ```bash
+ curl -fsSL https://opencode.ai/install | bash
+ ```
+
+3. **Use OpenCode from WSL**
+
+ Navigate to your project directory (access Windows files via `/mnt/c/`, `/mnt/d/`, etc.) and run OpenCode.
+
+ ```bash
+ cd /mnt/c/Users/YourName/project
+ opencode
+ ```
+
+
+
+---
+
+## Desktop App + WSL Server
+
+If you prefer using the OpenCode Desktop app but want to run the server in WSL:
+
+1. **Start the server in WSL** with `--hostname 0.0.0.0` to allow external connections:
+
+ ```bash
+ opencode serve --hostname 0.0.0.0 --port 4096
+ ```
+
+2. **Connect the Desktop app** to `http://localhost:4096`
+
+:::note
+If `localhost` does not work in your setup, connect using the WSL IP address instead (from WSL: `hostname -I`) and use `http://:4096`.
+:::
+
+:::caution
+When using `--hostname 0.0.0.0`, set `OPENCODE_SERVER_PASSWORD` to secure the server.
+
+```bash
+OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0
+```
+
+:::
+
+---
+
+## Web Client + WSL
+
+For the best web experience on Windows:
+
+1. **Run `opencode web` in the WSL terminal** rather than PowerShell:
+
+ ```bash
+ opencode web --hostname 0.0.0.0
+ ```
+
+2. **Access from your Windows browser** at `http://localhost:` (OpenCode prints the URL)
+
+Running `opencode web` from WSL ensures proper file system access and terminal integration while still being accessible from your Windows browser.
+
+---
+
+## Accessing Windows Files
+
+WSL can access all your Windows files through the `/mnt/` directory:
+
+- `C:` drive → `/mnt/c/`
+- `D:` drive → `/mnt/d/`
+- And so on...
+
+Example:
+
+```bash
+cd /mnt/c/Users/YourName/Documents/project
+opencode
+```
+
+:::tip
+For the smoothest experience, consider cloning/copying your repo into the WSL filesystem (for example under `~/code/`) and running OpenCode there.
+:::
+
+---
+
+## Tips
+
+- Keep OpenCode running in WSL for projects stored on Windows drives - file access is seamless
+- Use VS Code's [WSL extension](https://code.visualstudio.com/docs/remote/wsl) alongside OpenCode for an integrated development workflow
+- Your OpenCode config and sessions are stored within the WSL environment at `~/.local/share/opencode/`
diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx
index ddaabbef09..27f4c229c5 100644
--- a/packages/web/src/content/docs/zen.mdx
+++ b/packages/web/src/content/docs/zen.mdx
@@ -156,7 +156,7 @@ Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don
The free models:
- GLM 4.7 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
-- Kimi M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
+- Kimi K2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- MiniMax M2.1 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
diff --git a/script/beta.ts b/script/beta.ts
index 7a3dfcccf4..53329e4dce 100755
--- a/script/beta.ts
+++ b/script/beta.ts
@@ -1,87 +1,112 @@
#!/usr/bin/env bun
+import { $ } from "bun"
+
interface PR {
number: number
title: string
+ author: { login: string }
+ labels: Array<{ name: string }>
}
-interface RunResult {
- exitCode: number
- stdout: string
- stderr: string
+interface FailedPR {
+ number: number
+ title: string
+ reason: string
+}
+
+async function commentOnPR(prNumber: number, reason: string) {
+ const body = `⚠️ **Blocking Beta Release**
+
+This PR cannot be merged into the beta branch due to: **${reason}**
+
+Please resolve this issue to include this PR in the next beta release.`
+
+ try {
+ await $`gh pr comment ${prNumber} --body ${body}`
+ console.log(` Posted comment on PR #${prNumber}`)
+ } catch (err) {
+ console.log(` Failed to post comment on PR #${prNumber}: ${err}`)
+ }
}
async function main() {
- console.log("Fetching open contributor PRs...")
+ console.log("Fetching open PRs with beta label...")
- const prsResult = await $`gh pr list --label contributor --state open --json number,title --limit 100`.nothrow()
- if (prsResult.exitCode !== 0) {
- throw new Error(`Failed to fetch PRs: ${prsResult.stderr}`)
+ const stdout = await $`gh pr list --state open --label beta --json number,title,author,labels --limit 100`.text()
+ const prs: PR[] = JSON.parse(stdout)
+
+ console.log(`Found ${prs.length} open PRs with beta label`)
+
+ if (prs.length === 0) {
+ console.log("No team PRs to merge")
+ return
}
- const prs: PR[] = JSON.parse(prsResult.stdout)
- console.log(`Found ${prs.length} open contributor PRs`)
-
console.log("Fetching latest dev branch...")
- const fetchDev = await $`git fetch origin dev`.nothrow()
- if (fetchDev.exitCode !== 0) {
- throw new Error(`Failed to fetch dev branch: ${fetchDev.stderr}`)
- }
+ await $`git fetch origin dev`
console.log("Checking out beta branch...")
- const checkoutBeta = await $`git checkout -B beta origin/dev`.nothrow()
- if (checkoutBeta.exitCode !== 0) {
- throw new Error(`Failed to checkout beta branch: ${checkoutBeta.stderr}`)
- }
+ await $`git checkout -B beta origin/dev`
const applied: number[] = []
- const skipped: Array<{ number: number; reason: string }> = []
+ const failed: FailedPR[] = []
for (const pr of prs) {
console.log(`\nProcessing PR #${pr.number}: ${pr.title}`)
console.log(" Fetching PR head...")
- const fetch = await run(["git", "fetch", "origin", `pull/${pr.number}/head:pr/${pr.number}`])
- if (fetch.exitCode !== 0) {
- console.log(` Failed to fetch PR head: ${fetch.stderr}`)
- skipped.push({ number: pr.number, reason: `Fetch failed: ${fetch.stderr}` })
+ try {
+ await $`git fetch origin pull/${pr.number}/head:pr/${pr.number}`
+ } catch (err) {
+ console.log(` Failed to fetch: ${err}`)
+ failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" })
+ await commentOnPR(pr.number, "Fetch failed")
continue
}
console.log(" Merging...")
- const merge = await run(["git", "merge", "--no-commit", "--no-ff", `pr/${pr.number}`])
- if (merge.exitCode !== 0) {
+ try {
+ await $`git merge --no-commit --no-ff pr/${pr.number}`
+ } catch {
console.log(" Failed to merge (conflicts)")
- await $`git merge --abort`.nothrow()
- await $`git checkout -- .`.nothrow()
- await $`git clean -fd`.nothrow()
- skipped.push({ number: pr.number, reason: "Has conflicts" })
+ try {
+ await $`git merge --abort`
+ } catch {}
+ try {
+ await $`git checkout -- .`
+ } catch {}
+ try {
+ await $`git clean -fd`
+ } catch {}
+ failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" })
+ await commentOnPR(pr.number, "Merge conflicts with dev branch")
continue
}
- const mergeHead = await $`git rev-parse -q --verify MERGE_HEAD`.nothrow()
- if (mergeHead.exitCode !== 0) {
+ try {
+ await $`git rev-parse -q --verify MERGE_HEAD`.text()
+ } catch {
console.log(" No changes, skipping")
- skipped.push({ number: pr.number, reason: "No changes" })
continue
}
- const add = await $`git add -A`.nothrow()
- if (add.exitCode !== 0) {
- console.log(" Failed to stage")
- await $`git checkout -- .`.nothrow()
- await $`git clean -fd`.nothrow()
- skipped.push({ number: pr.number, reason: "Failed to stage" })
+ try {
+ await $`git add -A`
+ } catch {
+ console.log(" Failed to stage changes")
+ failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" })
+ await commentOnPR(pr.number, "Failed to stage changes")
continue
}
const commitMsg = `Apply PR #${pr.number}: ${pr.title}`
- const commit = await run(["git", "commit", "-m", commitMsg])
- if (commit.exitCode !== 0) {
- console.log(` Failed to commit: ${commit.stderr}`)
- await $`git checkout -- .`.nothrow()
- await $`git clean -fd`.nothrow()
- skipped.push({ number: pr.number, reason: `Commit failed: ${commit.stderr}` })
+ try {
+ await $`git commit -m ${commitMsg}`
+ } catch (err) {
+ console.log(` Failed to commit: ${err}`)
+ failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" })
+ await commentOnPR(pr.number, "Failed to commit changes")
continue
}
@@ -92,14 +117,15 @@ async function main() {
console.log("\n--- Summary ---")
console.log(`Applied: ${applied.length} PRs`)
applied.forEach((num) => console.log(` - PR #${num}`))
- console.log(`Skipped: ${skipped.length} PRs`)
- skipped.forEach((x) => console.log(` - PR #${x.number}: ${x.reason}`))
+
+ if (failed.length > 0) {
+ console.log(`Failed: ${failed.length} PRs`)
+ failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`))
+ throw new Error(`${failed.length} PR(s) failed to merge`)
+ }
console.log("\nForce pushing beta branch...")
- const push = await $`git push origin beta --force --no-verify`.nothrow()
- if (push.exitCode !== 0) {
- throw new Error(`Failed to push beta branch: ${push.stderr}`)
- }
+ await $`git push origin beta --force --no-verify`
console.log("Successfully synced beta branch")
}
@@ -108,31 +134,3 @@ main().catch((err) => {
console.error("Error:", err)
process.exit(1)
})
-
-async function run(args: string[], stdin?: Uint8Array): Promise {
- const proc = Bun.spawn(args, {
- stdin: stdin ?? "inherit",
- stdout: "pipe",
- stderr: "pipe",
- })
- const exitCode = await proc.exited
- const stdout = await new Response(proc.stdout).text()
- const stderr = await new Response(proc.stderr).text()
- return { exitCode, stdout, stderr }
-}
-
-function $(strings: TemplateStringsArray, ...values: unknown[]) {
- const cmd = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "")
- return {
- async nothrow() {
- const proc = Bun.spawn(cmd.split(" "), {
- stdout: "pipe",
- stderr: "pipe",
- })
- const exitCode = await proc.exited
- const stdout = await new Response(proc.stdout).text()
- const stderr = await new Response(proc.stderr).text()
- return { exitCode, stdout, stderr }
- },
- }
-}
diff --git a/script/changelog.ts b/script/changelog.ts
index 0043cd3d62..5fc30a228b 100755
--- a/script/changelog.ts
+++ b/script/changelog.ts
@@ -3,20 +3,7 @@
import { $ } from "bun"
import { createOpencode } from "@opencode-ai/sdk/v2"
import { parseArgs } from "util"
-
-export const team = [
- "actions-user",
- "opencode",
- "rekram1-node",
- "thdxr",
- "kommander",
- "jayair",
- "fwang",
- "adamdotdevin",
- "iamdavidhill",
- "opencode-agent[bot]",
- "R44VC0RP",
-]
+import { Script } from "@opencode-ai/script"
type Release = {
tag_name: string
@@ -191,7 +178,7 @@ export async function generateChangelog(commits: Commit[], opencode: Awaited