Resolve merge conflicts

This commit is contained in:
Kevin van Dijk
2026-02-18 13:02:19 +01:00
52 changed files with 1513 additions and 238 deletions
+3
View File
@@ -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.
+13 -13
View File
@@ -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=="],
@@ -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"
}
+2 -2
View File
@@ -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",
@@ -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,
+2 -1
View File
@@ -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<CommentStore>, setStore: SetStor
const add = (input: Omit<LineComment, "id" | "time">) => {
const next: LineComment = {
id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
id: uuid(),
time: Date.now(),
...input,
}
+4 -2
View File
@@ -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<string, unknown> =>
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)
}
}
}
+4 -3
View File
@@ -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<Map<string, TerminalCacheEntry>>()
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)
}
}
+2 -1
View File
@@ -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}} متاحة الآن للاستخدام.",
+4 -2
View File
@@ -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",
+2 -1
View File
@@ -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}} พร้อมใช้งานแล้ว",
+2 -1
View File
@@ -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}} 模型了。",
+11
View File
@@ -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)
@@ -32,7 +32,9 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
<div class="size-full rounded overflow-clip">
<Avatar
fallback={name()}
src={props.project.id === KILO_PROJECT_ID ? "https://kilo.ai/favicon.svg" : props.project.icon?.override}
src={
props.project.id === KILO_PROJECT_ID ? "https://kilo.ai/favicon.svg" : props.project.icon?.override
}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded"
classList={{ "badge-mask": unseenCount() > 0 && props.notify }}
+4 -4
View File
@@ -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<string, Nav>()
const pending = new Map<string, string>()
const active = new Map<string, string>()
@@ -94,7 +94,7 @@ function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
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" })
+3 -4
View File
@@ -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)
+78
View File
@@ -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<Crypto>) => {
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")
})
})
+12
View File
@@ -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()
}
}
+2 -2
View File
@@ -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",
+12 -1
View File
@@ -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<void>(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) => {
+30 -22
View File
@@ -1,5 +1,6 @@
import { cmd } from "../cmd"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -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?.()
}
},
})
@@ -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<void>) & {
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)
@@ -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() {
<box flexDirection="row">
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<Show when={!sidebarVisible() || !wide()}>
<Show when={showHeader() && (!sidebarVisible() || !wide())}>
<Header />
</Show>
<scrollbox
+105 -92
View File
@@ -9,6 +9,7 @@ import { Log } from "@/util/log"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import type { Event } from "@kilocode/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
declare global {
const KILO_WORKER_PATH: string // kilocode_change
@@ -77,99 +78,111 @@ export const TuiThreadCommand = cmd({
describe: "agent to use",
}),
handler: async (args) => {
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<typeof rpc>(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<typeof rpc>(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
},
})
+129
View File
@@ -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<typeof kernel> | 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
}
+21 -18
View File
@@ -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
+1 -1
View File
@@ -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<typeof Model>
+2 -2
View File
@@ -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,
@@ -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 }]))
+3 -1
View File
@@ -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
}
+2
View File
@@ -44,6 +44,7 @@ export namespace LLM {
small?: boolean
tools: Record<string, Tool>
retries?: number
toolChoice?: "auto" | "required" | "none"
}
export type StreamOutput = StreamTextResult<ToolSet, unknown>
@@ -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: {
@@ -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<typeof Format>
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({
+95 -4
View File
@@ -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<string, any>
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 }
+2 -2
View File
@@ -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.
+63 -13
View File
@@ -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 = [
`<path>${filepath}</path>`,
`<type>directory</type>`,
`<entries>`,
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)`,
`</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 = "<file>\n"
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].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</file>"
output += "\n</content>"
// 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),
},
}
},
+12 -10
View File
@@ -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 `<line>: <content>`. 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.
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
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)
})
})
@@ -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
})
+64 -1
View File
@@ -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<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
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) => {
+5
View File
@@ -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<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
@@ -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<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
@@ -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" },
+32
View File
@@ -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<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
@@ -3617,6 +3647,7 @@ export type SessionPromptAsyncData = {
tools?: {
[key: string]: boolean
}
format?: OutputFormat
system?: string
variant?: string
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
@@ -4066,6 +4097,7 @@ export type ProviderListResponses = {
}
provider?: {
npm: string
api: string
}
variants?: {
[key: string]: {
+92 -2
View File
@@ -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",
+3 -2
View File
@@ -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": "استجابة",
+3 -2
View File
@@ -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)",
+3 -3
View File
@@ -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)",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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": "응답",
+2 -1
View File
@@ -8,16 +8,17 @@ export const dict: Record<Keys, string> = {
"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",
+1
View File
@@ -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",
+3 -2
View File
@@ -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": "已删除",
+3 -2
View File
@@ -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": "已刪除",