diff --git a/.opencode/skill/bun-file-io/SKILL.md b/.opencode/skill/bun-file-io/SKILL.md index ea39507d2..f78de3309 100644 --- a/.opencode/skill/bun-file-io/SKILL.md +++ b/.opencode/skill/bun-file-io/SKILL.md @@ -32,6 +32,9 @@ description: Use this when you are working on file operations like reading, writ - Decode tool stderr with `Bun.readableStreamToText`. - For large writes, use `Bun.write(Bun.file(path), text)`. +NOTE: Bun.file(...).exists() will return `false` if the value is a directory. +Use Filesystem.exists(...) instead if path can be file or directory + ## Quick checklist - Use Bun APIs first. diff --git a/bun.lock b/bun.lock index 03b0819d0..119fc77d0 100644 --- a/bun.lock +++ b/bun.lock @@ -268,8 +268,8 @@ "@opencode-ai/script": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.77", - "@opentui/solid": "0.1.77", + "@opentui/core": "0.1.79", + "@opentui/solid": "0.1.79", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -444,7 +444,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.8", + "@types/bun": "1.3.9", "@types/luxon": "3.7.1", "@types/node": "22.13.9", "@types/semver": "7.7.1", @@ -1013,21 +1013,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], - "@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="], + "@opentui/core": ["@opentui/core@0.1.79", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.79", "@opentui/core-darwin-x64": "0.1.79", "@opentui/core-linux-arm64": "0.1.79", "@opentui/core-linux-x64": "0.1.79", "@opentui/core-win32-arm64": "0.1.79", "@opentui/core-win32-x64": "0.1.79", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-job/t09w8A/aHb/WuaVbimu5fIffyN+PCuVO5cYhXEg/NkOkC/WdFi80B8bwncR/DBPyLAh6oJ3EG86grOVo5g=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.79", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kgsGniV+DM5G1P3GideyJhvfnthNKcVCAm2mPTIr9InQ3L0gS/Feh7zgwOS/jxDvdlQbOWGKMk2Z3JApeC1MLw=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.79", "", { "os": "darwin", "cpu": "x64" }, "sha512-OpyAmFqAAKQ2CeFmf/oLWcNksmP6Ryx/3R5dbKXThOudMCeQvfvInJTRbc2jTn9VFpf+Qj4BgHkJg1h90tf/EA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.79", "", { "os": "linux", "cpu": "arm64" }, "sha512-DCa5YaknS4bWhFt8TMEGH+qmTinyzuY8hoZbO4crtWXAxofPP7Pas76Cwxlvis/PyLffA+pPxAl1l5sUZpsvqw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.79", "", { "os": "linux", "cpu": "x64" }, "sha512-V6xjvFfHh3NGvsuuDae1KHPRZXHMEE8XL0A/GM6v4I4OCC23kDmkK60Vn6OptQwAzwwbz0X0IX+Ut/GQU9qGgA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.79", "", { "os": "win32", "cpu": "arm64" }, "sha512-sPRKnVzOdT5szI59tte7pxwwkYA+07EQN+6miFAvkFuiLmRUngONUD8HVjL7nCnxcPFqxaU4Rvl1y40ST86g8g=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.79", "", { "os": "win32", "cpu": "x64" }, "sha512-vmQcFTvKf9fqajnDtgU6/uAsiTGwx8//khqHVBmiTEXUsiT792Ki9l8sgNughbuldqG5iZOiF6IaAWU1H67UpA=="], - "@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="], + "@opentui/solid": ["@opentui/solid@0.1.79", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.79", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-c5+0jexKxb8GwRDDkQ/U6isZZqClAzHccXmYiLYmSnqdoQQp2lIGHLartL+K8lfIQrsKClzP2ZHumN6nexRfRg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -1401,7 +1401,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/diff": ["@types/diff@6.0.0", "", {}, "sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA=="], @@ -1613,7 +1613,7 @@ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], - "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], diff --git a/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json b/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json deleted file mode 100644 index 41cb01a2b..000000000 --- a/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "keep": { - "days": true, - "amount": 14 - }, - "auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json", - "files": [ - { - "date": 1759827172859, - "name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log", - "hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16" - } - ], - "hashType": "sha256" -} diff --git a/package.json b/package.json index 0ee668635..83dd9ca8d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.8", + "packageManager": "bun@1.3.9", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop tauri dev", @@ -21,7 +21,7 @@ "packages/sdk/js" ], "catalog": { - "@types/bun": "1.3.8", + "@types/bun": "1.3.9", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", "ulid": "3.0.1", diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 0f778b518..232b7557d 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -2,6 +2,7 @@ import { onCleanup, onMount } from "solid-js" import { showToast } from "@opencode-ai/ui/toast" import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt" import { useLanguage } from "@/context/language" +import { uuid } from "@/utils/uuid" import { getCursorPosition } from "./editor-dom" export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] @@ -31,7 +32,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const dataUrl = reader.result as string const attachment: ImageAttachmentPart = { type: "image", - id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2), + id: uuid(), filename: file.name, mime: file.type, dataUrl, diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index b91f029bc..a88ea0d86 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { useParams } from "@solidjs/router" import { Persist, persisted } from "@/utils/persist" import { createScopedCache } from "@/utils/scoped-cache" +import { uuid } from "@/utils/uuid" import type { SelectedLineRange } from "@/context/file" export type LineComment = { @@ -53,7 +54,7 @@ function createCommentSessionState(store: Store, setStore: SetStor const add = (input: Omit) => { const next: LineComment = { - id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2), + id: uuid(), time: Date.now(), ...input, } diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index b63bec58f..f9e287050 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSync } from "./global-sync" import { useGlobalSDK } from "./global-sdk" import { useServer } from "./server" +import { usePlatform } from "./platform" import { Project } from "@kilocode/sdk/v2" import { Persist, persisted, removePersisted } from "@/utils/persist" import { same } from "@/utils/same" @@ -90,6 +91,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const globalSdk = useGlobalSDK() const globalSync = useGlobalSync() const server = useServer() + const platform = usePlatform() const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) @@ -200,10 +202,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( for (const entry of SESSION_STATE_KEYS) { const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key) - void removePersisted(target) + void removePersisted(target, platform) const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}` - void removePersisted({ key: legacyKey }) + void removePersisted({ key: legacyKey }, platform) } } } diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index d00c792e0..c7816158c 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -3,6 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" +import type { Platform } from "./platform" import { Persist, persisted, removePersisted } from "@/utils/persist" export type LocalPTY = { @@ -37,14 +38,14 @@ type TerminalCacheEntry = { const caches = new Set>() -export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[]) { +export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) { const key = getWorkspaceTerminalCacheKey(dir) for (const cache of caches) { const entry = cache.get(key) entry?.value.clear() } - removePersisted(Persist.workspace(dir, "terminal")) + removePersisted(Persist.workspace(dir, "terminal"), platform) const legacy = new Set(getLegacyTerminalStorageKeys(dir)) for (const id of sessionIDs ?? []) { @@ -53,7 +54,7 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[]) { } } for (const key of legacy) { - removePersisted({ key }) + removePersisted({ key }, platform) } } diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index ce435113c..aa54f1be9 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -132,7 +132,8 @@ export const dict = { "provider.connect.oauth.code.invalid": "رمز التفويض غير صالح", "provider.connect.oauth.auto.visit.prefix": "قم بزيارة ", "provider.connect.oauth.auto.visit.link": "هذا الرابط", - "provider.connect.oauth.auto.visit.suffix": " وأدخل الرمز أدناه لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.", + "provider.connect.oauth.auto.visit.suffix": + " وأدخل الرمز أدناه لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.", "provider.connect.oauth.auto.confirmationCode": "رمز التأكيد", "provider.connect.toast.connected.title": "تم توصيل {{provider}}", "provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 2feb0d5bd..f7bf84e1c 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -385,7 +385,8 @@ export const dict = { "toast.session.unshare.failed.description": "Une erreur s'est produite lors de l'annulation du partage de la session", "toast.session.listFailed.title": "Échec du chargement des sessions pour {{project}}", "toast.update.title": "Mise à jour disponible", - "toast.update.description": "Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.", + "toast.update.description": + "Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.", "toast.update.action.installRestart": "Installer et redémarrer", "toast.update.action.notYet": "Pas encore", "error.page.title": "Quelque chose s'est mal passé", @@ -516,7 +517,8 @@ export const dict = { "sidebar.workspaces.enable": "Activer les espaces de travail", "sidebar.workspaces.disable": "Désactiver les espaces de travail", "sidebar.gettingStarted.title": "Commencer", - "sidebar.gettingStarted.line1": "Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.", + "sidebar.gettingStarted.line1": + "Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.", "sidebar.gettingStarted.line2": "Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Sessions récentes", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index c595b67d2..8de06487e 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -144,7 +144,8 @@ export const dict = { "provider.connect.oauth.code.invalid": "รหัสการอนุญาตไม่ถูกต้อง", "provider.connect.oauth.auto.visit.prefix": "เยี่ยมชม ", "provider.connect.oauth.auto.visit.link": "ลิงก์นี้", - "provider.connect.oauth.auto.visit.suffix": " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo", + "provider.connect.oauth.auto.visit.suffix": + " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo", "provider.connect.oauth.auto.confirmationCode": "รหัสยืนยัน", "provider.connect.toast.connected.title": "{{provider}} ที่เชื่อมต่อแล้ว", "provider.connect.toast.connected.description": "โมเดล {{provider}} พร้อมใช้งานแล้ว", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 741b32464..d8cb85408 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -145,7 +145,8 @@ export const dict = { "provider.connect.oauth.code.invalid": "授權碼無效", "provider.connect.oauth.auto.visit.prefix": "造訪 ", "provider.connect.oauth.auto.visit.link": "此連結", - "provider.connect.oauth.auto.visit.suffix": " 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。", + "provider.connect.oauth.auto.visit.suffix": + " 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。", "provider.connect.oauth.auto.confirmationCode": "確認碼", "provider.connect.toast.connected.title": "{{provider}} 已連線", "provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index cb7dec2b7..92e35250d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1203,6 +1203,16 @@ export default function Layout(props: ParentProps) { if (!result) return + globalSync.set( + "project", + produce((draft) => { + const project = draft.find((item) => item.worktree === root) + if (!project) return + project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => sandbox !== directory) + }), + ) + setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory)) + layout.projects.close(directory) layout.projects.open(root) @@ -1230,6 +1240,7 @@ export default function Layout(props: ParentProps) { clearWorkspaceTerminals( directory, sessions.map((s) => s.id), + platform, ) await globalSDK.client.instance.dispose({ directory }).catch(() => undefined) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 9a676376d..8f365f1a7 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -32,7 +32,9 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
0 && props.notify }} diff --git a/packages/app/src/utils/perf.ts b/packages/app/src/utils/perf.ts index 0ecbc33ff..105d02a82 100644 --- a/packages/app/src/utils/perf.ts +++ b/packages/app/src/utils/perf.ts @@ -1,3 +1,5 @@ +import { uuid } from "@/utils/uuid" + type Nav = { id: string dir?: string @@ -16,8 +18,6 @@ const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}` const now = () => performance.now() -const uid = () => crypto.randomUUID?.() ?? Math.random().toString(16).slice(2) - const navs = new Map() const pending = new Map() const active = new Map() @@ -94,7 +94,7 @@ function ensure(id: string, data: Omit) { export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) { if (!dev) return - const id = uid() + const id = uuid() const start = now() const nav = ensure(id, { ...input, id, start }) nav.marks["navigate:start"] = start @@ -109,7 +109,7 @@ export function navParams(input: { dir?: string; from?: string; to: string }) { const k = key(input.dir, input.to) const pendingId = pending.get(k) if (pendingId) pending.delete(k) - const id = pendingId ?? uid() + const id = pendingId ?? uuid() const start = now() const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" }) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 57e01d86a..91c504742 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -1,4 +1,4 @@ -import { usePlatform } from "@/context/platform" +import { Platform, usePlatform } from "@/context/platform" import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage" import { checksum } from "@opencode-ai/util/encode" import { createResource, type Accessor } from "solid-js" @@ -318,9 +318,8 @@ export const Persist = { }, } -export function removePersisted(target: { storage?: string; key: string }) { - const platform = usePlatform() - const isDesktop = platform.platform === "desktop" && !!platform.storage +export function removePersisted(target: { storage?: string; key: string }, platform?: Platform) { + const isDesktop = platform?.platform === "desktop" && !!platform.storage if (isDesktop) { return platform.storage?.(target.storage)?.removeItem(target.key) diff --git a/packages/app/src/utils/uuid.test.ts b/packages/app/src/utils/uuid.test.ts new file mode 100644 index 000000000..e6b4e2824 --- /dev/null +++ b/packages/app/src/utils/uuid.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { uuid } from "./uuid" + +const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto") +const secureDescriptor = Object.getOwnPropertyDescriptor(globalThis, "isSecureContext") +const randomDescriptor = Object.getOwnPropertyDescriptor(Math, "random") + +const setCrypto = (value: Partial) => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: value as Crypto, + }) +} + +const setSecure = (value: boolean) => { + Object.defineProperty(globalThis, "isSecureContext", { + configurable: true, + value, + }) +} + +const setRandom = (value: () => number) => { + Object.defineProperty(Math, "random", { + configurable: true, + value, + }) +} + +afterEach(() => { + if (cryptoDescriptor) { + Object.defineProperty(globalThis, "crypto", cryptoDescriptor) + } + + if (secureDescriptor) { + Object.defineProperty(globalThis, "isSecureContext", secureDescriptor) + } + + if (!secureDescriptor) { + delete (globalThis as { isSecureContext?: boolean }).isSecureContext + } + + if (randomDescriptor) { + Object.defineProperty(Math, "random", randomDescriptor) + } +}) + +describe("uuid", () => { + test("uses randomUUID in secure contexts", () => { + setCrypto({ randomUUID: () => "00000000-0000-0000-0000-000000000000" }) + setSecure(true) + expect(uuid()).toBe("00000000-0000-0000-0000-000000000000") + }) + + test("falls back in insecure contexts", () => { + setCrypto({ randomUUID: () => "00000000-0000-0000-0000-000000000000" }) + setSecure(false) + setRandom(() => 0.5) + expect(uuid()).toBe("8") + }) + + test("falls back when randomUUID throws", () => { + setCrypto({ + randomUUID: () => { + throw new DOMException("Failed", "OperationError") + }, + }) + setSecure(true) + setRandom(() => 0.5) + expect(uuid()).toBe("8") + }) + + test("falls back when randomUUID is unavailable", () => { + setCrypto({}) + setSecure(true) + setRandom(() => 0.5) + expect(uuid()).toBe("8") + }) +}) diff --git a/packages/app/src/utils/uuid.ts b/packages/app/src/utils/uuid.ts new file mode 100644 index 000000000..7b964068c --- /dev/null +++ b/packages/app/src/utils/uuid.ts @@ -0,0 +1,12 @@ +const fallback = () => Math.random().toString(16).slice(2) + +export function uuid() { + const c = globalThis.crypto + if (!c || typeof c.randomUUID !== "function") return fallback() + if (typeof globalThis.isSecureContext === "boolean" && !globalThis.isSecureContext) return fallback() + try { + return c.randomUUID() + } catch { + return fallback() + } +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 85294dd91..950a08015 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -85,8 +85,8 @@ "@kilocode/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.77", - "@opentui/solid": "0.1.77", + "@opentui/core": "0.1.79", + "@opentui/solid": "0.1.79", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4d2051bc4..05164e7ec 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -3,6 +3,7 @@ import { Clipboard } from "@tui/util/clipboard" import { TextAttributes } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" +import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" import { Installation } from "@/installation" import { Flag } from "@/flag/flag" import { DialogProvider, useDialog } from "@tui/ui/dialog" @@ -114,8 +115,17 @@ export function tui(input: { }) { // promise to prevent immediate exit return new Promise(async (resolve) => { + const unguard = win32InstallCtrlCGuard() + win32DisableProcessedInput() + const mode = await getTerminalBackgroundColor() + + // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores + // the original console mode which re-enables ENABLE_PROCESSED_INPUT. + win32DisableProcessedInput() + const onExit = async () => { + unguard?.() await input.onExit?.() resolve() } @@ -741,7 +751,8 @@ function ErrorComponent(props: { const handleExit = async () => { renderer.setTerminalTitle("") renderer.destroy() - props.onExit() + win32FlushInputBuffer() + await props.onExit() } useKeyboard((evt) => { diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 8fed2b6a6..69de6517f 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -1,5 +1,6 @@ import { cmd } from "../cmd" import { tui } from "./app" +import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" export const AttachCommand = cmd({ command: "attach ", @@ -26,27 +27,34 @@ export const AttachCommand = cmd({ describe: "basic auth password (defaults to KILO_SERVER_PASSWORD)", }), handler: async (args) => { - const directory = (() => { - if (!args.dir) return undefined - try { - process.chdir(args.dir) - return process.cwd() - } catch { - // If the directory doesn't exist locally (remote attach), pass it through. - return args.dir - } - })() - const headers = (() => { - const password = args.password ?? process.env.KILO_SERVER_PASSWORD - if (!password) return undefined - const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` - return { Authorization: auth } - })() - await tui({ - url: args.url, - args: { sessionID: args.session }, - directory, - headers, - }) + const unguard = win32InstallCtrlCGuard() + try { + win32DisableProcessedInput() + + const directory = (() => { + if (!args.dir) return undefined + try { + process.chdir(args.dir) + return process.cwd() + } catch { + // If the directory doesn't exist locally (remote attach), pass it through. + return args.dir + } + })() + const headers = (() => { + const password = args.password ?? process.env.KILO_SERVER_PASSWORD + if (!password) return undefined + const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` + return { Authorization: auth } + })() + await tui({ + url: args.url, + args: { sessionID: args.session }, + directory, + headers, + }) + } finally { + unguard?.() + } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx index 2aac15220..3eb2edf72 100644 --- a/packages/opencode/src/cli/cmd/tui/context/exit.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/exit.tsx @@ -1,6 +1,7 @@ import { useRenderer } from "@opentui/solid" import { createSimpleContext } from "./helper" import { FormatError, FormatUnknownError } from "@/cli/error" +import { win32FlushInputBuffer } from "../win32" type Exit = ((reason?: unknown) => Promise) & { message: { set: (value?: string) => () => void @@ -32,6 +33,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({ // Reset window title before destroying renderer renderer.setTerminalTitle("") renderer.destroy() + win32FlushInputBuffer() await input.onExit?.() if (reason) { const formatted = FormatError(reason) ?? FormatUnknownError(reason) 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 c2c49b3c9..54f152db7 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -151,6 +151,7 @@ export function Session() { const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) + const [showHeader, setShowHeader] = kv.signal("header_visible", true) const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) @@ -596,6 +597,15 @@ export function Session() { dialog.clear() }, }, + { + title: showHeader() ? "Hide header" : "Show header", + value: "session.toggle.header", + category: "Session", + onSelect: (dialog) => { + setShowHeader((prev) => !prev) + dialog.clear() + }, + }, { title: "Page up", value: "session.page.up", @@ -977,7 +987,7 @@ export function Session() { - +
{ - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") - process.exit(1) - } - - // Resolve relative paths against PWD to preserve behavior when using --cwd flag - const baseCwd = process.env.PWD ?? process.cwd() - const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd() - const localWorker = new URL("./worker.ts", import.meta.url) - const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url) - const workerPath = await iife(async () => { - if (typeof KILO_WORKER_PATH !== "undefined") return KILO_WORKER_PATH // kilocode_change - if (await Bun.file(distWorker).exists()) return distWorker - return localWorker - }) + // Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it. + // (Important when running under `bun run` wrappers on Windows.) + const unguard = win32InstallCtrlCGuard() try { - process.chdir(cwd) - } catch (e) { - UI.error("Failed to change directory to " + cwd) - return + // Must be the very first thing — disables CTRL_C_EVENT before any Worker + // spawn or async work so the OS cannot kill the process group. + win32DisableProcessedInput() + + if (args.fork && !args.continue && !args.session) { + UI.error("--fork requires --continue or --session") + process.exitCode = 1 + return + } + + // Resolve relative paths against PWD to preserve behavior when using --cwd flag + const baseCwd = process.env.PWD ?? process.cwd() + const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd() + const localWorker = new URL("./worker.ts", import.meta.url) + const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url) + const workerPath = await iife(async () => { + if (typeof KILO_WORKER_PATH !== "undefined") return KILO_WORKER_PATH + if (await Bun.file(distWorker).exists()) return distWorker + return localWorker + }) + try { + process.chdir(cwd) + } catch (e) { + UI.error("Failed to change directory to " + cwd) + return + } + + const worker = new Worker(workerPath, { + env: Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + }) + worker.onerror = (e) => { + Log.Default.error(e) + } + const client = Rpc.client(worker) + process.on("uncaughtException", (e) => { + Log.Default.error(e) + }) + process.on("unhandledRejection", (e) => { + Log.Default.error(e) + }) + process.on("SIGUSR2", async () => { + await client.call("reload", undefined) + }) + + const prompt = await iife(async () => { + const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined + if (!args.prompt) return piped + return piped ? piped + "\n" + args.prompt : args.prompt + }) + + // Check if server should be started (port or hostname explicitly set in CLI or config) + const networkOpts = await resolveNetworkOptions(args) + const shouldStartServer = + process.argv.includes("--port") || + process.argv.includes("--hostname") || + process.argv.includes("--mdns") || + networkOpts.mdns || + networkOpts.port !== 0 || + networkOpts.hostname !== "127.0.0.1" + + let url: string + let customFetch: typeof fetch | undefined + let events: EventSource | undefined + + if (shouldStartServer) { + // Start HTTP server for external access + const server = await client.call("server", networkOpts) + url = server.url + } else { + // Use direct RPC communication (no HTTP) + url = "http://opencode.internal" + customFetch = createWorkerFetch(client) + events = createEventSource(client) + } + + const tuiPromise = tui({ + url, + fetch: customFetch, + events, + args: { + continue: args.continue, + sessionID: args.session, + agent: args.agent, + model: args.model, + prompt, + fork: args.fork, + }, + onExit: async () => { + await client.call("shutdown", undefined) + }, + }) + + setTimeout(() => { + client.call("checkUpgrade", { directory: cwd }).catch(() => {}) + }, 1000) + + await tuiPromise + } finally { + unguard?.() } - - const worker = new Worker(workerPath, { - env: Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ), - }) - worker.onerror = (e) => { - Log.Default.error(e) - } - const client = Rpc.client(worker) - process.on("uncaughtException", (e) => { - Log.Default.error(e) - }) - process.on("unhandledRejection", (e) => { - Log.Default.error(e) - }) - process.on("SIGUSR2", async () => { - await client.call("reload", undefined) - }) - - const prompt = await iife(async () => { - const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined - if (!args.prompt) return piped - return piped ? piped + "\n" + args.prompt : args.prompt - }) - - // Check if server should be started (port or hostname explicitly set in CLI or config) - const networkOpts = await resolveNetworkOptions(args) - const shouldStartServer = - process.argv.includes("--port") || - process.argv.includes("--hostname") || - process.argv.includes("--mdns") || - networkOpts.mdns || - networkOpts.port !== 0 || - networkOpts.hostname !== "127.0.0.1" - - let url: string - let customFetch: typeof fetch | undefined - let events: EventSource | undefined - - if (shouldStartServer) { - // Start HTTP server for external access - const server = await client.call("server", networkOpts) - url = server.url - } else { - // Use direct RPC communication (no HTTP) - url = "http://opencode.internal" - customFetch = createWorkerFetch(client) - events = createEventSource(client) - } - - const tuiPromise = tui({ - url, - fetch: customFetch, - events, - args: { - continue: args.continue, - sessionID: args.session, - agent: args.agent, - model: args.model, - prompt, - fork: args.fork, - }, - onExit: async () => { - await client.call("shutdown", undefined) - }, - }) - - setTimeout(() => { - client.call("checkUpgrade", { directory: cwd }).catch(() => {}) - }, 1000) - - await tuiPromise }, }) diff --git a/packages/opencode/src/cli/cmd/tui/win32.ts b/packages/opencode/src/cli/cmd/tui/win32.ts new file mode 100644 index 000000000..23e9f4485 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/win32.ts @@ -0,0 +1,129 @@ +import { dlopen, ptr } from "bun:ffi" + +const STD_INPUT_HANDLE = -10 +const ENABLE_PROCESSED_INPUT = 0x0001 + +const kernel = () => + dlopen("kernel32.dll", { + GetStdHandle: { args: ["i32"], returns: "ptr" }, + GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" }, + SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" }, + FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" }, + }) + +let k32: ReturnType | undefined + +function load() { + if (process.platform !== "win32") return false + try { + k32 ??= kernel() + return true + } catch { + return false + } +} + +/** + * Clear ENABLE_PROCESSED_INPUT on the console stdin handle. + */ +export function win32DisableProcessedInput() { + if (process.platform !== "win32") return + if (!process.stdin.isTTY) return + if (!load()) return + + const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) + const buf = new Uint32Array(1) + if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return + + const mode = buf[0]! + if ((mode & ENABLE_PROCESSED_INPUT) === 0) return + k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT) +} + +/** + * Discard any queued console input (mouse events, key presses, etc.). + */ +export function win32FlushInputBuffer() { + if (process.platform !== "win32") return + if (!process.stdin.isTTY) return + if (!load()) return + + const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) + k32!.symbols.FlushConsoleInputBuffer(handle) +} + +let unhook: (() => void) | undefined + +/** + * Keep ENABLE_PROCESSED_INPUT disabled. + * + * On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when + * ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes + * (sometimes on a later tick), and the flag is console-global, not per-process. + * + * We combine: + * - A `setRawMode(...)` hook to re-clear after known raw-mode toggles. + * - A low-frequency poll as a backstop for native/external mode changes. + */ +export function win32InstallCtrlCGuard() { + if (process.platform !== "win32") return + if (!process.stdin.isTTY) return + if (!load()) return + if (unhook) return unhook + + const stdin = process.stdin as any + const original = stdin.setRawMode + + const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) + const buf = new Uint32Array(1) + + if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return + const initial = buf[0]! + + const enforce = () => { + if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return + const mode = buf[0]! + if ((mode & ENABLE_PROCESSED_INPUT) === 0) return + k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT) + } + + // Some runtimes can re-apply console modes on the next tick; enforce twice. + const later = () => { + enforce() + setImmediate(enforce) + } + + let wrapped: ((mode: boolean) => unknown) | undefined + + if (typeof original === "function") { + wrapped = (mode: boolean) => { + const result = original.call(stdin, mode) + later() + return result + } + + stdin.setRawMode = wrapped + } + + // Ensure it's cleared immediately too (covers any earlier mode changes). + later() + + const interval = setInterval(enforce, 100) + interval.unref() + + let done = false + unhook = () => { + if (done) return + done = true + + clearInterval(interval) + if (wrapped && stdin.setRawMode === wrapped) { + stdin.setRawMode = original + } + + k32!.symbols.SetConsoleMode(handle, initial) + unhook = undefined + } + + return unhook +} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 29e958fe3..3c29fe03d 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -571,25 +571,28 @@ export namespace MCP { const clientsSnapshot = await clients() const defaultTimeout = cfg.experimental?.mcp_timeout - for (const [clientName, client] of Object.entries(clientsSnapshot)) { - // Only include tools from connected MCPs (skip disabled ones) - if (s.status[clientName]?.status !== "connected") { - continue - } + const connectedClients = Object.entries(clientsSnapshot).filter( + ([clientName]) => s.status[clientName]?.status === "connected", + ) - const toolsResult = await client.listTools().catch((e) => { - log.error("failed to get tools", { clientName, error: e.message }) - const failedStatus = { - status: "failed" as const, - error: e instanceof Error ? e.message : String(e), - } - s.status[clientName] = failedStatus - delete s.clients[clientName] - return undefined - }) - if (!toolsResult) { - continue - } + const toolsResults = await Promise.all( + connectedClients.map(async ([clientName, client]) => { + const toolsResult = await client.listTools().catch((e) => { + log.error("failed to get tools", { clientName, error: e.message }) + const failedStatus = { + status: "failed" as const, + error: e instanceof Error ? e.message : String(e), + } + s.status[clientName] = failedStatus + delete s.clients[clientName] + return undefined + }) + return { clientName, client, toolsResult } + }), + ) + + for (const { clientName, client, toolsResult } of toolsResults) { + if (!toolsResult) continue const mcpConfig = config[clientName] const entry = isMcpConfigured(mcpConfig) ? mcpConfig : undefined const timeout = entry?.timeout ?? defaultTimeout diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 10360e918..0c6047064 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -82,7 +82,7 @@ export namespace ModelsDev { status: z.enum(["alpha", "beta", "deprecated"]).optional(), options: z.record(z.string(), z.any()), headers: z.record(z.string(), z.string()).optional(), - provider: z.object({ npm: z.string() }).optional(), + provider: z.object({ npm: z.string(), api: z.string() }).optional(), variants: z.record(z.string(), z.record(z.string(), z.any())).optional(), }) export type Model = z.infer diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 169d18bed..94b8874e2 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -648,7 +648,7 @@ export namespace Provider { family: model.family, api: { id: model.id, - url: provider.api!, + url: model.provider?.api ?? provider.api!, npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible", }, status: model.status ?? "active", @@ -800,7 +800,7 @@ export namespace Provider { existingModel?.api.npm ?? modelsDev[providerID]?.npm ?? "@ai-sdk/openai-compatible", - url: provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api, + url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api, }, status: model.status ?? existingModel?.status ?? "active", name, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f42fbf4b1..349809e75 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -539,6 +539,8 @@ export namespace ProviderTransform { // https://v5.ai-sdk.dev/providers/ai-sdk-providers/xai case "@ai-sdk/deepinfra": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra + case "venice-ai-sdk-provider": + // https://docs.venice.ai/overview/guides/reasoning-models#reasoning-effort case "@ai-sdk/openai-compatible": return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 5660ca973..924542605 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -41,7 +41,9 @@ export namespace SessionCompaction { const reserved = config.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model)) - const usable = input.model.limit.input ? input.model.limit.input - reserved : context - reserved + const usable = input.model.limit.input + ? input.model.limit.input - reserved + : context - ProviderTransform.maxOutputTokens(input.model) return count >= usable } diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index fcea39330..340f86e9e 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -44,6 +44,7 @@ export namespace LLM { small?: boolean tools: Record retries?: number + toolChoice?: "auto" | "required" | "none" } export type StreamOutput = StreamTextResult @@ -221,6 +222,7 @@ export namespace LLM { providerOptions: ProviderTransform.providerOptions(input.model, params.options), activeTools: Object.keys(tools).filter((x) => x !== "invalid"), tools, + toolChoice: input.toolChoice, maxOutputTokens, abortSignal: input.abort, headers: { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 63159ecc5..70763548c 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -15,6 +15,13 @@ import type { Provider } from "@/provider/provider" export namespace MessageV2 { export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) + export const StructuredOutputError = NamedError.create( + "StructuredOutputError", + z.object({ + message: z.string(), + retries: z.number(), + }), + ) export const AuthError = NamedError.create( "ProviderAuthError", z.object({ @@ -39,6 +46,29 @@ export namespace MessageV2 { z.object({ message: z.string(), responseBody: z.string().optional() }), ) + export const OutputFormatText = z + .object({ + type: z.literal("text"), + }) + .meta({ + ref: "OutputFormatText", + }) + + export const OutputFormatJsonSchema = z + .object({ + type: z.literal("json_schema"), + schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }), + retryCount: z.number().int().min(0).default(2), + }) + .meta({ + ref: "OutputFormatJsonSchema", + }) + + export const Format = z.discriminatedUnion("type", [OutputFormatText, OutputFormatJsonSchema]).meta({ + ref: "OutputFormat", + }) + export type OutputFormat = z.infer + const PartBase = z.object({ id: z.string(), sessionID: z.string(), @@ -313,6 +343,7 @@ export namespace MessageV2 { time: z.object({ created: z.number(), }), + format: Format.optional(), summary: z .object({ title: z.string().optional(), @@ -365,6 +396,7 @@ export namespace MessageV2 { NamedError.Unknown.Schema, OutputLengthError.Schema, AbortedError.Schema, + StructuredOutputError.Schema, ContextOverflowError.Schema, APIError.Schema, ]) @@ -393,6 +425,7 @@ export namespace MessageV2 { write: z.number(), }), }), + structured: z.any().optional(), variant: z.string().optional(), finish: z.string().optional(), }).meta({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b8ef83057..7312b7bce 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -50,6 +50,16 @@ import { Truncate } from "@/tool/truncation" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false +const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. + +IMPORTANT: +- You MUST call this tool exactly once at the end of your response +- The input must be valid JSON matching the required schema +- Complete all necessary research and tool calls BEFORE calling this tool +- This tool provides your final answer - no further actions are taken after calling it` + +const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.` + export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) @@ -96,6 +106,7 @@ export namespace SessionPrompt { .describe( "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", ), + format: MessageV2.Format.optional(), system: z.string().optional(), variant: z.string().optional(), parts: z.array( @@ -285,6 +296,11 @@ export namespace SessionPrompt { }) // kilocode_change end + // Structured output state + // Note: On session resumption, state is reset but outputFormat is preserved + // on the user message and will be retrieved from lastUser below + let structuredOutput: unknown | undefined + let step = 0 const session = await Session.get(sessionID) while (true) { @@ -603,6 +619,16 @@ export namespace SessionPrompt { messages: msgs, }) + // Inject StructuredOutput tool if JSON schema mode enabled + if (lastUser.format?.type === "json_schema") { + tools["StructuredOutput"] = createStructuredOutputTool({ + schema: lastUser.format.schema, + onSuccess(output) { + structuredOutput = output + }, + }) + } + if (step === 1) { SessionSummary.summarize({ sessionID: sessionID, @@ -633,12 +659,19 @@ export namespace SessionPrompt { await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) + // Build system prompt, adding structured output instruction if needed + const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())] + const format = lastUser.format ?? { type: "text" } + if (format.type === "json_schema") { + system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) + } + const result = await processor.process({ user: lastUser, agent, abort, sessionID, - system: [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())], + system, messages: [ ...MessageV2.toModelMessages(sessionMessages, model), ...(isLastStep @@ -652,7 +685,33 @@ export namespace SessionPrompt { ], tools, model, + toolChoice: format.type === "json_schema" ? "required" : undefined, }) + + // If structured output was captured, save it and exit immediately + // This takes priority because the StructuredOutput tool was called successfully + if (structuredOutput !== undefined) { + processor.message.structured = structuredOutput + processor.message.finish = processor.message.finish ?? "stop" + await Session.updateMessage(processor.message) + break + } + + // Check if model finished (finish reason is not "tool-calls" or "unknown") + const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish) + + if (modelFinished && !processor.message.error) { + if (format.type === "json_schema") { + // Model stopped without calling StructuredOutput tool + processor.message.error = new MessageV2.StructuredOutputError({ + message: "Model did not produce structured output", + retries: 0, + }).toObject() + await Session.updateMessage(processor.message) + break + } + } + // kilocode_change start if (result === "stop") { if (abort.aborted || processor.message.error?.name === "MessageAbortedError") closeReason = "interrupted" @@ -692,7 +751,8 @@ export namespace SessionPrompt { return Provider.defaultModel() } - async function resolveTools(input: { + /** @internal Exported for testing */ + export async function resolveTools(input: { agent: Agent.Info model: Provider.Model session: Session.Info @@ -872,6 +932,36 @@ export namespace SessionPrompt { return tools } + /** @internal Exported for testing */ + export function createStructuredOutputTool(input: { + schema: Record + onSuccess: (output: unknown) => void + }): AITool { + // Remove $schema property if present (not needed for tool input) + const { $schema, ...toolSchema } = input.schema + + return tool({ + id: "StructuredOutput" as any, + description: STRUCTURED_OUTPUT_DESCRIPTION, + inputSchema: jsonSchema(toolSchema as any), + async execute(args) { + // AI SDK validates args against inputSchema before calling execute() + input.onSuccess(args) + return { + output: "Structured output captured successfully.", + title: "Structured Output", + metadata: { valid: true }, + } + }, + toModelOutput(result) { + return { + type: "text", + value: result.output, + } + }, + }) + } + async function createUserMessage(input: PromptInput) { const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) @@ -893,6 +983,7 @@ export namespace SessionPrompt { agent: agent.name, model, system: input.system, + format: input.format, variant, } using _ = defer(() => InstructionPrompt.clear(info.id)) @@ -1045,9 +1136,9 @@ export namespace SessionPrompt { } } } - offset = Math.max(start - 1, 0) + offset = Math.max(start, 1) if (end) { - limit = end - offset + limit = end - (offset - 1) } } const args = { filePath: filepath, offset, limit } diff --git a/packages/opencode/src/tool/edit.txt b/packages/opencode/src/tool/edit.txt index 863efb840..618fd5ad1 100644 --- a/packages/opencode/src/tool/edit.txt +++ b/packages/opencode/src/tool/edit.txt @@ -2,9 +2,9 @@ Performs exact string replacements in files. Usage: - You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. -- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the oldString or newString. +- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. - The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content". -- The edit will FAIL if `oldString` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`. +- The edit will FAIL if `oldString` is found multiple times in the file with an error "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match." Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`. - Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index f230cdf44..7f5a9a9bd 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -17,19 +17,26 @@ const MAX_BYTES = 50 * 1024 export const ReadTool = Tool.define("read", { description: DESCRIPTION, parameters: z.object({ - filePath: z.string().describe("The path to the file to read"), - offset: z.coerce.number().describe("The line number to start reading from (0-based)").optional(), - limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(), + filePath: z.string().describe("The absolute path to the file or directory to read"), + offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(), + limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(), }), async execute(params, ctx) { + if (params.offset !== undefined && params.offset < 1) { + throw new Error("offset must be greater than or equal to 1") + } let filepath = params.filePath if (!path.isAbsolute(filepath)) { filepath = path.resolve(Instance.directory, filepath) } const title = path.relative(Instance.worktree, filepath) + const file = Bun.file(filepath) + const stat = await file.stat().catch(() => undefined) + await assertExternalDirectory(ctx, filepath, { bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), + kind: stat?.isDirectory() ? "directory" : "file", }) await ctx.ask({ @@ -39,8 +46,7 @@ export const ReadTool = Tool.define("read", { metadata: {}, }) - const file = Bun.file(filepath) - if (!(await file.exists())) { + if (!stat) { const dir = path.dirname(filepath) const base = path.basename(filepath) @@ -60,6 +66,48 @@ export const ReadTool = Tool.define("read", { throw new Error(`File not found: ${filepath}`) } + if (stat.isDirectory()) { + const dirents = await fs.promises.readdir(filepath, { withFileTypes: true }) + const entries = await Promise.all( + dirents.map(async (dirent) => { + if (dirent.isDirectory()) return dirent.name + "/" + if (dirent.isSymbolicLink()) { + const target = await fs.promises.stat(path.join(filepath, dirent.name)).catch(() => undefined) + if (target?.isDirectory()) return dirent.name + "/" + } + return dirent.name + }), + ) + entries.sort((a, b) => a.localeCompare(b)) + + const limit = params.limit ?? DEFAULT_READ_LIMIT + const offset = params.offset ?? 1 + const start = offset - 1 + const sliced = entries.slice(start, start + limit) + const truncated = start + sliced.length < entries.length + + const output = [ + `${filepath}`, + `directory`, + ``, + sliced.join("\n"), + truncated + ? `\n(Showing ${sliced.length} of ${entries.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})` + : `\n(${entries.length} entries)`, + ``, + ].join("\n") + + return { + title, + output, + metadata: { + preview: sliced.slice(0, 20).join("\n"), + truncated, + loaded: [] as string[], + }, + } + } + const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID) // Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files) @@ -75,7 +123,7 @@ export const ReadTool = Tool.define("read", { metadata: { preview: msg, truncated: false, - ...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }), + loaded: instructions.map((i) => i.filepath), }, attachments: [ { @@ -94,13 +142,15 @@ export const ReadTool = Tool.define("read", { if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`) const limit = params.limit ?? DEFAULT_READ_LIMIT - const offset = params.offset || 0 + const offset = params.offset ?? 1 + const start = offset - 1 const lines = await file.text().then((text) => text.split("\n")) + if (start >= lines.length) throw new Error(`Offset ${offset} is out of range for this file (${lines.length} lines)`) const raw: string[] = [] let bytes = 0 let truncatedByBytes = false - for (let i = offset; i < Math.min(lines.length, offset + limit); i++) { + for (let i = start; i < Math.min(lines.length, start + limit); i++) { const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i] const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0) if (bytes + size > MAX_BYTES) { @@ -112,15 +162,15 @@ export const ReadTool = Tool.define("read", { } const content = raw.map((line, index) => { - return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}` + return `${index + offset}: ${line}` }) const preview = raw.slice(0, 20).join("\n") - let output = "\n" + let output = [`${filepath}`, `file`, ""].join("\n") output += content.join("\n") const totalLines = lines.length - const lastReadLine = offset + raw.length + const lastReadLine = offset + raw.length - 1 const hasMoreLines = totalLines > lastReadLine const truncated = hasMoreLines || truncatedByBytes @@ -131,7 +181,7 @@ export const ReadTool = Tool.define("read", { } else { output += `\n\n(End of file - total ${totalLines} lines)` } - output += "\n" + output += "\n" // just warms the lsp client LSP.touchFile(filepath, false) @@ -147,7 +197,7 @@ export const ReadTool = Tool.define("read", { metadata: { preview, truncated, - ...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }), + loaded: instructions.map((i) => i.filepath), }, } }, diff --git a/packages/opencode/src/tool/read.txt b/packages/opencode/src/tool/read.txt index b5bffee26..368174cc8 100644 --- a/packages/opencode/src/tool/read.txt +++ b/packages/opencode/src/tool/read.txt @@ -1,12 +1,14 @@ -Reads a file from the local filesystem. You can access any file directly by using this tool. -Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. +Read a file or directory from the local filesystem. If the path does not exist, an error is returned. Usage: -- The filePath parameter must be an absolute path, not a relative path -- By default, it reads up to 2000 lines starting from the beginning of the file -- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters -- Any lines longer than 2000 characters will be truncated -- Results are returned using cat -n format, with line numbers starting at 1 -- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. -- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. -- You can read image files using this tool. +- The filePath parameter should be an absolute path. +- By default, this tool returns up to 2000 lines from the start of the file. +- The offset parameter is the line number to start from (1-indexed). +- To read later sections, call this tool again with a larger offset. +- Use the grep tool to find specific content in large files or files with long lines. +- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern. +- Contents are returned with each line prefixed by its line number as `: `. For example, if a file has contents "foo\n", you will receive "1: foo\n". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories. +- Any line longer than 2000 characters is truncated. +- Call this tool in parallel when you know there are multiple files you want to read. +- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window. +- This tool can read image files and PDFs and return them as file attachments. diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts new file mode 100644 index 000000000..c9c543656 --- /dev/null +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" +import { MessageV2 } from "../../src/session/message-v2" + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +// Skip tests if no API key is available +const hasApiKey = !!process.env.ANTHROPIC_API_KEY + +// Helper to run test within Instance context +async function withInstance(fn: () => Promise): Promise { + return Instance.provide({ + directory: projectRoot, + fn, + }) +} + +describe("StructuredOutput Integration", () => { + test.skipIf(!hasApiKey)( + "produces structured output with simple schema", + async () => { + await withInstance(async () => { + const session = await Session.create({ title: "Structured Output Test" }) + + const result = await SessionPrompt.prompt({ + sessionID: session.id, + parts: [ + { + type: "text", + text: "What is 2 + 2? Provide a simple answer.", + }, + ], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { + answer: { type: "number", description: "The numerical answer" }, + explanation: { type: "string", description: "Brief explanation" }, + }, + required: ["answer"], + }, + retryCount: 0, + }, + }) + + // Verify structured output was captured (only on assistant messages) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.structured).toBeDefined() + expect(typeof result.info.structured).toBe("object") + + const output = result.info.structured as any + expect(output.answer).toBe(4) + + // Verify no error was set + expect(result.info.error).toBeUndefined() + } + + // Clean up + // Note: Not removing session to avoid race with background SessionSummary.summarize + }) + }, + 60000, + ) + + test.skipIf(!hasApiKey)( + "produces structured output with nested objects", + async () => { + await withInstance(async () => { + const session = await Session.create({ title: "Nested Schema Test" }) + + const result = await SessionPrompt.prompt({ + sessionID: session.id, + parts: [ + { + type: "text", + text: "Tell me about Anthropic company in a structured format.", + }, + ], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { + company: { + type: "object", + properties: { + name: { type: "string" }, + founded: { type: "number" }, + }, + required: ["name", "founded"], + }, + products: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["company"], + }, + retryCount: 0, + }, + }) + + // Verify structured output was captured (only on assistant messages) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.structured).toBeDefined() + const output = result.info.structured as any + + expect(output.company).toBeDefined() + expect(output.company.name).toBe("Anthropic") + expect(typeof output.company.founded).toBe("number") + + if (output.products) { + expect(Array.isArray(output.products)).toBe(true) + } + + // Verify no error was set + expect(result.info.error).toBeUndefined() + } + + // Clean up + // Note: Not removing session to avoid race with background SessionSummary.summarize + }) + }, + 60000, + ) + + test.skipIf(!hasApiKey)( + "works with text outputFormat (default)", + async () => { + await withInstance(async () => { + const session = await Session.create({ title: "Text Output Test" }) + + const result = await SessionPrompt.prompt({ + sessionID: session.id, + parts: [ + { + type: "text", + text: "Say hello.", + }, + ], + format: { + type: "text", + }, + }) + + // Verify no structured output (text mode) and no error + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.structured).toBeUndefined() + expect(result.info.error).toBeUndefined() + } + + // Verify we got a response with parts + expect(result.parts.length).toBeGreaterThan(0) + + // Clean up + // Note: Not removing session to avoid race with background SessionSummary.summarize + }) + }, + 60000, + ) + + test.skipIf(!hasApiKey)( + "stores outputFormat on user message", + async () => { + await withInstance(async () => { + const session = await Session.create({ title: "OutputFormat Storage Test" }) + + await SessionPrompt.prompt({ + sessionID: session.id, + parts: [ + { + type: "text", + text: "What is 1 + 1?", + }, + ], + format: { + type: "json_schema", + schema: { + type: "object", + properties: { + result: { type: "number" }, + }, + required: ["result"], + }, + retryCount: 3, + }, + }) + + // Get all messages from session + const messages = await Session.messages({ sessionID: session.id }) + const userMessage = messages.find((m) => m.info.role === "user") + + // Verify outputFormat was stored on user message + expect(userMessage).toBeDefined() + if (userMessage?.info.role === "user") { + expect(userMessage.info.format).toBeDefined() + expect(userMessage.info.format?.type).toBe("json_schema") + if (userMessage.info.format?.type === "json_schema") { + expect(userMessage.info.format.retryCount).toBe(3) + } + } + + // Clean up + // Note: Not removing session to avoid race with background SessionSummary.summarize + }) + }, + 60000, + ) + + test("unit test: StructuredOutputError is properly structured", () => { + const error = new MessageV2.StructuredOutputError({ + message: "Failed to produce valid structured output after 3 attempts", + retries: 3, + }) + + expect(error.name).toBe("StructuredOutputError") + expect(error.data.message).toContain("3 attempts") + expect(error.data.retries).toBe(3) + + const obj = error.toObject() + expect(obj.name).toBe("StructuredOutputError") + expect(obj.data.retries).toBe(3) + }) +}) diff --git a/packages/opencode/test/session/structured-output.test.ts b/packages/opencode/test/session/structured-output.test.ts new file mode 100644 index 000000000..2be4257dc --- /dev/null +++ b/packages/opencode/test/session/structured-output.test.ts @@ -0,0 +1,385 @@ +import { describe, expect, test } from "bun:test" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionPrompt } from "../../src/session/prompt" + +describe("structured-output.OutputFormat", () => { + test("parses text format", () => { + const result = MessageV2.Format.safeParse({ type: "text" }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.type).toBe("text") + } + }) + + test("parses json_schema format with defaults", () => { + const result = MessageV2.Format.safeParse({ + type: "json_schema", + schema: { type: "object", properties: { name: { type: "string" } } }, + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.type).toBe("json_schema") + if (result.data.type === "json_schema") { + expect(result.data.retryCount).toBe(2) // default value + } + } + }) + + test("parses json_schema format with custom retryCount", () => { + const result = MessageV2.Format.safeParse({ + type: "json_schema", + schema: { type: "object" }, + retryCount: 5, + }) + expect(result.success).toBe(true) + if (result.success && result.data.type === "json_schema") { + expect(result.data.retryCount).toBe(5) + } + }) + + test("rejects invalid type", () => { + const result = MessageV2.Format.safeParse({ type: "invalid" }) + expect(result.success).toBe(false) + }) + + test("rejects json_schema without schema", () => { + const result = MessageV2.Format.safeParse({ type: "json_schema" }) + expect(result.success).toBe(false) + }) + + test("rejects negative retryCount", () => { + const result = MessageV2.Format.safeParse({ + type: "json_schema", + schema: { type: "object" }, + retryCount: -1, + }) + expect(result.success).toBe(false) + }) +}) + +describe("structured-output.StructuredOutputError", () => { + test("creates error with message and retries", () => { + const error = new MessageV2.StructuredOutputError({ + message: "Failed to validate", + retries: 3, + }) + + expect(error.name).toBe("StructuredOutputError") + expect(error.data.message).toBe("Failed to validate") + expect(error.data.retries).toBe(3) + }) + + test("converts to object correctly", () => { + const error = new MessageV2.StructuredOutputError({ + message: "Test error", + retries: 2, + }) + + const obj = error.toObject() + expect(obj.name).toBe("StructuredOutputError") + expect(obj.data.message).toBe("Test error") + expect(obj.data.retries).toBe(2) + }) + + test("isInstance correctly identifies error", () => { + const error = new MessageV2.StructuredOutputError({ + message: "Test", + retries: 1, + }) + + expect(MessageV2.StructuredOutputError.isInstance(error)).toBe(true) + expect(MessageV2.StructuredOutputError.isInstance({ name: "other" })).toBe(false) + }) +}) + +describe("structured-output.UserMessage", () => { + test("user message accepts outputFormat", () => { + const result = MessageV2.User.safeParse({ + id: "test-id", + sessionID: "test-session", + role: "user", + time: { created: Date.now() }, + agent: "default", + model: { providerID: "anthropic", modelID: "claude-3" }, + outputFormat: { + type: "json_schema", + schema: { type: "object" }, + }, + }) + expect(result.success).toBe(true) + }) + + test("user message works without outputFormat (optional)", () => { + const result = MessageV2.User.safeParse({ + id: "test-id", + sessionID: "test-session", + role: "user", + time: { created: Date.now() }, + agent: "default", + model: { providerID: "anthropic", modelID: "claude-3" }, + }) + expect(result.success).toBe(true) + }) +}) + +describe("structured-output.AssistantMessage", () => { + const baseAssistantMessage = { + id: "test-id", + sessionID: "test-session", + role: "assistant" as const, + parentID: "parent-id", + modelID: "claude-3", + providerID: "anthropic", + mode: "default", + agent: "default", + path: { cwd: "/test", root: "/test" }, + cost: 0.001, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: Date.now() }, + } + + test("assistant message accepts structured", () => { + const result = MessageV2.Assistant.safeParse({ + ...baseAssistantMessage, + structured: { company: "Anthropic", founded: 2021 }, + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.structured).toEqual({ company: "Anthropic", founded: 2021 }) + } + }) + + test("assistant message works without structured_output (optional)", () => { + const result = MessageV2.Assistant.safeParse(baseAssistantMessage) + expect(result.success).toBe(true) + }) +}) + +describe("structured-output.createStructuredOutputTool", () => { + test("creates tool with correct id", () => { + const tool = SessionPrompt.createStructuredOutputTool({ + schema: { type: "object", properties: { name: { type: "string" } } }, + onSuccess: () => {}, + }) + + // AI SDK tool type doesn't expose id, but we set it internally + expect((tool as any).id).toBe("StructuredOutput") + }) + + test("creates tool with description", () => { + const tool = SessionPrompt.createStructuredOutputTool({ + schema: { type: "object" }, + onSuccess: () => {}, + }) + + expect(tool.description).toContain("structured format") + }) + + test("creates tool with schema as inputSchema", () => { + const schema = { + type: "object", + properties: { + company: { type: "string" }, + founded: { type: "number" }, + }, + required: ["company"], + } + + const tool = SessionPrompt.createStructuredOutputTool({ + schema, + onSuccess: () => {}, + }) + + // AI SDK wraps schema in { jsonSchema: {...} } + expect(tool.inputSchema).toBeDefined() + const inputSchema = tool.inputSchema as any + expect(inputSchema.jsonSchema?.properties?.company).toBeDefined() + expect(inputSchema.jsonSchema?.properties?.founded).toBeDefined() + }) + + test("strips $schema property from inputSchema", () => { + const schema = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { name: { type: "string" } }, + } + + const tool = SessionPrompt.createStructuredOutputTool({ + schema, + onSuccess: () => {}, + }) + + // AI SDK wraps schema in { jsonSchema: {...} } + const inputSchema = tool.inputSchema as any + expect(inputSchema.jsonSchema?.$schema).toBeUndefined() + }) + + test("execute calls onSuccess with valid args", async () => { + let capturedOutput: unknown + + const tool = SessionPrompt.createStructuredOutputTool({ + schema: { type: "object", properties: { name: { type: "string" } } }, + onSuccess: (output) => { + capturedOutput = output + }, + }) + + expect(tool.execute).toBeDefined() + const testArgs = { name: "Test Company" } + const result = await tool.execute!(testArgs, { + toolCallId: "test-call-id", + messages: [], + abortSignal: undefined as any, + }) + + expect(capturedOutput).toEqual(testArgs) + expect(result.output).toBe("Structured output captured successfully.") + expect(result.metadata.valid).toBe(true) + }) + + test("AI SDK validates schema before execute - missing required field", async () => { + // Note: The AI SDK validates the input against the schema BEFORE calling execute() + // So invalid inputs never reach the tool's execute function + // This test documents the expected schema behavior + const tool = SessionPrompt.createStructuredOutputTool({ + schema: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name", "age"], + }, + onSuccess: () => {}, + }) + + // The schema requires both 'name' and 'age' + expect(tool.inputSchema).toBeDefined() + const inputSchema = tool.inputSchema as any + expect(inputSchema.jsonSchema?.required).toContain("name") + expect(inputSchema.jsonSchema?.required).toContain("age") + }) + + test("AI SDK validates schema types before execute - wrong type", async () => { + // Note: The AI SDK validates the input against the schema BEFORE calling execute() + // So invalid inputs never reach the tool's execute function + // This test documents the expected schema behavior + const tool = SessionPrompt.createStructuredOutputTool({ + schema: { + type: "object", + properties: { + count: { type: "number" }, + }, + required: ["count"], + }, + onSuccess: () => {}, + }) + + // The schema defines 'count' as a number + expect(tool.inputSchema).toBeDefined() + const inputSchema = tool.inputSchema as any + expect(inputSchema.jsonSchema?.properties?.count?.type).toBe("number") + }) + + test("execute handles nested objects", async () => { + let capturedOutput: unknown + + const tool = SessionPrompt.createStructuredOutputTool({ + schema: { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name"], + }, + }, + required: ["user"], + }, + onSuccess: (output) => { + capturedOutput = output + }, + }) + + // Valid nested object - AI SDK validates before calling execute() + const validResult = await tool.execute!( + { user: { name: "John", email: "john@test.com" } }, + { + toolCallId: "test-call-id", + messages: [], + abortSignal: undefined as any, + }, + ) + + expect(capturedOutput).toEqual({ user: { name: "John", email: "john@test.com" } }) + expect(validResult.metadata.valid).toBe(true) + + // Verify schema has correct nested structure + const inputSchema = tool.inputSchema as any + expect(inputSchema.jsonSchema?.properties?.user?.type).toBe("object") + expect(inputSchema.jsonSchema?.properties?.user?.properties?.name?.type).toBe("string") + expect(inputSchema.jsonSchema?.properties?.user?.required).toContain("name") + }) + + test("execute handles arrays", async () => { + let capturedOutput: unknown + + const tool = SessionPrompt.createStructuredOutputTool({ + schema: { + type: "object", + properties: { + tags: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["tags"], + }, + onSuccess: (output) => { + capturedOutput = output + }, + }) + + // Valid array - AI SDK validates before calling execute() + const validResult = await tool.execute!( + { tags: ["a", "b", "c"] }, + { + toolCallId: "test-call-id", + messages: [], + abortSignal: undefined as any, + }, + ) + + expect(capturedOutput).toEqual({ tags: ["a", "b", "c"] }) + expect(validResult.metadata.valid).toBe(true) + + // Verify schema has correct array structure + const inputSchema = tool.inputSchema as any + expect(inputSchema.jsonSchema?.properties?.tags?.type).toBe("array") + expect(inputSchema.jsonSchema?.properties?.tags?.items?.type).toBe("string") + }) + + test("toModelOutput returns text value", () => { + const tool = SessionPrompt.createStructuredOutputTool({ + schema: { type: "object" }, + onSuccess: () => {}, + }) + + expect(tool.toModelOutput).toBeDefined() + const modelOutput = tool.toModelOutput!({ + output: "Test output", + title: "Test", + metadata: { valid: true }, + }) + + expect(modelOutput.type).toBe("text") + expect(modelOutput.value).toBe("Test output") + }) + + // Note: Retry behavior is handled by the AI SDK and the prompt loop, not the tool itself + // The tool simply calls onSuccess when execute() is called with valid args + // See prompt.ts loop() for actual retry logic +}) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index fd7f1631c..9102c18f6 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -78,6 +78,32 @@ describe("tool.read external_directory permission", () => { }) }) + test("asks for directory-scoped external_directory permission when reading external directory", async () => { + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "external", "a.txt"), "a") + }, + }) + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await read.execute({ filePath: path.join(outerTmp.path, "external") }, testCtx) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(path.join(outerTmp.path, "external", "*")) + }, + }) + }) + test("asks for external_directory permission when reading relative path outside project", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ @@ -234,7 +260,7 @@ describe("tool.read truncation", () => { test("respects offset parameter", async () => { await using tmp = await tmpdir({ init: async (dir) => { - const lines = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n") + const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n") await Bun.write(path.join(dir, "offset.txt"), lines) }, }) @@ -251,6 +277,43 @@ describe("tool.read truncation", () => { }) }) + test("throws when offset is beyond end of file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n") + await Bun.write(path.join(dir, "short.txt"), lines) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + await expect( + read.execute({ filePath: path.join(tmp.path, "short.txt"), offset: 4, limit: 5 }, ctx), + ).rejects.toThrow("Offset 4 is out of range for this file (3 lines)") + }, + }) + }) + + test("does not mark final directory page as truncated", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Promise.all( + Array.from({ length: 10 }, (_, i) => Bun.write(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`)), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "dir"), offset: 6, limit: 5 }, ctx) + expect(result.metadata.truncated).toBe(false) + expect(result.output).not.toContain("Showing 5 of 10 entries") + }, + }) + }) + test("truncates long lines", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 8f4101975..e8d276034 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -65,6 +65,7 @@ import type { McpLocalConfig, McpRemoteConfig, McpStatusResponses, + OutputFormat, Part as Part2, PartDeleteErrors, PartDeleteResponses, @@ -1481,6 +1482,7 @@ export class Session extends HeyApiClient { tools?: { [key: string]: boolean } + format?: OutputFormat system?: string variant?: string parts?: Array @@ -1499,6 +1501,7 @@ export class Session extends HeyApiClient { { in: "body", key: "agent" }, { in: "body", key: "noReply" }, { in: "body", key: "tools" }, + { in: "body", key: "format" }, { in: "body", key: "system" }, { in: "body", key: "variant" }, { in: "body", key: "parts" }, @@ -1569,6 +1572,7 @@ export class Session extends HeyApiClient { tools?: { [key: string]: boolean } + format?: OutputFormat system?: string variant?: string parts?: Array @@ -1587,6 +1591,7 @@ export class Session extends HeyApiClient { { in: "body", key: "agent" }, { in: "body", key: "noReply" }, { in: "body", key: "tools" }, + { in: "body", key: "format" }, { in: "body", key: "system" }, { in: "body", key: "variant" }, { in: "body", key: "parts" }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8d1be189b..92e551da3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -90,6 +90,22 @@ export type EventFileEdited = { } } +export type OutputFormatText = { + type: "text" +} + +export type JsonSchema = { + [key: string]: unknown +} + +export type OutputFormatJsonSchema = { + type: "json_schema" + schema: JsonSchema + retryCount?: number +} + +export type OutputFormat = OutputFormatText | OutputFormatJsonSchema + export type FileDiff = { file: string before: string @@ -106,6 +122,7 @@ export type UserMessage = { time: { created: number } + format?: OutputFormat summary?: { title?: string body?: string @@ -152,6 +169,14 @@ export type MessageAbortedError = { } } +export type StructuredOutputError = { + name: "StructuredOutputError" + data: { + message: string + retries: number + } +} + export type ContextOverflowError = { name: "ContextOverflowError" data: { @@ -189,6 +214,7 @@ export type AssistantMessage = { | UnknownError | MessageOutputLengthError | MessageAbortedError + | StructuredOutputError | ContextOverflowError | ApiError parentID: string @@ -212,6 +238,7 @@ export type AssistantMessage = { write: number } } + structured?: unknown variant?: string finish?: string } @@ -845,6 +872,7 @@ export type EventSessionError = { | UnknownError | MessageOutputLengthError | MessageAbortedError + | StructuredOutputError | ContextOverflowError | ApiError } @@ -1546,6 +1574,7 @@ export type ProviderConfig = { } provider?: { npm: string + api: string } /** * Variant-specific configuration @@ -3430,6 +3459,7 @@ export type SessionPromptData = { tools?: { [key: string]: boolean } + format?: OutputFormat system?: string variant?: string parts: Array @@ -3617,6 +3647,7 @@ export type SessionPromptAsyncData = { tools?: { [key: string]: boolean } + format?: OutputFormat system?: string variant?: string parts: Array @@ -4066,6 +4097,7 @@ export type ProviderListResponses = { } provider?: { npm: string + api: string } variants?: { [key: string]: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 2c218e7ef..a05779282 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2420,6 +2420,9 @@ "type": "boolean" } }, + "format": { + "$ref": "#/components/schemas/OutputFormat" + }, "system": { "type": "string" }, @@ -2796,6 +2799,9 @@ "type": "boolean" } }, + "format": { + "$ref": "#/components/schemas/OutputFormat" + }, "system": { "type": "string" }, @@ -3790,9 +3796,12 @@ "properties": { "npm": { "type": "string" + }, + "api": { + "type": "string" } }, - "required": ["npm"] + "required": ["npm", "api"] }, "variants": { "type": "object", @@ -6385,6 +6394,52 @@ }, "required": ["type", "properties"] }, + "OutputFormatText": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": ["type"] + }, + "JSONSchema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "OutputFormatJsonSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "json_schema" + }, + "schema": { + "$ref": "#/components/schemas/JSONSchema" + }, + "retryCount": { + "default": 2, + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["type", "schema"] + }, + "OutputFormat": { + "anyOf": [ + { + "$ref": "#/components/schemas/OutputFormatText" + }, + { + "$ref": "#/components/schemas/OutputFormatJsonSchema" + } + ] + }, "FileDiff": { "type": "object", "properties": { @@ -6432,6 +6487,9 @@ }, "required": ["created"] }, + "format": { + "$ref": "#/components/schemas/OutputFormat" + }, "summary": { "type": "object", "properties": { @@ -6557,6 +6615,28 @@ }, "required": ["name", "data"] }, + "StructuredOutputError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "StructuredOutputError" + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "retries": { + "type": "number" + } + }, + "required": ["message", "retries"] + } + }, + "required": ["name", "data"] + }, "ContextOverflowError": { "type": "object", "properties": { @@ -6664,6 +6744,9 @@ { "$ref": "#/components/schemas/MessageAbortedError" }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, { "$ref": "#/components/schemas/ContextOverflowError" }, @@ -6735,6 +6818,7 @@ }, "required": ["input", "output", "reasoning", "cache"] }, + "structured": {}, "variant": { "type": "string" }, @@ -8444,6 +8528,9 @@ { "$ref": "#/components/schemas/MessageAbortedError" }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, { "$ref": "#/components/schemas/ContextOverflowError" }, @@ -9686,9 +9773,12 @@ "properties": { "npm": { "type": "string" + }, + "api": { + "type": "string" } }, - "required": ["npm"] + "required": ["npm", "api"] }, "variants": { "description": "Variant-specific configuration", diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index f041eb485..7ee17e2e0 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -1,20 +1,21 @@ export const dict = { "ui.sessionReview.title": "تغييرات الجلسة", "ui.sessionReview.title.lastTurn": "تغييرات آخر دور", - "ui.sessionReview.diffStyle.unified": "موجد", + "ui.sessionReview.diffStyle.unified": "موحد", "ui.sessionReview.diffStyle.split": "منقسم", "ui.sessionReview.expandAll": "توسيع الكل", "ui.sessionReview.collapseAll": "طي الكل", - "ui.sessionReview.change.added": "مضاف", "ui.sessionReview.change.removed": "محذوف", "ui.sessionReview.change.modified": "معدل", + "ui.lineComment.label.prefix": "تعليق على ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "جارٍ التعليق على ", "ui.lineComment.editorLabel.suffix": "", "ui.lineComment.placeholder": "أضف تعليقًا", "ui.lineComment.submit": "تعليق", + "ui.sessionTurn.steps.show": "إظهار الخطوات", "ui.sessionTurn.steps.hide": "إخفاء الخطوات", "ui.sessionTurn.summary.response": "استجابة", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index b8765ff94..6d7449d84 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -5,16 +5,17 @@ export const dict = { "ui.sessionReview.diffStyle.split": "Dividido", "ui.sessionReview.expandAll": "Expandir tudo", "ui.sessionReview.collapseAll": "Recolher tudo", - "ui.sessionReview.change.added": "Adicionado", "ui.sessionReview.change.removed": "Removido", "ui.sessionReview.change.modified": "Modificado", + "ui.lineComment.label.prefix": "Comentar em ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "Comentando em ", "ui.lineComment.editorLabel.suffix": "", "ui.lineComment.placeholder": "Adicionar comentário", "ui.lineComment.submit": "Comentar", + "ui.sessionTurn.steps.show": "Mostrar passos", "ui.sessionTurn.steps.hide": "Ocultar passos", "ui.sessionTurn.summary.response": "Resposta", @@ -94,7 +95,7 @@ export const dict = { "ui.patch.action.deleted": "Excluído", "ui.patch.action.created": "Criado", "ui.patch.action.moved": "Movido", - "ui.patch.action.patched": "Aplicado patch", + "ui.patch.action.patched": "Patch aplicado", "ui.question.subtitle.answered": "{{count}} respondidas", "ui.question.answer.none": "(sem resposta)", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index a87cf795e..218f3b26a 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -63,8 +63,8 @@ export const dict = { "ui.tool.webfetch": "Webhentning", "ui.tool.shell": "Shell", "ui.tool.patch": "Patch", - "ui.tool.todos": "To-dos", - "ui.tool.todos.read": "Læs to-dos", + "ui.tool.todos": "Opgaver", + "ui.tool.todos.read": "Læs opgaver", "ui.tool.questions": "Spørgsmål", "ui.tool.agent": "{{type}} Agent", @@ -94,7 +94,7 @@ export const dict = { "ui.patch.action.deleted": "Slettet", "ui.patch.action.created": "Oprettet", "ui.patch.action.moved": "Flyttet", - "ui.patch.action.patched": "Patched", + "ui.patch.action.patched": "Patchet", "ui.question.subtitle.answered": "{{count}} besvaret", "ui.question.answer.none": "(intet svar)", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 7308fccad..4fd921b60 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -5,16 +5,17 @@ export const dict = { "ui.sessionReview.diffStyle.split": "Dividido", "ui.sessionReview.expandAll": "Expandir todo", "ui.sessionReview.collapseAll": "Colapsar todo", - "ui.sessionReview.change.added": "Añadido", "ui.sessionReview.change.removed": "Eliminado", "ui.sessionReview.change.modified": "Modificado", + "ui.lineComment.label.prefix": "Comentar en ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "Comentando en ", "ui.lineComment.editorLabel.suffix": "", "ui.lineComment.placeholder": "Añadir comentario", "ui.lineComment.submit": "Comentar", + "ui.sessionTurn.steps.show": "Mostrar pasos", "ui.sessionTurn.steps.hide": "Ocultar pasos", "ui.sessionTurn.summary.response": "Respuesta", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 4b6f28f0d..537d01bba 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -5,16 +5,17 @@ export const dict = { "ui.sessionReview.diffStyle.split": "Divisé", "ui.sessionReview.expandAll": "Tout développer", "ui.sessionReview.collapseAll": "Tout réduire", - "ui.sessionReview.change.added": "Ajouté", "ui.sessionReview.change.removed": "Supprimé", "ui.sessionReview.change.modified": "Modifié", + "ui.lineComment.label.prefix": "Commenter sur ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "Commentaire sur ", "ui.lineComment.editorLabel.suffix": "", "ui.lineComment.placeholder": "Ajouter un commentaire", "ui.lineComment.submit": "Commenter", + "ui.sessionTurn.steps.show": "Afficher les étapes", "ui.sessionTurn.steps.hide": "Masquer les étapes", "ui.sessionTurn.summary.response": "Réponse", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index d2203aad1..fd394dbb7 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -5,16 +5,17 @@ export const dict = { "ui.sessionReview.diffStyle.split": "분할 보기", "ui.sessionReview.expandAll": "모두 펼치기", "ui.sessionReview.collapseAll": "모두 접기", - "ui.sessionReview.change.added": "추가됨", "ui.sessionReview.change.removed": "삭제됨", "ui.sessionReview.change.modified": "수정됨", + "ui.lineComment.label.prefix": "", "ui.lineComment.label.suffix": "에 댓글 달기", "ui.lineComment.editorLabel.prefix": "", "ui.lineComment.editorLabel.suffix": "에 댓글 작성 중", "ui.lineComment.placeholder": "댓글 추가", "ui.lineComment.submit": "댓글", + "ui.sessionTurn.steps.show": "단계 표시", "ui.sessionTurn.steps.hide": "단계 숨기기", "ui.sessionTurn.summary.response": "응답", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index ca1504da2..dcb353614 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -8,16 +8,17 @@ export const dict: Record = { "ui.sessionReview.diffStyle.split": "Delt", "ui.sessionReview.expandAll": "Utvid alle", "ui.sessionReview.collapseAll": "Fold sammen alle", - "ui.sessionReview.change.added": "Lagt til", "ui.sessionReview.change.removed": "Fjernet", "ui.sessionReview.change.modified": "Endret", + "ui.lineComment.label.prefix": "Kommenter på ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "Kommenterer på ", "ui.lineComment.editorLabel.suffix": "", "ui.lineComment.placeholder": "Legg til kommentar", "ui.lineComment.submit": "Kommenter", + "ui.sessionTurn.steps.show": "Vis trinn", "ui.sessionTurn.steps.hide": "Skjul trinn", "ui.sessionTurn.summary.response": "Svar", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 4a1fbb0a4..68bb0d733 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -57,6 +57,7 @@ export const dict = { "ui.imagePreview.alt": "ตัวอย่างรูปภาพ", "ui.tool.read": "อ่าน", + "ui.tool.loaded": "โหลดแล้ว", "ui.tool.list": "รายการ", "ui.tool.glob": "Glob", "ui.tool.grep": "Grep", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index aead06a03..53beeb1e4 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -9,16 +9,17 @@ export const dict = { "ui.sessionReview.diffStyle.split": "拆分", "ui.sessionReview.expandAll": "全部展开", "ui.sessionReview.collapseAll": "全部收起", - "ui.sessionReview.change.added": "已添加", "ui.sessionReview.change.removed": "已移除", "ui.sessionReview.change.modified": "已修改", + "ui.lineComment.label.prefix": "评论 ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "正在评论 ", "ui.lineComment.editorLabel.suffix": "", "ui.lineComment.placeholder": "添加评论", "ui.lineComment.submit": "评论", + "ui.sessionTurn.steps.show": "显示步骤", "ui.sessionTurn.steps.hide": "隐藏步骤", "ui.sessionTurn.summary.response": "回复", @@ -92,7 +93,7 @@ export const dict = { "ui.message.expand": "展开消息", "ui.message.collapse": "收起消息", "ui.message.copy": "复制", - "ui.message.copied": "已复制", + "ui.message.copied": "已复制!", "ui.message.attachment.alt": "附件", "ui.patch.action.deleted": "已删除", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 0b9fb6eac..1449b0530 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -9,16 +9,17 @@ export const dict = { "ui.sessionReview.diffStyle.split": "拆分", "ui.sessionReview.expandAll": "全部展開", "ui.sessionReview.collapseAll": "全部收合", - "ui.sessionReview.change.added": "已新增", "ui.sessionReview.change.removed": "已移除", "ui.sessionReview.change.modified": "已修改", + "ui.lineComment.label.prefix": "評論 ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "正在評論 ", "ui.lineComment.editorLabel.suffix": "", "ui.lineComment.placeholder": "新增評論", "ui.lineComment.submit": "評論", + "ui.sessionTurn.steps.show": "顯示步驟", "ui.sessionTurn.steps.hide": "隱藏步驟", "ui.sessionTurn.summary.response": "回覆", @@ -92,7 +93,7 @@ export const dict = { "ui.message.expand": "展開訊息", "ui.message.collapse": "收合訊息", "ui.message.copy": "複製", - "ui.message.copied": "已複製", + "ui.message.copied": "已複製!", "ui.message.attachment.alt": "附件", "ui.patch.action.deleted": "已刪除",