diff --git a/.gitignore b/.gitignore index a07a7493..27316da6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules .env .idea .vscode +openapi.json diff --git a/STATS.md b/STATS.md index 242ca4c1..256ffb5d 100644 --- a/STATS.md +++ b/STATS.md @@ -5,3 +5,6 @@ | 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) | | 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) | | 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) | +| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) | +| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) | +| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) | diff --git a/bun.lock b/bun.lock index 5457a140..0fec03e8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "name": "opencode", "devDependencies": { "prettier": "3.5.3", - "sst": "3.17.6", + "sst": "3.17.8", }, }, "packages/function": { @@ -78,11 +78,12 @@ "lang-map": "0.4.0", "luxon": "3.6.1", "marked": "15.0.12", + "marked-shiki": "1.2.0", "rehype-autolink-headings": "7.1.0", "sharp": "0.32.5", "shiki": "3.4.2", "solid-js": "1.9.7", - "toolbeam-docs-theme": "0.3.0", + "toolbeam-docs-theme": "0.4.1", }, "devDependencies": { "@types/node": "catalog:", @@ -462,7 +463,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="], - "@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="], + "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], @@ -492,6 +493,8 @@ "@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], + "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + "@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="], "@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="], @@ -602,7 +605,7 @@ "buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="], - "bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], + "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -1050,6 +1053,8 @@ "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "marked-shiki": ["marked-shiki@1.2.0", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-N924hp8veE6Mc91g5/kCNVoTU7TkeJfB2G2XEWb+k1fVA0Bck2T0rVt93d39BlOYH6ohP4Q9BFlPk+UkblhXbg=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], @@ -1476,23 +1481,23 @@ "split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="], - "sst": ["sst@3.17.6", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.6", "sst-darwin-x64": "3.17.6", "sst-linux-arm64": "3.17.6", "sst-linux-x64": "3.17.6", "sst-linux-x86": "3.17.6", "sst-win32-arm64": "3.17.6", "sst-win32-x64": "3.17.6", "sst-win32-x86": "3.17.6" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-p+AcqwfYQUdkxeRjCikQoTMviPCBiGoU7M0vcV6GDVmVis8hzhVw4EFfHTafZC+aWfy1Ke2UQi66vZlEVWuEqA=="], + "sst": ["sst@3.17.8", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.8", "sst-darwin-x64": "3.17.8", "sst-linux-arm64": "3.17.8", "sst-linux-x64": "3.17.8", "sst-linux-x86": "3.17.8", "sst-win32-arm64": "3.17.8", "sst-win32-x64": "3.17.8", "sst-win32-x86": "3.17.8" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-P/a9/ZsjtQRrTBerBMO1ODaVa5HVTmNLrQNJiYvu2Bgd0ov+vefQeHv6oima8HLlPwpDIPS2gxJk8BZrTZMfCA=="], - "sst-darwin-arm64": ["sst-darwin-arm64@3.17.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6tb7KlcPR7PTi3ofQv8dX/n6Jf7pNP9VfrnYL4HBWnWrcYaZeJ5MWobILfIJ/y2jHgoqmg9e5C3266Eds0JQyw=="], + "sst-darwin-arm64": ["sst-darwin-arm64@3.17.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-50P6YRMnZVItZUfB0+NzqMww2mmm4vB3zhTVtWUtGoXeiw78g1AEnVlmS28gYXPHM1P987jTvR7EON9u9ig/Dg=="], - "sst-darwin-x64": ["sst-darwin-x64@3.17.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-lFakq6/EgTuBSjbl8Kry4pfgAPEIyn6o7ZkyRz3hz5331wUaX88yfjs3tL9JQ8Ey6jBUYxwhP/Q1n7fzIG046g=="], + "sst-darwin-x64": ["sst-darwin-x64@3.17.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-P0pnMHCmpkpcsxkWpilmeoD79LkbkoIcv6H0aeM9ArT/71/JBhvqH+HjMHSJCzni/9uR6er+nH5F+qol0UO6Bw=="], - "sst-linux-arm64": ["sst-linux-arm64@3.17.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-SdTxXMbTEdiwOqp37w31kXv97vHqSx3oK9h/76lKg7V9k5JxPJ6JMefPLhoKWwK0Zh6AndY2zo2oRoEv4SIaDw=="], + "sst-linux-arm64": ["sst-linux-arm64@3.17.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-vun54YA/UzprCu9p8BC4rMwFU5Cj9xrHAHYLYUp/yq4H0pfmBIiQM62nsfIKizRThe/TkBFy60EEi9myf6raYA=="], - "sst-linux-x64": ["sst-linux-x64@3.17.6", "", { "os": "linux", "cpu": "x64" }, "sha512-qneh7uWDiTUYx8X1Y3h2YVw3SJ0ybBBlRrVybIvCM09JqQ8+qq/XjKXGzA/3/EF0Jr7Ug8cARSn9CwxhdQGN7Q=="], + "sst-linux-x64": ["sst-linux-x64@3.17.8", "", { "os": "linux", "cpu": "x64" }, "sha512-HqByCaLE2gEJbM20P1QRd+GqDMAiieuU53FaZA1F+AGxQi+kR82NWjrPqFcMj4dMYg8w/TWXuV+G5+PwoUmpDw=="], - "sst-linux-x86": ["sst-linux-x86@3.17.6", "", { "os": "linux", "cpu": "none" }, "sha512-pU3D5OeqnmfxGqN31DxuwWnc1OayxhkErnITHhZ39D0MTiwbIgCapH26FuLW8B08/uxJWG8djUlOboCRhSBvWA=="], + "sst-linux-x86": ["sst-linux-x86@3.17.8", "", { "os": "linux", "cpu": "none" }, "sha512-bCd6QM3MejfSmdvg8I/k+aUJQIZEQJg023qmN78fv00vwlAtfECvY7tjT9E2m3LDp33pXrcRYbFOQzPu+tWFfA=="], - "sst-win32-arm64": ["sst-win32-arm64@3.17.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rr3RTYWAsH9sM9CbM/sAZCk7dB1OsSAljjJuuHMvdSAYW3RDpXEza0PBJGxnBID2eOrpswEchzMPL2d8LtL7oA=="], + "sst-win32-arm64": ["sst-win32-arm64@3.17.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-pilx0n8gm4aHJae/vNiqIwZkWF3tdwWzD/ON7hkytw+CVSZ0FXtyFW/yO/+2u3Yw0Kj0lSWPnUqYgm/eHPLwQA=="], - "sst-win32-x64": ["sst-win32-x64@3.17.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yZ3roxwI0Wve9PFzdrrF1kfzCmIMFCCoa8qKeXY7LxCJ4QQIqHbCOccLK1Wv/MIU/mcZHWXTQVCLHw77uaa0GQ=="], + "sst-win32-x64": ["sst-win32-x64@3.17.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Jb0FVRyiOtESudF1V8ucW65PuHrx/iOHUamIO0JnbujWNHZBTRPB2QHN1dbewgkueYDaCmyS8lvuIImLwYJnzQ=="], - "sst-win32-x86": ["sst-win32-x86@3.17.6", "", { "os": "win32", "cpu": "none" }, "sha512-zV7TJWPJN9PmIXr15iXFSs0tbGsa52oBR3+xiKrUj2qj9XsZe7HBFwskRnHyiFq0durZY9kk9ZtoVlpuUuzr1g=="], + "sst-win32-x86": ["sst-win32-x86@3.17.8", "", { "os": "win32", "cpu": "none" }, "sha512-oVmFa/PoElQmfnGJlB0w6rPXiYuldiagO6AbrLMT/6oAnWerLQ8Uhv9tJWfMh3xtPLImQLTjxDo1v0AIzEv9QA=="], "stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="], @@ -1546,7 +1551,7 @@ "token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="], - "toolbeam-docs-theme": ["toolbeam-docs-theme@0.3.0", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-qlBkKRp8HVYV7p7jaG9lT2lvQY7c8b9czZ0tnsJUrN2TBTtEyFJymCdkhhpZNC9U4oGZ7lLk0glRJHrndWvVsg=="], + "toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.1", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-lTI4dHZaVNQky29m7sb36Oy4tWPwxsCuFxFjF8hgGW0vpV+S6qPvI9SwsJFvdE/OHO5DoI7VMbryV1pxZHkkHQ=="], "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], diff --git a/infra/app.ts b/infra/app.ts index 1123e3a6..834936b7 100644 --- a/infra/app.ts +++ b/infra/app.ts @@ -9,6 +9,9 @@ const bucket = new sst.cloudflare.Bucket("Bucket") export const api = new sst.cloudflare.Worker("Api", { domain: `api.${domain}`, handler: "packages/function/src/api.ts", + environment: { + WEB_DOMAIN: domain, + }, url: true, link: [bucket], transform: { diff --git a/package.json b/package.json index ed4fcded..09248dcf 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "type": "module", "packageManager": "bun@1.2.14", "scripts": { + "dev": "bun run packages/opencode/src/index.ts", "typecheck": "bun run --filter='*' typecheck", + "stainless": "bun run ./packages/opencode/src/index.ts serve ", "postinstall": "./scripts/hooks" }, "workspaces": { @@ -21,7 +23,7 @@ }, "devDependencies": { "prettier": "3.5.3", - "sst": "3.17.6" + "sst": "3.17.8" }, "repository": { "type": "git", diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts index 0d5e44df..701b9854 100644 --- a/packages/function/src/api.ts +++ b/packages/function/src/api.ts @@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto" type Env = { SYNC_SERVER: DurableObjectNamespace Bucket: R2Bucket + WEB_DOMAIN: string } export class SyncServer extends DurableObject { @@ -127,7 +128,7 @@ export default { return new Response( JSON.stringify({ secret, - url: "https://opencode.ai/s/" + short, + url: `https://${env.WEB_DOMAIN}/s/${short}`, }), { headers: { "Content-Type": "application/json" }, diff --git a/packages/opencode/.gitignore b/packages/opencode/.gitignore index 66857d89..e057ca61 100644 --- a/packages/opencode/.gitignore +++ b/packages/opencode/.gitignore @@ -1,4 +1,3 @@ -node_modules research dist gen diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode index 63c524f6..8f75eb18 100755 --- a/packages/opencode/bin/opencode +++ b/packages/opencode/bin/opencode @@ -49,7 +49,7 @@ else done if [ -z "$resolved" ]; then - printf "It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2 + printf "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the \"%s\" package\n" "$name" >&2 exit 1 fi fi diff --git a/packages/opencode/bin/opencode.cmd b/packages/opencode/bin/opencode.cmd index 8bac765c..5908a815 100644 --- a/packages/opencode/bin/opencode.cmd +++ b/packages/opencode/bin/opencode.cmd @@ -48,9 +48,9 @@ set "current_dir=%parent_dir%" goto :search_loop :not_found -echo It seems that your package manager failed to install the right version of the OpenCode CLI for your platform. You can try manually installing the "%name%" package >&2 +echo It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "%name%" package >&2 exit /b 1 :execute rem Execute the binary with all arguments -"%resolved%" %* \ No newline at end of file +"%resolved%" %* diff --git a/packages/opencode/config.schema.json b/packages/opencode/config.schema.json index 6ee406c0..35dfd6f1 100644 --- a/packages/opencode/config.schema.json +++ b/packages/opencode/config.schema.json @@ -297,6 +297,13 @@ }, "description": "MCP (Model Context Protocol) server configurations" }, + "instructions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional instruction files or patterns to include" + }, "experimental": { "type": "object", "properties": { diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 53c6c916..3f4c2005 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -40,7 +40,7 @@ for (const [os, arch] of targets) { console.log(`building ${os}-${arch}`) const name = `${pkg.name}-${os}-${arch}` await $`mkdir -p dist/${name}/bin` - await $`GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd( + await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`.cwd( "../tui", ) await $`bun build --define OPENCODE_VERSION="'${version}'" --compile --minify --target=bun-${os}-${arch} --outfile=dist/${name}/bin/opencode ./src/index.ts ./dist/${name}/bin/tui` diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 87f6f982..5cdda83c 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -9,6 +9,7 @@ import fs from "fs/promises" import { Installation } from "../../installation" import { Config } from "../../config/config" import { Bus } from "../../bus" +import { Log } from "../../util/log" export const TuiCommand = cmd({ command: "$0 [project]", @@ -57,6 +58,9 @@ export const TuiCommand = cmd({ cwd = process.cwd() cmd = [binary] } + Log.Default.info("tui", { + cmd, + }) const proc = Bun.spawn({ cmd: [...cmd, ...process.argv.slice(2)], cwd, @@ -100,7 +104,7 @@ export const TuiCommand = cmd({ UI.empty() UI.println(UI.logo(" ")) const result = await Bun.spawn({ - cmd: [process.execPath, "auth", "login"], + cmd: [...getOpencodeCommand(), "auth", "login"], cwd: process.cwd(), stdout: "inherit", stderr: "inherit", @@ -112,3 +116,25 @@ export const TuiCommand = cmd({ } }, }) + +/** + * Get the correct command to run opencode CLI + * In development: ["bun", "run", "packages/opencode/src/index.ts"] + * In production: ["/path/to/opencode"] + */ +function getOpencodeCommand(): string[] { + // Check if OPENCODE_BIN_PATH is set (used by shell wrapper scripts) + if (process.env["OPENCODE_BIN_PATH"]) { + return [process.env["OPENCODE_BIN_PATH"]] + } + + const execPath = process.execPath.toLowerCase() + + if (Installation.isDev()) { + // In development, use bun to run the TypeScript entry point + return [execPath, "run", process.argv[1]] + } + + // In production, use the current executable path + return [process.execPath] +} diff --git a/packages/opencode/src/commands/index.ts b/packages/opencode/src/commands/index.ts index 52948728..c7e1f175 100644 --- a/packages/opencode/src/commands/index.ts +++ b/packages/opencode/src/commands/index.ts @@ -3,7 +3,6 @@ import path from "path" import { Global } from "../global" import { Log } from "../util/log" import { App } from "../app/app" -import { BANNED_COMMANDS } from "../tool/bash" import { z } from "zod" export namespace Commands { @@ -122,10 +121,6 @@ export namespace Commands { async function executeBashCommand( command: string, ): Promise { - if (BANNED_COMMANDS.some((item) => command.startsWith(item))) { - throw new Error(`Command '${command}' is not allowed`) - } - const process = Bun.spawn({ cmd: ["bash", "-c", command], cwd: App.info().path.cwd, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index cf2f3479..eb67778e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -176,6 +176,10 @@ export namespace Config { .record(z.string(), Mcp) .optional() .describe("MCP (Model Context Protocol) server configurations"), + instructions: z + .array(z.string()) + .optional() + .describe("Additional instruction files or patterns to include"), experimental: z .object({ hook: z diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 39ae5b59..a975d34b 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -32,7 +32,7 @@ export namespace Ripgrep { }), }) - const Match = z.object({ + export const Match = z.object({ type: z.literal("match"), data: z.object({ path: z.object({ diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index 53132197..908efdcc 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,6 +1,8 @@ import { App } from "../app/app" +import { Log } from "../util/log" export namespace FileTime { + const log = Log.create({ service: "file.time" }) export const state = App.state("tool.filetimes", () => { const read: { [sessionID: string]: { @@ -13,6 +15,7 @@ export namespace FileTime { }) export function read(sessionID: string, file: string) { + log.info("read", { sessionID, file }) const { read } = state() read[sessionID] = read[sessionID] || {} read[sessionID][file] = new Date() diff --git a/packages/opencode/src/file/watch.ts b/packages/opencode/src/file/watch.ts index 8bc70cd8..1d12168f 100644 --- a/packages/opencode/src/file/watch.ts +++ b/packages/opencode/src/file/watch.ts @@ -3,6 +3,7 @@ import { Bus } from "../bus" import fs from "fs" import { App } from "../app/app" import { Log } from "../util/log" +import { Flag } from "../flag/flag" export namespace FileWatcher { const log = Log.create({ service: "file.watcher" }) @@ -16,37 +17,39 @@ export namespace FileWatcher { }), ), } + const state = App.state( + "file.watcher", + () => { + const app = App.use() + try { + const watcher = fs.watch( + app.info.path.cwd, + { recursive: true }, + (event, file) => { + log.info("change", { file, event }) + if (!file) return + // for some reason async local storage is lost here + // https://github.com/oven-sh/bun/issues/20754 + App.provideExisting(app, async () => { + Bus.publish(Event.Updated, { + file, + event, + }) + }) + }, + ) + return { watcher } + } catch { + return {} + } + }, + async (state) => { + state.watcher?.close() + }, + ) export function init() { - App.state( - "file.watcher", - () => { - const app = App.use() - try { - const watcher = fs.watch( - app.info.path.cwd, - { recursive: true }, - (event, file) => { - log.info("change", { file, event }) - if (!file) return - // for some reason async local storage is lost here - // https://github.com/oven-sh/bun/issues/20754 - App.provideExisting(app, async () => { - Bus.publish(Event.Updated, { - file, - event, - }) - }) - }, - ) - return { watcher } - } finally { - return {} - } - }, - async (state) => { - state.watcher?.close() - }, - )() + if (Flag.OPENCODE_DISABLE_WATCHER) return + state() } } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 9c01d13c..e6f54440 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -1,5 +1,6 @@ export namespace Flag { export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE") + export const OPENCODE_DISABLE_WATCHER = truthy("OPENCODE_DISABLE_WATCHER") function truthy(key: string) { const value = process.env[key]?.toLowerCase() diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 78cc6b92..754b75d4 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -31,7 +31,7 @@ export namespace Format { const result = [] for (const item of Object.values(Formatter)) { if (!item.extensions.includes(ext)) continue - if (!isEnabled(item)) continue + if (!(await isEnabled(item))) continue result.push(item) } return result diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index f06a8c68..5aff437d 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -92,11 +92,20 @@ export namespace LSPClient { }, }), 5_000, - ).catch(() => { - throw new InitializeError({ serverID }) + ).catch((err) => { + log.error("initialize error", { error: err }) + throw new InitializeError( + { serverID }, + { + cause: err, + }, + ) }) + await connection.sendNotification("initialized", {}) - log.info("initialized") + log.info("initialized", { + serverID, + }) const files: { [path: string]: number @@ -174,7 +183,6 @@ export namespace LSPClient { log.info("shutting down", { serverID }) connection.end() connection.dispose() - server.process.kill("SIGTERM") log.info("shutdown", { serverID }) }, } diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index e4280bf2..88e549bb 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -4,10 +4,34 @@ import { LSPClient } from "./client" import path from "path" import { LSPServer } from "./server" import { Ripgrep } from "../file/ripgrep" +import { z } from "zod" export namespace LSP { const log = Log.create({ service: "lsp" }) + export const Symbol = z + .object({ + name: z.string(), + kind: z.number(), + location: z.object({ + uri: z.string(), + range: z.object({ + start: z.object({ + line: z.number(), + character: z.number(), + }), + end: z.object({ + line: z.number(), + character: z.number(), + }), + }), + }), + }) + .openapi({ + ref: "LSP.Symbol", + }) + export type Symbol = z.infer + const state = App.state( "lsp", async (app) => { @@ -23,7 +47,7 @@ export namespace LSP { const handle = await server.spawn(App.info()) if (!handle) break const client = await LSPClient.create(server.id, handle).catch( - () => {}, + (err) => log.error("", { error: err }), ) if (!client) break clients.set(server.id, client) @@ -96,7 +120,7 @@ export namespace LSP { client.connection.sendRequest("workspace/symbol", { query, }), - ) + ).then((result) => result.flat() as LSP.Symbol[]) } async function run( diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 8dde48ee..39a23f0f 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -4,6 +4,8 @@ import path from "path" import { Global } from "../global" import { Log } from "../util/log" import { BunProc } from "../bun" +import { $ } from "bun" +import fs from "fs/promises" export namespace LSPServer { const log = Log.create({ service: "lsp.server" }) @@ -57,6 +59,7 @@ export namespace LSPServer { PATH: process.env["PATH"] + ":" + Global.Path.bin, }) if (!bin) { + if (!Bun.which("go")) return log.info("installing gopls") const proc = Bun.spawn({ cmd: ["go", "install", "golang.org/x/tools/gopls@latest"], @@ -143,4 +146,60 @@ export namespace LSPServer { } }, } + + export const ElixirLS: Info = { + id: "elixir-ls", + extensions: [".ex", ".exs"], + async spawn() { + let binary = Bun.which("elixir-ls") + if (!binary) { + const elixirLsPath = path.join(Global.Path.bin, "elixir-ls") + binary = path.join( + Global.Path.bin, + "elixir-ls-master", + "release", + process.platform === "win32" + ? "language_server.bar" + : "language_server.sh", + ) + + if (!(await Bun.file(binary).exists())) { + const elixir = Bun.which("elixir") + if (!elixir) { + log.error("elixir is required to run elixir-ls") + return + } + + log.info("downloading elixir-ls from GitHub releases") + + const response = await fetch( + "https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip", + ) + if (!response.ok) return + const zipPath = path.join(Global.Path.bin, "elixir-ls.zip") + await Bun.file(zipPath).write(response) + + await $`unzip -o -q ${zipPath}`.cwd(Global.Path.bin).nothrow() + + await fs.rm(zipPath, { + force: true, + recursive: true, + }) + + await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release` + .quiet() + .cwd(path.join(Global.Path.bin, "elixir-ls-master")) + .env({ MIX_ENV: "prod", ...process.env }) + + log.info(`installed elixir-ls`, { + path: elixirLsPath, + }) + } + } + + return { + process: spawn(binary), + } + }, + } } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 3374e3b2..f05d15ce 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -99,11 +99,25 @@ export namespace Provider { }) info.access = tokens.access } + let isAgentCall = false + try { + const body = + typeof init.body === "string" + ? JSON.parse(init.body) + : init.body + if (body?.messages) { + isAgentCall = body.messages.some( + (msg: any) => + msg.role && ["tool", "assistant"].includes(msg.role), + ) + } + } catch {} const headers = { ...init.headers, ...copilot.HEADERS, Authorization: `Bearer ${info.access}`, "Openai-Intent": "conversation-edits", + "X-Initiator": isAgentCall ? "agent" : "user", } delete headers["x-api-key"] return fetch(input, { @@ -191,6 +205,17 @@ export namespace Provider { }, } }, + openrouter: async () => { + return { + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }, + }, + } + }, } const state = App.state("provider", async () => { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index d7f6f2bf..6b69c024 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -15,6 +15,8 @@ import { ModelsDev } from "../provider/models" import { Ripgrep } from "../file/ripgrep" import { Config } from "../config/config" import { Commands } from "../commands" +import { File } from "../file" +import { LSP } from "../lsp" const ERRORS = { 400: { @@ -74,7 +76,7 @@ export namespace Server { documentation: { info: { title: "opencode", - version: "0.0.2", + version: "0.0.3", description: "opencode api", }, openapi: "3.0.0", @@ -493,12 +495,44 @@ export namespace Server { }, ) .get( - "/file", + "/find", describeRoute({ - description: "Search for files", + description: "Find text in files", responses: { 200: { - description: "Search for files", + description: "Matches", + content: { + "application/json": { + schema: resolver(Ripgrep.Match.shape.data.array()), + }, + }, + }, + }, + }), + zValidator( + "query", + z.object({ + pattern: z.string(), + }), + ), + async (c) => { + const app = App.info() + const pattern = c.req.valid("query").pattern + const result = await Ripgrep.search({ + cwd: app.path.cwd, + pattern, + limit: 10, + }) + return c.json(result) + }, + ) + .get( + "/find/file", + describeRoute({ + description: "Find files", + responses: { + 200: { + description: "File paths", content: { "application/json": { schema: resolver(z.string().array()), @@ -524,6 +558,98 @@ export namespace Server { return c.json(result) }, ) + .get( + "/find/symbol", + describeRoute({ + description: "Find workspace symbols", + responses: { + 200: { + description: "Symbols", + content: { + "application/json": { + schema: resolver(z.unknown().array()), + }, + }, + }, + }, + }), + zValidator( + "query", + z.object({ + query: z.string(), + }), + ), + async (c) => { + const query = c.req.valid("query").query + const result = await LSP.workspaceSymbol(query) + return c.json(result) + }, + ) + .get( + "/file", + describeRoute({ + description: "Read a file", + responses: { + 200: { + description: "File content", + content: { + "application/json": { + schema: resolver( + z.object({ + type: z.enum(["raw", "patch"]), + content: z.string(), + }), + ), + }, + }, + }, + }, + }), + zValidator( + "query", + z.object({ + path: z.string(), + }), + ), + async (c) => { + const path = c.req.valid("query").path + const content = await File.read(path) + log.info("read file", { + path, + content: content.content, + }) + return c.json(content) + }, + ) + .get( + "/file/status", + describeRoute({ + description: "Get file status", + responses: { + 200: { + description: "File status", + content: { + "application/json": { + schema: resolver( + z + .object({ + file: z.string(), + added: z.number().int(), + removed: z.number().int(), + status: z.enum(["added", "deleted", "modified"]), + }) + .array(), + ), + }, + }, + }, + }, + }), + async (c) => { + const content = await File.status() + return c.json(content) + }, + ) .get( "/commands", describeRoute({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 4b83d808..f96d2387 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,40 +1,43 @@ -import path from "path" -import { App } from "../app/app" -import { Identifier } from "../id/id" -import { Storage } from "../storage/storage" -import { Log } from "../util/log" +import path from "node:path" +import { Decimal } from "decimal.js" +import { z, ZodSchema } from "zod" import { generateText, LoadAPIKeyError, convertToCoreMessages, streamText, tool, + wrapLanguageModel, type Tool as AITool, type LanguageModelUsage, type CoreMessage, type UIMessage, type ProviderMetadata, - wrapLanguageModel, + type Attachment, } from "ai" -import { z, ZodSchema } from "zod" -import { Decimal } from "decimal.js" import PROMPT_INITIALIZE from "../session/prompt/initialize.txt" -import { Share } from "../share/share" -import { Message } from "./message" +import { App } from "../app/app" import { Bus } from "../bus" -import { Provider } from "../provider/provider" -import { MCP } from "../mcp" -import { NamedError } from "../util/error" -import type { Tool } from "../tool/tool" -import { SystemPrompt } from "./system" -import { Flag } from "../flag/flag" -import type { ModelsDev } from "../provider/models" -import { Installation } from "../installation" import { Config } from "../config/config" +import { Flag } from "../flag/flag" +import { Identifier } from "../id/id" +import { Installation } from "../installation" +import { MCP } from "../mcp" +import { Provider } from "../provider/provider" import { ProviderTransform } from "../provider/transform" import { FileReference } from "../util/file-reference" +import type { ModelsDev } from "../provider/models" +import { Share } from "../share/share" +import { Snapshot } from "../snapshot" +import { Storage } from "../storage/storage" +import type { Tool } from "../tool/tool" +import { Log } from "../util/log" +import { NamedError } from "../util/error" +import { Message } from "./message" +import { SystemPrompt } from "./system" +import { FileTime } from "../file/time" export namespace Session { const log = Log.create({ service: "session" }) @@ -54,6 +57,13 @@ export namespace Session { created: z.number(), updated: z.number(), }), + revert: z + .object({ + messageID: z.string(), + part: z.number(), + snapshot: z.string().optional(), + }) + .optional(), }) .openapi({ ref: "Session", @@ -178,11 +188,13 @@ export namespace Session { } export async function unshare(id: string) { + const share = await getShare(id) + if (!share) return await Storage.remove("session/share/" + id) await update(id, (draft) => { draft.share = undefined }) - await Share.remove(id) + await Share.remove(id, share.secret) } export async function update(id: string, editor: (session: Info) => void) { @@ -286,6 +298,37 @@ export namespace Session { l.info("chatting") const model = await Provider.getModel(input.providerID, input.modelID) let msgs = await messages(input.sessionID) + const session = await get(input.sessionID) + + if (session.revert) { + const trimmed = [] + for (const msg of msgs) { + if ( + msg.id > session.revert.messageID || + (msg.id === session.revert.messageID && session.revert.part === 0) + ) { + await Storage.remove( + "session/message/" + input.sessionID + "/" + msg.id, + ) + await Bus.publish(Message.Event.Removed, { + sessionID: input.sessionID, + messageID: msg.id, + }) + continue + } + + if (msg.id === session.revert.messageID) { + if (session.revert.part === 0) break + msg.parts = msg.parts.slice(0, session.revert.part) + } + trimmed.push(msg) + } + msgs = trimmed + await update(input.sessionID, (draft) => { + draft.revert = undefined + }) + } + const previous = msgs.at(-1) // auto summarize if too long @@ -326,15 +369,68 @@ export namespace Session { const { processedText } = await FileReference.resolve(part.text) return { ...part, - text: processedText + text: processedText, } } return part - }) + }), ) const app = App.info() - const session = await get(input.sessionID) + input.parts = await Promise.all( + input.parts.map(async (part): Promise => { + if (part.type === "file") { + const url = new URL(part.url) + switch (url.protocol) { + case "file:": + const filepath = path.join(app.path.cwd, url.pathname) + let file = Bun.file(filepath) + + if (part.mediaType === "text/plain") { + let text = await file.text() + const range = { + start: url.searchParams.get("start"), + end: url.searchParams.get("end"), + } + if (range.start != null && part.mediaType === "text/plain") { + const lines = text.split("\n") + const start = parseInt(range.start) + const end = range.end ? parseInt(range.end) : lines.length + text = lines.slice(start, end).join("\n") + } + FileTime.read(input.sessionID, filepath) + return [ + { + type: "text", + text: [ + "Called the Read tool on " + url.pathname, + "", + text, + "", + ].join("\n"), + }, + ] + } + + return [ + { + type: "text", + text: ["Called the Read tool on " + url.pathname].join("\n"), + }, + { + type: "file", + url: + `data:${part.mediaType};base64,` + + Buffer.from(await file.bytes()).toString("base64url"), + mediaType: part.mediaType, + filename: part.filename!, + }, + ] + } + } + return [part] + }), + ).then((x) => x.flat()) if (msgs.length === 0 && !session.parentID) { generateText({ maxTokens: input.providerID === "google" ? 1024 : 20, @@ -350,7 +446,7 @@ export namespace Session { { role: "user", content: "", - parts: toParts(input.parts), + parts: toParts(input.parts).parts, }, ]), ], @@ -364,6 +460,7 @@ export namespace Session { }) .catch(() => {}) } + const snapshot = await Snapshot.create(input.sessionID) const msg: Message.Info = { role: "user", id: Identifier.ascending("message"), @@ -374,6 +471,7 @@ export namespace Session { }, sessionID: input.sessionID, tool: {}, + snapshot, }, } await updateMessage(msg) @@ -388,6 +486,7 @@ export namespace Session { role: "assistant", parts: [], metadata: { + snapshot, assistant: { system, path: { @@ -439,6 +538,7 @@ export namespace Session { }) next.metadata!.tool![opts.toolCallId] = { ...result.metadata, + snapshot: await Snapshot.create(input.sessionID), time: { start, end: Date.now(), @@ -451,6 +551,7 @@ export namespace Session { error: true, message: e.toString(), title: e.toString(), + snapshot: await Snapshot.create(input.sessionID), time: { start, end: Date.now(), @@ -472,6 +573,7 @@ export namespace Session { const result = await execute(args, opts) next.metadata!.tool![opts.toolCallId] = { ...result.metadata, + snapshot: await Snapshot.create(input.sessionID), time: { start, end: Date.now(), @@ -486,6 +588,7 @@ export namespace Session { next.metadata!.tool![opts.toolCallId] = { error: true, message: e.toString(), + snapshot: await Snapshot.create(input.sessionID), title: "mcp", time: { start, @@ -553,6 +656,7 @@ export namespace Session { // return step // }, toolCallStreaming: true, + maxRetries: 10, maxTokens: Math.max(0, model.info.limit.output) || undefined, abortSignal: abort.signal, maxSteps: 1000, @@ -750,6 +854,51 @@ export namespace Session { return next } + export async function revert(input: { + sessionID: string + messageID: string + part: number + }) { + const message = await getMessage(input.sessionID, input.messageID) + if (!message) return + const part = message.parts[input.part] + if (!part) return + const session = await get(input.sessionID) + const snapshot = + session.revert?.snapshot ?? (await Snapshot.create(input.sessionID)) + const old = (() => { + if (message.role === "assistant") { + const lastTool = message.parts.findLast( + (part, index) => + part.type === "tool-invocation" && index < input.part, + ) + if (lastTool && lastTool.type === "tool-invocation") + return message.metadata.tool[lastTool.toolInvocation.toolCallId] + .snapshot + } + return message.metadata.snapshot + })() + if (old) await Snapshot.restore(input.sessionID, old) + await update(input.sessionID, (draft) => { + draft.revert = { + messageID: input.messageID, + part: input.part, + snapshot, + } + }) + } + + export async function unrevert(sessionID: string) { + const session = await get(sessionID) + if (!session) return + if (!session.revert) return + if (session.revert.snapshot) + await Snapshot.restore(sessionID, session.revert.snapshot) + update(sessionID, (draft) => { + draft.revert = undefined + }) + } + export async function summarize(input: { sessionID: string providerID: string @@ -949,7 +1098,7 @@ function toUIMessage(msg: Message.Info): UIMessage { id: msg.id, role: "assistant", content: "", - parts: toParts(msg.parts), + ...toParts(msg.parts), } } @@ -958,35 +1107,41 @@ function toUIMessage(msg: Message.Info): UIMessage { id: msg.id, role: "user", content: "", - parts: toParts(msg.parts), + ...toParts(msg.parts), } } throw new Error("not implemented") } -function toParts(parts: Message.MessagePart[]): UIMessage["parts"] { - const result: UIMessage["parts"] = [] +function toParts(parts: Message.MessagePart[]) { + const result: { + parts: UIMessage["parts"] + experimental_attachments: Attachment[] + } = { + parts: [], + experimental_attachments: [], + } for (const part of parts) { switch (part.type) { case "text": - result.push({ type: "text", text: part.text }) + result.parts.push({ type: "text", text: part.text }) break case "file": - result.push({ - type: "file", - data: part.url, - mimeType: part.mediaType, + result.experimental_attachments.push({ + url: part.url, + contentType: part.mediaType, + name: part.filename, }) break case "tool-invocation": - result.push({ + result.parts.push({ type: "tool-invocation", toolInvocation: part.toolInvocation, }) break case "step-start": - result.push({ + result.parts.push({ type: "step-start", }) break diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index b2171fa4..2d319e87 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -159,6 +159,7 @@ export namespace Message { z .object({ title: z.string(), + snapshot: z.string().optional(), time: z.object({ start: z.number(), end: z.number(), @@ -188,11 +189,7 @@ export namespace Message { }), }) .optional(), - user: z - .object({ - snapshot: z.string().optional(), - }) - .optional(), + snapshot: z.string().optional(), }) .openapi({ ref: "MessageMetadata" }), }) @@ -208,6 +205,13 @@ export namespace Message { info: Info, }), ), + Removed: Bus.event( + "message.removed", + z.object({ + sessionID: z.string(), + messageID: z.string(), + }), + ), PartUpdated: Bus.event( "message.part.updated", z.object({ diff --git a/packages/opencode/src/session/prompt/anthropic.txt b/packages/opencode/src/session/prompt/anthropic.txt index f70bf05b..45b001e4 100644 --- a/packages/opencode/src/session/prompt/anthropic.txt +++ b/packages/opencode/src/session/prompt/anthropic.txt @@ -134,7 +134,7 @@ The user will primarily request you perform software engineering tasks. This inc - Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially. - Implement the solution using all tools available to you - Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach. -- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CLAUDE.md so that you will know to run it next time. +- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time. NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 1c77824b..722964ea 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -2,6 +2,7 @@ import { App } from "../app/app" import { Ripgrep } from "../file/ripgrep" import { Global } from "../global" import { Filesystem } from "../util/filesystem" +import { Config } from "../config/config" import path from "path" import os from "os" @@ -55,8 +56,10 @@ export namespace SystemPrompt { "CLAUDE.md", "CONTEXT.md", // deprecated ] + export async function custom() { const { cwd, root } = App.info().path + const config = await Config.get() const found = [] for (const item of CUSTOM_FILES) { const matches = await Filesystem.findUp(item, cwd, root) @@ -72,6 +75,18 @@ export namespace SystemPrompt { .text() .catch(() => ""), ) + + if (config.instructions) { + for (const instruction of config.instructions) { + try { + const matches = await Filesystem.globUp(instruction, cwd, root) + found.push(...matches.map((x) => Bun.file(x).text())) + } catch { + continue // Skip invalid glob patterns + } + } + } + return Promise.all(found).then((result) => result.filter(Boolean)) } diff --git a/packages/opencode/src/share/share.ts b/packages/opencode/src/share/share.ts index f498e0f4..a5080969 100644 --- a/packages/opencode/src/share/share.ts +++ b/packages/opencode/src/share/share.ts @@ -66,10 +66,10 @@ export namespace Share { .then((x) => x as { url: string; secret: string }) } - export async function remove(id: string) { + export async function remove(sessionID: string, secret: string) { return fetch(`${URL}/share_delete`, { method: "POST", - body: JSON.stringify({ id }), + body: JSON.stringify({ sessionID, secret }), }).then((x) => x.json()) } } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index bf8ea05f..1bbb870f 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,14 +1,7 @@ import { App } from "../app/app" -import { - add, - commit, - init, - checkout, - statusMatrix, - remove, -} from "isomorphic-git" +import { $ } from "bun" import path from "path" -import fs from "fs" +import fs from "fs/promises" import { Ripgrep } from "../file/ripgrep" import { Log } from "../util/log" @@ -16,66 +9,55 @@ export namespace Snapshot { const log = Log.create({ service: "snapshot" }) export async function create(sessionID: string) { + return + log.info("creating snapshot") const app = App.info() const git = gitdir(sessionID) - const files = await Ripgrep.files({ - cwd: app.path.cwd, - limit: app.git ? undefined : 1000, - }) - // not a git repo and too big to snapshot - if (!app.git && files.length === 1000) return - await init({ - dir: app.path.cwd, - gitdir: git, - fs, - }) - const status = await statusMatrix({ - fs, - gitdir: git, - dir: app.path.cwd, - }) - await add({ - fs, - gitdir: git, - parallel: true, - dir: app.path.cwd, - filepath: files, - }) - for (const [file, _head, workdir, stage] of status) { - if (workdir === 0 && stage === 1) { - log.info("remove", { file }) - await remove({ - fs, - gitdir: git, - dir: app.path.cwd, - filepath: file, - }) - } + + // not a git repo, check if too big to snapshot + if (!app.git) { + const files = await Ripgrep.files({ + cwd: app.path.cwd, + limit: 1000, + }) + log.info("found files", { count: files.length }) + if (files.length > 1000) return } - const result = await commit({ - fs, - gitdir: git, - dir: app.path.cwd, - message: "snapshot", - author: { - name: "opencode", - email: "mail@opencode.ai", - }, - }) - log.info("commit", { result }) - return result + + if (await fs.mkdir(git, { recursive: true })) { + await $`git init` + .env({ + ...process.env, + GIT_DIR: git, + GIT_WORK_TREE: app.path.root, + }) + .quiet() + .nothrow() + log.info("initialized") + } + + await $`git --git-dir ${git} add .`.quiet().cwd(app.path.cwd).nothrow() + log.info("added files") + + const result = + await $`git --git-dir ${git} commit --allow-empty -m "snapshot" --author="opencode "` + .quiet() + .cwd(app.path.cwd) + .nothrow() + log.info("commit") + + const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/) + if (!match) return + return match![1] } export async function restore(sessionID: string, commit: string) { log.info("restore", { commit }) const app = App.info() - await checkout({ - fs, - gitdir: gitdir(sessionID), - dir: app.path.cwd, - ref: commit, - force: true, - }) + const git = gitdir(sessionID) + await $`git --git-dir=${git} checkout ${commit} --force` + .quiet() + .cwd(app.path.root) } function gitdir(sessionID: string) { diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 0b525ef6..620a8c8d 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -4,25 +4,6 @@ import DESCRIPTION from "./bash.txt" import { App } from "../app/app" const MAX_OUTPUT_LENGTH = 30000 -export const BANNED_COMMANDS = [ - "alias", - "curl", - "curlie", - "wget", - "axel", - "aria2c", - "nc", - "telnet", - "lynx", - "w3m", - "links", - "httpie", - "xh", - "http-prompt", - "chrome", - "firefox", - "safari", -] const DEFAULT_TIMEOUT = 1 * 60 * 1000 const MAX_TIMEOUT = 10 * 60 * 1000 @@ -45,8 +26,6 @@ export const BashTool = Tool.define({ }), async execute(params, ctx) { const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT) - if (BANNED_COMMANDS.some((item) => params.command.startsWith(item))) - throw new Error(`Command '${params.command}' is not allowed`) const process = Bun.spawn({ cmd: ["bash", "-c", params.command], diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index fb02a536..8c9043e6 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -489,10 +489,10 @@ export function replace( BlockAnchorReplacer, WhitespaceNormalizedReplacer, IndentationFlexibleReplacer, - EscapeNormalizedReplacer, - TrimmedBoundaryReplacer, - ContextAwareReplacer, - MultiOccurrenceReplacer, + // EscapeNormalizedReplacer, + // TrimmedBoundaryReplacer, + // ContextAwareReplacer, + // MultiOccurrenceReplacer, ]) { for (const search of replacer(content, oldString)) { const index = content.indexOf(search) diff --git a/packages/opencode/src/tool/read.txt b/packages/opencode/src/tool/read.txt index b00740c1..d1bf8c5d 100644 --- a/packages/opencode/src/tool/read.txt +++ b/packages/opencode/src/tool/read.txt @@ -7,7 +7,7 @@ Usage: - 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 -- This tool allows OpenCode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as OpenCode is a multimodal LLM. +- This tool allows opencode to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as opencode is a multimodal LLM. - 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. - You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index bddc4025..c4fd163c 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -15,4 +15,28 @@ export namespace Filesystem { } return result } + + export async function globUp(pattern: string, start: string, stop?: string) { + let current = start + const result = [] + while (true) { + try { + const glob = new Bun.Glob(pattern) + for await (const match of glob.scan({ + cwd: current, + onlyFiles: true, + dot: true, + })) { + result.push(join(current, match)) + } + } catch { + // Skip invalid glob patterns + } + if (stop === current) break + const parent = dirname(current) + if (parent === current) break + current = parent + } + return result + } } diff --git a/packages/opencode/src/util/lazy.ts b/packages/opencode/src/util/lazy.ts index 3533bcc8..935ebe0f 100644 --- a/packages/opencode/src/util/lazy.ts +++ b/packages/opencode/src/util/lazy.ts @@ -4,6 +4,7 @@ export function lazy(fn: () => T) { return (): T => { if (loaded) return value as T + loaded = true value = fn() return value as T } diff --git a/packages/tui/go.mod b/packages/tui/go.mod index 0ea1f9da..74047af1 100644 --- a/packages/tui/go.mod +++ b/packages/tui/go.mod @@ -15,11 +15,13 @@ require ( github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.16.0 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 - github.com/sst/opencode-sdk-go v0.1.0-alpha.7 + github.com/sst/opencode-sdk-go v0.1.0-alpha.8 github.com/tidwall/gjson v1.14.4 rsc.io/qr v0.2.0 ) +replace github.com/sst/opencode-sdk-go => ./sdk + require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect require ( @@ -35,6 +37,7 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/goccy/go-yaml v1.17.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/invopop/yaml v0.3.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect diff --git a/packages/tui/go.sum b/packages/tui/go.sum index 159f2b20..fdc5bbb0 100644 --- a/packages/tui/go.sum +++ b/packages/tui/go.sum @@ -92,6 +92,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -181,8 +183,6 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/sst/opencode-sdk-go v0.1.0-alpha.7 h1:trfzTMn9o/h2fxE4z+BtJPZvCTdVHjwgXnAH/rTAx0I= -github.com/sst/opencode-sdk-go v0.1.0-alpha.7/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index b203f14a..c753c335 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -21,9 +21,6 @@ import ( "github.com/sst/opencode/internal/util" ) -var RootPath string -var CwdPath string - type App struct { Info opencode.App Version string @@ -40,6 +37,7 @@ type App struct { } type SessionSelectedMsg = *opencode.Session +type SessionLoadedMsg struct{} type ModelSelectedMsg struct { Provider opencode.Provider Model opencode.Model @@ -48,14 +46,14 @@ type SessionClearedMsg struct{} type CompactSessionMsg struct{} type SendMsg struct { Text string - Attachments []Attachment -} -type CompletionDialogTriggeredMsg struct { - InitialValue string + Attachments []opencode.FilePartParam } type OptimisticMessageAddedMsg struct { Message opencode.Message } +type FileRenderedMsg struct { + FilePath string +} func New( ctx context.Context, @@ -63,8 +61,8 @@ func New( appInfo opencode.App, httpClient *opencode.Client, ) (*App, error) { - RootPath = appInfo.Path.Root - CwdPath = appInfo.Path.Cwd + util.RootPath = appInfo.Path.Root + util.CwdPath = appInfo.Path.Cwd configInfo, err := httpClient.Config.Get(ctx) if err != nil { @@ -134,6 +132,23 @@ func New( return app, nil } +func (a *App) Key(commandName commands.CommandName) string { + t := theme.CurrentTheme() + base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render + muted := styles.NewStyle(). + Background(t.Background()). + Foreground(t.TextMuted()). + Faint(true). + Render + command := a.Commands[commandName] + kb := command.Keybindings[0] + key := kb.Key + if kb.RequiresLeader { + key = a.Config.Keybinds.Leader + " " + kb.Key + } + return base(key) + muted(" "+command.Description) +} + func (a *App) InitializeProvider() tea.Cmd { return func() tea.Msg { providersResponse, err := a.Client.Config.Providers(context.Background()) @@ -196,7 +211,10 @@ func (a *App) InitializeProvider() tea.Cmd { } } -func getDefaultModel(response *opencode.ConfigProvidersResponse, provider opencode.Provider) *opencode.Model { +func getDefaultModel( + response *opencode.ConfigProvidersResponse, + provider opencode.Provider, +) *opencode.Model { if match, ok := response.Default[provider.ID]; ok { model := provider.Models[match] return &model @@ -208,13 +226,6 @@ func getDefaultModel(response *opencode.ConfigProvidersResponse, provider openco return nil } -type Attachment struct { - FilePath string - FileName string - MimeType string - Content []byte -} - func (a *App) IsBusy() bool { if len(a.Messages) == 0 { return false @@ -287,24 +298,40 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) { return session, nil } -func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd { +func (a *App) SendChatMessage( + ctx context.Context, + text string, + attachments []opencode.FilePartParam, +) (*App, tea.Cmd) { var cmds []tea.Cmd if a.Session.ID == "" { session, err := a.CreateSession(ctx) if err != nil { - return toast.NewErrorToast(err.Error()) + return a, toast.NewErrorToast(err.Error()) } a.Session = session cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session))) } + optimisticParts := []opencode.MessagePart{{ + Type: opencode.MessagePartTypeText, + Text: text, + }} + if len(attachments) > 0 { + for _, attachment := range attachments { + optimisticParts = append(optimisticParts, opencode.MessagePart{ + Type: opencode.MessagePartTypeFile, + Filename: attachment.Filename.Value, + MediaType: attachment.MediaType.Value, + URL: attachment.URL.Value, + }) + } + } + optimisticMessage := opencode.Message{ - ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()), - Role: opencode.MessageRoleUser, - Parts: []opencode.MessagePart{{ - Type: opencode.MessagePartTypeText, - Text: text, - }}, + ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()), + Role: opencode.MessageRoleUser, + Parts: optimisticParts, Metadata: opencode.MessageMetadata{ SessionID: a.Session.ID, Time: opencode.MessageMetadataTime{ @@ -317,13 +344,25 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage})) cmds = append(cmds, func() tea.Msg { + parts := []opencode.MessagePartUnionParam{ + opencode.TextPartParam{ + Type: opencode.F(opencode.TextPartTypeText), + Text: opencode.F(text), + }, + } + if len(attachments) > 0 { + for _, attachment := range attachments { + parts = append(parts, opencode.FilePartParam{ + MediaType: attachment.MediaType, + Type: attachment.Type, + URL: attachment.URL, + Filename: attachment.Filename, + }) + } + } + _, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{ - Parts: opencode.F([]opencode.MessagePartUnionParam{ - opencode.TextPartParam{ - Type: opencode.F(opencode.TextPartTypeText), - Text: opencode.F(text), - }, - }), + Parts: opencode.F(parts), ProviderID: opencode.F(a.Provider.ID), ModelID: opencode.F(a.Model.ID), }) @@ -337,7 +376,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At // The actual response will come through SSE // For now, just return success - return tea.Batch(cmds...) + return a, tea.Batch(cmds...) } func (a *App) Cancel(ctx context.Context, sessionID string) error { diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index ae0eb494..32126686 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -82,18 +82,21 @@ const ( SessionNewCommand CommandName = "session_new" SessionListCommand CommandName = "session_list" SessionShareCommand CommandName = "session_share" + SessionUnshareCommand CommandName = "session_unshare" SessionInterruptCommand CommandName = "session_interrupt" SessionCompactCommand CommandName = "session_compact" ToolDetailsCommand CommandName = "tool_details" ModelListCommand CommandName = "model_list" ThemeListCommand CommandName = "theme_list" + FileListCommand CommandName = "file_list" + FileCloseCommand CommandName = "file_close" + FileSearchCommand CommandName = "file_search" + FileDiffToggleCommand CommandName = "file_diff_toggle" ProjectInitCommand CommandName = "project_init" InputClearCommand CommandName = "input_clear" InputPasteCommand CommandName = "input_paste" InputSubmitCommand CommandName = "input_submit" InputNewlineCommand CommandName = "input_newline" - HistoryPreviousCommand CommandName = "history_previous" - HistoryNextCommand CommandName = "history_next" MessagesPageUpCommand CommandName = "messages_page_up" MessagesPageDownCommand CommandName = "messages_page_down" MessagesHalfPageUpCommand CommandName = "messages_half_page_up" @@ -102,6 +105,9 @@ const ( MessagesNextCommand CommandName = "messages_next" MessagesFirstCommand CommandName = "messages_first" MessagesLastCommand CommandName = "messages_last" + MessagesLayoutToggleCommand CommandName = "messages_layout_toggle" + MessagesCopyCommand CommandName = "messages_copy" + MessagesRevertCommand CommandName = "messages_revert" AppExitCommand CommandName = "app_exit" ) diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go index 9775cb21..1750625d 100644 --- a/packages/tui/internal/completions/commands.go +++ b/packages/tui/internal/completions/commands.go @@ -38,13 +38,6 @@ func (c *CommandCompletionProvider) GetId() string { return "commands" } -func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI { - return dialog.NewCompletionItem(dialog.CompletionItem{ - Title: "Commands", - Value: "commands", - }) -} - func (c *CommandCompletionProvider) GetEmptyMessage() string { return "no matching commands" } diff --git a/packages/tui/internal/completions/files-folders.go b/packages/tui/internal/completions/files-folders.go index 6fb4316f..ec298af9 100644 --- a/packages/tui/internal/completions/files-folders.go +++ b/packages/tui/internal/completions/files-folders.go @@ -2,64 +2,114 @@ package completions import ( "context" + "log/slog" + "sort" + "strconv" + "strings" "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" ) type filesAndFoldersContextGroup struct { - app *app.App - prefix string + app *app.App + gitFiles []dialog.CompletionItemI } func (cg *filesAndFoldersContextGroup) GetId() string { - return cg.prefix -} - -func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI { - return dialog.NewCompletionItem(dialog.CompletionItem{ - Title: "Files & Folders", - Value: "files", - }) + return "files" } func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string { return "no matching files" } -func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) { - files, err := cg.app.Client.File.Search( - context.Background(), - opencode.FileSearchParams{Query: opencode.F(query)}, - ) - if err != nil { - return []string{}, err +func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI { + t := theme.CurrentTheme() + items := make([]dialog.CompletionItemI, 0) + base := styles.NewStyle().Background(t.BackgroundElement()) + green := base.Foreground(t.Success()).Render + red := base.Foreground(t.Error()).Render + + status, _ := cg.app.Client.File.Status(context.Background()) + if status != nil { + files := *status + sort.Slice(files, func(i, j int) bool { + return files[i].Added+files[i].Removed > files[j].Added+files[j].Removed + }) + + for _, file := range files { + title := file.File + if file.Added > 0 { + title += green(" +" + strconv.Itoa(int(file.Added))) + } + if file.Removed > 0 { + title += red(" -" + strconv.Itoa(int(file.Removed))) + } + item := dialog.NewCompletionItem(dialog.CompletionItem{ + Title: title, + Value: file.File, + }) + items = append(items, item) + } } - return *files, nil + + return items } -func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { - matches, err := cg.getFiles(query) - if err != nil { - return nil, err +func (cg *filesAndFoldersContextGroup) GetChildEntries( + query string, +) ([]dialog.CompletionItemI, error) { + items := make([]dialog.CompletionItemI, 0) + + query = strings.TrimSpace(query) + if query == "" { + items = append(items, cg.gitFiles...) } - items := make([]dialog.CompletionItemI, 0, len(matches)) - for _, file := range matches { - item := dialog.NewCompletionItem(dialog.CompletionItem{ - Title: file, - Value: file, - }) - items = append(items, item) + files, err := cg.app.Client.Find.Files( + context.Background(), + opencode.FindFilesParams{Query: opencode.F(query)}, + ) + if err != nil { + slog.Error("Failed to get completion items", "error", err) + return items, err + } + if files == nil { + return items, nil + } + + for _, file := range *files { + exists := false + for _, existing := range cg.gitFiles { + if existing.GetValue() == file { + if query != "" { + items = append(items, existing) + } + exists = true + } + } + if !exists { + item := dialog.NewCompletionItem(dialog.CompletionItem{ + Title: file, + Value: file, + }) + items = append(items, item) + } } return items, nil } func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider { - return &filesAndFoldersContextGroup{ - app: app, - prefix: "file", + cg := &filesAndFoldersContextGroup{ + app: app, } + go func() { + cg.gitFiles = cg.getGitFiles() + }() + return cg } diff --git a/packages/tui/internal/completions/manager.go b/packages/tui/internal/completions/manager.go deleted file mode 100644 index 5368208f..00000000 --- a/packages/tui/internal/completions/manager.go +++ /dev/null @@ -1,32 +0,0 @@ -package completions - -import ( - "strings" - - "github.com/sst/opencode/internal/app" - "github.com/sst/opencode/internal/components/dialog" -) - -type CompletionManager struct { - providers map[string]dialog.CompletionProvider -} - -func NewCompletionManager(app *app.App) *CompletionManager { - return &CompletionManager{ - providers: map[string]dialog.CompletionProvider{ - "files": NewFileAndFolderContextGroup(app), - "commands": NewCommandCompletionProvider(app), - }, - } -} - -func (m *CompletionManager) DefaultProvider() dialog.CompletionProvider { - return m.providers["commands"] -} - -func (m *CompletionManager) GetProvider(input string) dialog.CompletionProvider { - if strings.HasPrefix(input, "/") { - return m.providers["commands"] - } - return m.providers["files"] -} diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 6d1eeb11..0fa8b6ed 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -3,17 +3,19 @@ package chat import ( "fmt" "log/slog" + "path/filepath" "strings" "github.com/charmbracelet/bubbles/v2/spinner" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + "github.com/google/uuid" + "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/components/textarea" "github.com/sst/opencode/internal/image" - "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" @@ -26,10 +28,8 @@ type CustomCommandExecuteMsg struct { type EditorComponent interface { tea.Model - // tea.ViewModel - SetSize(width, height int) tea.Cmd - View(width int, align lipgloss.Position) string - Content(width int, align lipgloss.Position) string + View(width int) string + Content(width int) string Lines() int Value() string Focused() bool @@ -39,19 +39,12 @@ type EditorComponent interface { Clear() (tea.Model, tea.Cmd) Paste() (tea.Model, tea.Cmd) Newline() (tea.Model, tea.Cmd) - Previous() (tea.Model, tea.Cmd) - Next() (tea.Model, tea.Cmd) SetInterruptKeyInDebounce(inDebounce bool) } type editorComponent struct { app *app.App - width, height int textarea textarea.Model - attachments []app.Attachment - history []string - historyIndex int - currentMessage string spinner spinner.Model interruptKeyInDebounce bool } @@ -80,7 +73,8 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spinner = createSpinner() return m, tea.Batch(m.spinner.Tick, m.textarea.Focus()) case dialog.CompletionSelectedMsg: - if msg.IsCommand { + switch msg.ProviderID { + case "commands": commandName := strings.TrimPrefix(msg.CompletionValue, "/") // Check if this is a valid custom command (not a built-in command) @@ -91,17 +85,53 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) cmds = append(cmds, util.CmdHandler(CustomCommandExecuteMsg{Name: customCommandName, Arguments: ""})) return m, tea.Batch(cmds...) - } else { - updated, cmd := m.Clear() - m = updated.(*editorComponent) - cmds = append(cmds, cmd) - cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)]))) - return m, tea.Batch(cmds...) } - } else { - existingValue := m.textarea.Value() - // Replace the current token (after last space) + updated, cmd := m.Clear() + m = updated.(*editorComponent) + cmds = append(cmds, cmd) + cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)]))) + return m, tea.Batch(cmds...) + case "files": + atIndex := m.textarea.LastRuneIndex('@') + if atIndex == -1 { + // Should not happen, but as a fallback, just insert. + m.textarea.InsertString(msg.CompletionValue + " ") + return m, nil + } + + // The range to replace is from the '@' up to the current cursor position. + // Replace the search term (e.g., "@search") with an empty string first. + cursorCol := m.textarea.CursorColumn() + m.textarea.ReplaceRange(atIndex, cursorCol, "") + + // Now, insert the attachment at the position where the '@' was. + // The cursor is now at `atIndex` after the replacement. + filePath := msg.CompletionValue + extension := filepath.Ext(filePath) + mediaType := "" + switch extension { + case ".jpg": + mediaType = "image/jpeg" + case ".png", ".jpeg", ".gif", ".webp": + mediaType = "image/" + extension[1:] + case ".pdf": + mediaType = "application/pdf" + default: + mediaType = "text/plain" + } + attachment := &textarea.Attachment{ + ID: uuid.NewString(), + Display: "@" + filePath, + URL: fmt.Sprintf("file://./%s", filePath), + Filename: filePath, + MediaType: mediaType, + } + m.textarea.InsertAttachment(attachment) + m.textarea.InsertString(" ") + return m, nil + default: + existingValue := m.textarea.Value() lastSpaceIndex := strings.LastIndex(existingValue, " ") if lastSpaceIndex == -1 { m.textarea.SetValue(msg.CompletionValue + " ") @@ -140,7 +170,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *editorComponent) Content(width int, align lipgloss.Position) string { +func (m *editorComponent) Content(width int) string { t := theme.CurrentTheme() base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render @@ -149,6 +179,7 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string { Bold(true) prompt := promptStyle.Render(">") + m.textarea.SetWidth(width - 6) textarea := lipgloss.JoinHorizontal( lipgloss.Top, prompt, @@ -170,7 +201,15 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string { if m.app.IsBusy() { keyText := m.getInterruptKeyText() if m.interruptKeyInDebounce { - hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt") + hint = muted( + "working", + ) + m.spinner.View() + muted( + " ", + ) + base( + keyText+" again", + ) + muted( + " interrupt", + ) } else { hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt") } @@ -181,7 +220,7 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string { model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name) } - space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) + space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("") info := hint + spacer + model @@ -191,19 +230,18 @@ func (m *editorComponent) Content(width int, align lipgloss.Position) string { return content } -func (m *editorComponent) View(width int, align lipgloss.Position) string { +func (m *editorComponent) View(width int) string { if m.Lines() > 1 { - t := theme.CurrentTheme() return lipgloss.Place( width, - m.height, - align, + 5, + lipgloss.Center, lipgloss.Center, "", - styles.WhitespaceStyle(t.Background()), + styles.WhitespaceStyle(theme.CurrentTheme().Background()), ) } - return m.Content(width, align) + return m.Content(width) } func (m *editorComponent) Focused() bool { @@ -218,16 +256,6 @@ func (m *editorComponent) Blur() { m.textarea.Blur() } -func (m *editorComponent) GetSize() (width, height int) { - return m.width, m.height -} - -func (m *editorComponent) SetSize(width, height int) tea.Cmd { - m.width = width - m.height = height - return nil -} - func (m *editorComponent) Lines() int { return m.textarea.LineCount() } @@ -243,29 +271,29 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { } if len(value) > 0 && value[len(value)-1] == '\\' { // If the last character is a backslash, remove it and add a newline - m.textarea.SetValue(value[:len(value)-1] + "\n") + m.textarea.ReplaceRange(len(value)-1, len(value), "") + m.textarea.InsertString("\n") return m, nil } var cmds []tea.Cmd + + attachments := m.textarea.GetAttachments() + fileParts := make([]opencode.FilePartParam, 0) + for _, attachment := range attachments { + fileParts = append(fileParts, opencode.FilePartParam{ + Type: opencode.F(opencode.FilePartTypeFile), + MediaType: opencode.F(attachment.MediaType), + URL: opencode.F(attachment.URL), + Filename: opencode.F(attachment.Filename), + }) + } + updated, cmd := m.Clear() m = updated.(*editorComponent) cmds = append(cmds, cmd) - attachments := m.attachments - - // Save to history if not empty and not a duplicate of the last entry - if value != "" { - if len(m.history) == 0 || m.history[len(m.history)-1] != value { - m.history = append(m.history, value) - } - m.historyIndex = len(m.history) - m.currentMessage = "" - } - - m.attachments = nil - - cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments})) + cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts})) return m, tea.Batch(cmds...) } @@ -275,18 +303,23 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) { } func (m *editorComponent) Paste() (tea.Model, tea.Cmd) { - imageBytes, text, err := image.GetImageFromClipboard() + _, text, err := image.GetImageFromClipboard() if err != nil { slog.Error(err.Error()) return m, nil } - if len(imageBytes) != 0 { - attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments)) - attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"} - m.attachments = append(m.attachments, attachment) - } else { - m.textarea.SetValue(m.textarea.Value() + text) - } + // if len(imageBytes) != 0 { + // attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments)) + // attachment := app.Attachment{ + // FilePath: attachmentName, + // FileName: attachmentName, + // Content: imageBytes, + // MimeType: "image/png", + // } + // m.attachments = append(m.attachments, attachment) + // } else { + m.textarea.InsertString(text) + // } return m, nil } @@ -295,48 +328,6 @@ func (m *editorComponent) Newline() (tea.Model, tea.Cmd) { return m, nil } -func (m *editorComponent) Previous() (tea.Model, tea.Cmd) { - currentLine := m.textarea.Line() - - // Only navigate history if we're at the first line - if currentLine == 0 && len(m.history) > 0 { - // Save current message if we're just starting to navigate - if m.historyIndex == len(m.history) { - m.currentMessage = m.textarea.Value() - } - - // Go to previous message in history - if m.historyIndex > 0 { - m.historyIndex-- - m.textarea.SetValue(m.history[m.historyIndex]) - } - return m, nil - } - return m, nil -} - -func (m *editorComponent) Next() (tea.Model, tea.Cmd) { - currentLine := m.textarea.Line() - value := m.textarea.Value() - lines := strings.Split(value, "\n") - totalLines := len(lines) - - // Only navigate history if we're at the last line - if currentLine == totalLines-1 { - if m.historyIndex < len(m.history)-1 { - // Go to next message in history - m.historyIndex++ - m.textarea.SetValue(m.history[m.historyIndex]) - } else if m.historyIndex == len(m.history)-1 { - // Return to the current message being composed - m.historyIndex = len(m.history) - m.textarea.SetValue(m.currentMessage) - } - return m, nil - } - return m, nil -} - func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) { m.interruptKeyInDebounce = inDebounce } @@ -359,18 +350,31 @@ func createTextArea(existing *textarea.Model) textarea.Model { ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss() - ta.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss() + ta.Styles.Blurred.Placeholder = styles.NewStyle(). + Foreground(textMutedColor). + Background(bgColor). + Lipgloss() ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss() - ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss() + ta.Styles.Focused.Placeholder = styles.NewStyle(). + Foreground(textMutedColor). + Background(bgColor). + Lipgloss() ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() + ta.Styles.Attachment = styles.NewStyle(). + Foreground(t.Secondary()). + Background(bgColor). + Lipgloss() + ta.Styles.SelectedAttachment = styles.NewStyle(). + Foreground(t.Text()). + Background(t.Secondary()). + Lipgloss() ta.Styles.Cursor.Color = t.Primary() ta.Prompt = " " ta.ShowLineNumbers = false ta.CharLimit = -1 - ta.SetWidth(layout.Current.Container.Width - 6) if existing != nil { ta.SetValue(existing.Value()) @@ -402,9 +406,6 @@ func NewEditorComponent(app *app.App) EditorComponent { return &editorComponent{ app: app, textarea: ta, - history: []string{}, - historyIndex: 0, - currentMessage: "", spinner: s, interruptKeyInDebounce: false, } diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go index 8e4cbc1a..9e245c8b 100644 --- a/packages/tui/internal/components/chat/message.go +++ b/packages/tui/internal/components/chat/message.go @@ -3,65 +3,46 @@ package chat import ( "encoding/json" "fmt" - "path/filepath" "slices" "strings" "time" - "unicode" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2/compat" - "github.com/charmbracelet/x/ansi" "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/components/diff" "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/util" "github.com/tidwall/gjson" "golang.org/x/text/cases" "golang.org/x/text/language" ) -func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string { - r := styles.GetMarkdownRenderer(width-7, backgroundColor) - content = strings.ReplaceAll(content, app.RootPath+"/", "") - rendered, _ := r.Render(content) - lines := strings.Split(rendered, "\n") - - if len(lines) > 0 { - firstLine := lines[0] - cleaned := ansi.Strip(firstLine) - nospace := strings.ReplaceAll(cleaned, " ", "") - if nospace == "" { - lines = lines[1:] - } - if len(lines) > 0 { - lastLine := lines[len(lines)-1] - cleaned = ansi.Strip(lastLine) - nospace = strings.ReplaceAll(cleaned, " ", "") - if nospace == "" { - lines = lines[:len(lines)-1] - } - } - } - content = strings.Join(lines, "\n") - return strings.TrimSuffix(content, "\n") -} - type blockRenderer struct { - border bool - borderColor *compat.AdaptiveColor - paddingTop int - paddingBottom int - paddingLeft int - paddingRight int - marginTop int - marginBottom int + textColor compat.AdaptiveColor + border bool + borderColor *compat.AdaptiveColor + borderColorRight bool + paddingTop int + paddingBottom int + paddingLeft int + paddingRight int + marginTop int + marginBottom int } type renderingOption func(*blockRenderer) +func WithTextColor(color compat.AdaptiveColor) renderingOption { + return func(c *blockRenderer) { + c.textColor = color + } +} + func WithNoBorder() renderingOption { return func(c *blockRenderer) { c.border = false @@ -74,6 +55,13 @@ func WithBorderColor(color compat.AdaptiveColor) renderingOption { } } +func WithBorderColorRight(color compat.AdaptiveColor) renderingOption { + return func(c *blockRenderer) { + c.borderColorRight = true + c.borderColor = &color + } +} + func WithMarginTop(padding int) renderingOption { return func(c *blockRenderer) { c.marginTop = padding @@ -120,13 +108,15 @@ func WithPaddingBottom(padding int) renderingOption { } func renderContentBlock( + app *app.App, content string, + highlight bool, width int, - align lipgloss.Position, options ...renderingOption, ) string { t := theme.CurrentTheme() renderer := &blockRenderer{ + textColor: t.TextMuted(), border: true, paddingTop: 1, paddingBottom: 1, @@ -143,7 +133,7 @@ func renderContentBlock( } style := styles.NewStyle(). - Foreground(t.TextMuted()). + Foreground(renderer.textColor). Background(t.BackgroundPanel()). Width(width). PaddingTop(renderer.paddingTop). @@ -161,21 +151,30 @@ func renderContentBlock( BorderLeftBackground(t.Background()). BorderRightForeground(t.BackgroundPanel()). BorderRightBackground(t.Background()) + + if renderer.borderColorRight { + style = style. + BorderLeftBackground(t.Background()). + BorderLeftForeground(t.BackgroundPanel()). + BorderRightForeground(borderColor). + BorderRightBackground(t.Background()) + } + + if highlight { + style = style. + BorderLeftForeground(borderColor). + BorderRightForeground(borderColor) + } + } + + if highlight { + style = style. + Foreground(t.Text()). + Background(t.BackgroundElement()). + Bold(true) } content = style.Render(content) - content = lipgloss.PlaceHorizontal( - width, - lipgloss.Left, - content, - styles.WhitespaceStyle(t.Background()), - ) - content = lipgloss.PlaceHorizontal( - layout.Current.Viewport.Width, - align, - content, - styles.WhitespaceStyle(t.Background()), - ) if renderer.marginTop > 0 { for range renderer.marginTop { content = "\n" + content @@ -186,37 +185,71 @@ func renderContentBlock( content = content + "\n" } } + + if highlight { + copy := app.Key(commands.MessagesCopyCommand) + // revert := app.Key(commands.MessagesRevertCommand) + + background := t.Background() + header := layout.Render( + layout.FlexOptions{ + Background: &background, + Direction: layout.Row, + Justify: layout.JustifyCenter, + Align: layout.AlignStretch, + Width: width - 2, + Gap: 5, + }, + layout.FlexItem{ + View: copy, + }, + // layout.FlexItem{ + // View: revert, + // }, + ) + header = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(header) + + content = "\n\n\n" + header + "\n\n" + content + "\n\n\n" + } + return content } func renderText( + app *app.App, message opencode.Message, text string, author string, showToolDetails bool, + highlight bool, width int, - align lipgloss.Position, + extra string, toolCalls ...opencode.ToolInvocationPart, ) string { t := theme.CurrentTheme() - timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM") + timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)). + Local(). + Format("02 Jan 2006 03:04 PM") if time.Now().Format("02 Jan 2006") == timestamp[:11] { // don't show the date if it's today timestamp = timestamp[12:] } info := fmt.Sprintf("%s (%s)", author, timestamp) + info = styles.NewStyle().Foreground(t.TextMuted()).Render(info) - messageStyle := styles.NewStyle(). - Background(t.BackgroundPanel()). - Foreground(t.Text()) + backgroundColor := t.BackgroundPanel() + if highlight { + backgroundColor = t.BackgroundElement() + } + messageStyle := styles.NewStyle().Background(backgroundColor) if message.Role == opencode.MessageRoleUser { messageStyle = messageStyle.Width(width - 6) } content := messageStyle.Render(text) if message.Role == opencode.MessageRoleAssistant { - content = toMarkdown(text, width, t.BackgroundPanel()) + content = util.ToMarkdown(text, width, backgroundColor) } if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 { @@ -237,21 +270,28 @@ func renderText( } } - content = strings.Join([]string{content, info}, "\n") + sections := []string{content, info} + if extra != "" { + sections = append(sections, "\n"+extra) + } + content = strings.Join(sections, "\n") switch message.Role { case opencode.MessageRoleUser: return renderContentBlock( + app, content, + highlight, width, - align, - WithBorderColor(t.Secondary()), + WithTextColor(t.Text()), + WithBorderColorRight(t.Secondary()), ) case opencode.MessageRoleAssistant: return renderContentBlock( + app, content, + highlight, width, - align, WithBorderColor(t.Accent()), ) } @@ -259,10 +299,11 @@ func renderText( } func renderToolDetails( + app *app.App, toolCall opencode.ToolInvocationPart, messageMetadata opencode.MessageMetadata, + highlight bool, width int, - align lipgloss.Position, ) string { ignoredTools := []string{"todoread"} if slices.Contains(ignoredTools, toolCall.ToolInvocation.ToolName) { @@ -282,7 +323,7 @@ func renderToolDetails( if toolCall.ToolInvocation.State == "partial-call" { title := renderToolTitle(toolCall, messageMetadata, width) - return renderContentBlock(title, width, align) + return renderContentBlock(app, title, highlight, width) } toolArgsMap := make(map[string]any) @@ -301,6 +342,12 @@ func renderToolDetails( body := "" finished := result != nil && *result != "" t := theme.CurrentTheme() + backgroundColor := t.BackgroundPanel() + borderColor := t.BackgroundPanel() + if highlight { + backgroundColor = t.BackgroundElement() + borderColor = t.BorderActive() + } switch toolCall.ToolInvocation.ToolName { case "read": @@ -308,7 +355,7 @@ func renderToolDetails( if preview != nil && toolArgsMap["filePath"] != nil { filename := toolArgsMap["filePath"].(string) body = preview.(string) - body = renderFile(filename, body, width, WithTruncate(6)) + body = util.RenderFile(filename, body, width, util.WithTruncate(6)) } case "edit": if filename, ok := toolArgsMap["filePath"].(string); ok { @@ -321,38 +368,39 @@ func renderToolDetails( patch, diff.WithWidth(width-2), ) - formattedDiff = strings.TrimSpace(formattedDiff) - formattedDiff = styles.NewStyle(). - BorderStyle(lipgloss.ThickBorder()). - BorderBackground(t.Background()). - BorderForeground(t.BackgroundPanel()). - BorderLeft(true). - BorderRight(true). - Render(formattedDiff) - body = strings.TrimSpace(formattedDiff) - body = renderContentBlock( - body, - width, - align, - WithNoBorder(), - WithPadding(0), - ) + style := styles.NewStyle(). + Background(backgroundColor). + Foreground(t.TextMuted()). + Padding(1, 2). + Width(width - 4) + if highlight { + style = style.Foreground(t.Text()).Bold(true) + } if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" { - body += "\n" + renderContentBlock(diagnostics, width, align) + diagnostics = style.Render(diagnostics) + body += "\n" + diagnostics } title := renderToolTitle(toolCall, messageMetadata, width) - title = renderContentBlock(title, width, align) + title = style.Render(title) content := title + "\n" + body + content = renderContentBlock( + app, + content, + highlight, + width, + WithPadding(0), + WithBorderColor(borderColor), + ) return content } } case "write": if filename, ok := toolArgsMap["filePath"].(string); ok { if content, ok := toolArgsMap["content"].(string); ok { - body = renderFile(filename, content, width) + body = util.RenderFile(filename, content, width) if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" { body += "\n\n" + diagnostics } @@ -363,14 +411,14 @@ func renderToolDetails( if stdout != nil { command := toolArgsMap["command"].(string) body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout) - body = toMarkdown(body, width, t.BackgroundPanel()) + body = util.ToMarkdown(body, width, backgroundColor) } case "webfetch": if format, ok := toolArgsMap["format"].(string); ok && result != nil { body = *result - body = truncateHeight(body, 10) + body = util.TruncateHeight(body, 10) if format == "html" || format == "markdown" { - body = toMarkdown(body, width, t.BackgroundPanel()) + body = util.ToMarkdown(body, width, backgroundColor) } } case "todowrite": @@ -389,7 +437,7 @@ func renderToolDetails( body += fmt.Sprintf("- [ ] %s\n", content) } } - body = toMarkdown(body, width, t.BackgroundPanel()) + body = util.ToMarkdown(body, width, backgroundColor) } case "task": summary := metadata.JSON.ExtraFields["summary"] @@ -424,7 +472,7 @@ func renderToolDetails( result = &empty } body = *result - body = truncateHeight(body, 10) + body = util.TruncateHeight(body, 10) } error := "" @@ -437,18 +485,18 @@ func renderToolDetails( if error != "" { body = styles.NewStyle(). Foreground(t.Error()). - Background(t.BackgroundPanel()). + Background(backgroundColor). Render(error) } if body == "" && error == "" && result != nil { body = *result - body = truncateHeight(body, 10) + body = util.TruncateHeight(body, 10) } title := renderToolTitle(toolCall, messageMetadata, width) content := title + "\n\n" + body - return renderContentBlock(content, width, align) + return renderContentBlock(app, content, highlight, width, WithBorderColor(borderColor)) } func renderToolName(name string) string { @@ -459,8 +507,8 @@ func renderToolName(name string) string { return "Plan" default: normalizedName := name - if strings.HasPrefix(name, "opencode_") { - normalizedName = strings.TrimPrefix(name, "opencode_") + if after, ok := strings.CutPrefix(name, "opencode_"); ok { + normalizedName = after } return cases.Title(language.Und).String(normalizedName) } @@ -505,7 +553,7 @@ func renderToolTitle( title = fmt.Sprintf("%s %s", title, toolArgs) case "edit", "write": if filename, ok := toolArgsMap["filePath"].(string); ok { - title = fmt.Sprintf("%s %s", title, relative(filename)) + title = fmt.Sprintf("%s %s", title, util.Relative(filename)) } case "bash", "task": if description, ok := toolArgsMap["description"].(string); ok { @@ -551,50 +599,6 @@ func renderToolAction(name string) string { return "Working..." } -type fileRenderer struct { - filename string - content string - height int -} - -type fileRenderingOption func(*fileRenderer) - -func WithTruncate(height int) fileRenderingOption { - return func(c *fileRenderer) { - c.height = height - } -} - -func renderFile( - filename string, - content string, - width int, - options ...fileRenderingOption) string { - t := theme.CurrentTheme() - renderer := &fileRenderer{ - filename: filename, - content: content, - } - for _, option := range options { - option(renderer) - } - - lines := []string{} - for line := range strings.SplitSeq(content, "\n") { - line = strings.TrimRightFunc(line, unicode.IsSpace) - line = strings.ReplaceAll(line, "\t", " ") - lines = append(lines, line) - } - content = strings.Join(lines, "\n") - - if renderer.height > 0 { - content = truncateHeight(content, renderer.height) - } - content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content) - content = toMarkdown(content, width, t.BackgroundPanel()) - return content -} - func renderArgs(args *map[string]any, titleKey string) string { if args == nil || len(*args) == 0 { return "" @@ -614,7 +618,7 @@ func renderArgs(args *map[string]any, titleKey string) string { continue } if key == "filePath" || key == "path" { - value = relative(value.(string)) + value = util.Relative(value.(string)) } if key == titleKey { title = fmt.Sprintf("%s", value) @@ -628,29 +632,6 @@ func renderArgs(args *map[string]any, titleKey string) string { return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", ")) } -func truncateHeight(content string, height int) string { - lines := strings.Split(content, "\n") - if len(lines) > height { - return strings.Join(lines[:height], "\n") - } - return content -} - -func relative(path string) string { - path = strings.TrimPrefix(path, app.CwdPath+"/") - return strings.TrimPrefix(path, app.RootPath+"/") -} - -func extension(path string) string { - ext := filepath.Ext(path) - if ext == "" { - ext = "" - } else { - ext = strings.ToLower(ext[1:]) - } - return ext -} - // Diagnostic represents an LSP diagnostic type Diagnostic struct { Range struct { @@ -688,7 +669,10 @@ func renderDiagnostics(metadata opencode.MessageMetadataTool, filePath string) s } line := diag.Range.Start.Line + 1 // 1-based column := diag.Range.Start.Character + 1 // 1-based - errorDiagnostics = append(errorDiagnostics, fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message)) + errorDiagnostics = append( + errorDiagnostics, + fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message), + ) } if len(errorDiagnostics) == 0 { return "" diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 86779439..a59b5d79 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -17,73 +17,93 @@ import ( type MessagesComponent interface { tea.Model - tea.ViewModel - // View(width int) string - SetSize(width, height int) tea.Cmd + View(width, height int) string + SetWidth(width int) tea.Cmd PageUp() (tea.Model, tea.Cmd) PageDown() (tea.Model, tea.Cmd) HalfPageUp() (tea.Model, tea.Cmd) HalfPageDown() (tea.Model, tea.Cmd) First() (tea.Model, tea.Cmd) Last() (tea.Model, tea.Cmd) - // Previous() (tea.Model, tea.Cmd) - // Next() (tea.Model, tea.Cmd) + Previous() (tea.Model, tea.Cmd) + Next() (tea.Model, tea.Cmd) ToolDetailsVisible() bool + Selected() string } type messagesComponent struct { - width, height int + width int app *app.App viewport viewport.Model - attachments viewport.Model cache *MessageCache rendering bool showToolDetails bool tail bool + partCount int + lineCount int + selectedPart int + selectedText string } type renderFinishedMsg struct{} +type selectedMessagePartChangedMsg struct { + part int +} + type ToggleToolDetailsMsg struct{} func (m *messagesComponent) Init() tea.Cmd { return tea.Batch(m.viewport.Init()) } +func (m *messagesComponent) Selected() string { + return m.selectedText +} + func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - switch msg.(type) { + switch msg := msg.(type) { case app.SendMsg: m.viewport.GotoBottom() m.tail = true + m.selectedPart = -1 return m, nil case app.OptimisticMessageAddedMsg: - m.renderView() - if m.tail { - m.viewport.GotoBottom() - } - return m, nil + m.tail = true + m.rendering = true + return m, m.Reload() case dialog.ThemeSelectedMsg: m.cache.Clear() + m.rendering = true return m, m.Reload() case ToggleToolDetailsMsg: m.showToolDetails = !m.showToolDetails + m.rendering = true return m, m.Reload() - case app.SessionSelectedMsg: + case app.SessionLoadedMsg, app.SessionClearedMsg: m.cache.Clear() m.tail = true + m.rendering = true return m, m.Reload() - case app.SessionClearedMsg: - m.cache.Clear() - cmd := m.Reload() - return m, cmd case renderFinishedMsg: m.rendering = false if m.tail { m.viewport.GotoBottom() } - case opencode.EventListResponseEventSessionUpdated, opencode.EventListResponseEventMessageUpdated: - m.renderView() - if m.tail { - m.viewport.GotoBottom() + case selectedMessagePartChangedMsg: + return m, m.Reload() + case opencode.EventListResponseEventSessionUpdated: + if msg.Properties.Info.ID == m.app.Session.ID { + m.renderView(m.width) + if m.tail { + m.viewport.GotoBottom() + } + } + case opencode.EventListResponseEventMessageUpdated: + if msg.Properties.Info.Metadata.SessionID == m.app.Session.ID { + m.renderView(m.width) + if m.tail { + m.viewport.GotoBottom() + } } } @@ -95,62 +115,117 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *messagesComponent) renderView() { - if m.width == 0 { - return - } - +func (m *messagesComponent) renderView(width int) { measure := util.Measure("messages.renderView") defer measure("messageCount", len(m.app.Messages)) t := theme.CurrentTheme() + blocks := make([]string, 0) + m.partCount = 0 + m.lineCount = 0 - align := lipgloss.Center - width := layout.Current.Container.Width + orphanedToolCalls := make([]opencode.ToolInvocationPart, 0) - sb := strings.Builder{} - util.WriteStringsPar(&sb, m.app.Messages, func(message opencode.Message) string { + for _, message := range m.app.Messages { var content string var cached bool - blocks := make([]string, 0) switch message.Role { case opencode.MessageRoleUser: - for _, part := range message.Parts { + userLoop: + for partIndex, part := range message.Parts { switch part := part.AsUnion().(type) { case opencode.TextPart: - key := m.cache.GenerateKey(message.ID, part.Text, layout.Current.Viewport.Width) + remainingParts := message.Parts[partIndex+1:] + fileParts := make([]opencode.FilePart, 0) + for _, part := range remainingParts { + switch part := part.AsUnion().(type) { + case opencode.FilePart: + fileParts = append(fileParts, part) + } + } + flexItems := []layout.FlexItem{} + if len(fileParts) > 0 { + fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1) + mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1) + for _, filePart := range fileParts { + mediaType := "" + switch filePart.MediaType { + case "text/plain": + mediaType = "txt" + case "image/png", "image/jpeg", "image/gif", "image/webp": + mediaType = "img" + mediaTypeStyle = mediaTypeStyle.Background(t.Accent()) + case "application/pdf": + mediaType = "pdf" + mediaTypeStyle = mediaTypeStyle.Background(t.Primary()) + } + flexItems = append(flexItems, layout.FlexItem{ + View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename), + }) + } + } + bgColor := t.BackgroundPanel() + files := layout.Render( + layout.FlexOptions{ + Background: &bgColor, + Width: width - 6, + Direction: layout.Column, + }, + flexItems..., + ) + + key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount, files) content, cached = m.cache.Get(key) if !cached { content = renderText( + m.app, message, part.Text, m.app.Info.User, m.showToolDetails, + m.partCount == m.selectedPart, width, - align, + files, ) m.cache.Set(key, content) } if content != "" { + m = m.updateSelected(content, part.Text) blocks = append(blocks, content) } + // Only render the first text part + break userLoop } } case opencode.MessageRoleAssistant: - for i, p := range message.Parts { + hasTextPart := false + for partIndex, p := range message.Parts { switch part := p.AsUnion().(type) { case opencode.TextPart: + hasTextPart = true finished := message.Metadata.Time.Completed > 0 - remainingParts := message.Parts[i+1:] + remainingParts := message.Parts[partIndex+1:] toolCallParts := make([]opencode.ToolInvocationPart, 0) + + // sometimes tool calls happen without an assistant message + // these should be included in this assistant message as well + if len(orphanedToolCalls) > 0 { + toolCallParts = append(toolCallParts, orphanedToolCalls...) + orphanedToolCalls = make([]opencode.ToolInvocationPart, 0) + } + + remaining := true for _, part := range remainingParts { + if !remaining { + break + } switch part := part.AsUnion().(type) { case opencode.TextPart: // we only want tool calls associated with the current text part. // if we hit another text part, we're done. - break + remaining = false case opencode.ToolInvocationPart: toolCallParts = append(toolCallParts, part) if part.ToolInvocation.State != "result" { @@ -162,36 +237,44 @@ func (m *messagesComponent) renderView() { } if finished { - key := m.cache.GenerateKey(message.ID, p.Text, layout.Current.Viewport.Width, m.showToolDetails) + key := m.cache.GenerateKey(message.ID, p.Text, width, m.showToolDetails, m.selectedPart == m.partCount) content, cached = m.cache.Get(key) if !cached { content = renderText( + m.app, message, p.Text, message.Metadata.Assistant.ModelID, m.showToolDetails, + m.partCount == m.selectedPart, width, - align, + "", toolCallParts..., ) m.cache.Set(key, content) } } else { content = renderText( + m.app, message, p.Text, message.Metadata.Assistant.ModelID, m.showToolDetails, + m.partCount == m.selectedPart, width, - align, + "", toolCallParts..., ) } if content != "" { + m = m.updateSelected(content, p.Text) blocks = append(blocks, content) } case opencode.ToolInvocationPart: if !m.showToolDetails { + if !hasTextPart { + orphanedToolCalls = append(orphanedToolCalls, part) + } continue } @@ -199,28 +282,32 @@ func (m *messagesComponent) renderView() { key := m.cache.GenerateKey(message.ID, part.ToolInvocation.ToolCallID, m.showToolDetails, - layout.Current.Viewport.Width, + width, + m.partCount == m.selectedPart, ) content, cached = m.cache.Get(key) if !cached { content = renderToolDetails( + m.app, part, message.Metadata, + m.partCount == m.selectedPart, width, - align, ) m.cache.Set(key, content) } } else { // if the tool call isn't finished, don't cache content = renderToolDetails( + m.app, part, message.Metadata, + m.partCount == m.selectedPart, width, - align, ) } if content != "" { + m = m.updateSelected(content, "") blocks = append(blocks, content) } } @@ -240,36 +327,49 @@ func (m *messagesComponent) renderView() { if error != "" { error = renderContentBlock( + m.app, error, + false, width, - align, WithBorderColor(t.Error()), ) blocks = append(blocks, error) + m.lineCount += lipgloss.Height(error) + 1 } + } - return strings.Join(blocks, "\n\n") - }) + m.viewport.SetContent("\n" + strings.Join(blocks, "\n\n")) + if m.selectedPart == m.partCount { + m.viewport.GotoBottom() + } - content := sb.String() - - m.viewport.SetHeight(m.height - lipgloss.Height(m.header()) + 1) - m.viewport.SetContent("\n" + content) } -func (m *messagesComponent) header() string { +func (m *messagesComponent) updateSelected(content string, selectedText string) *messagesComponent { + if m.selectedPart == m.partCount { + m.viewport.SetYOffset(m.lineCount - (m.viewport.Height() / 2) + 4) + m.selectedText = selectedText + } + m.partCount++ + m.lineCount += lipgloss.Height(content) + 1 + return m +} + +func (m *messagesComponent) header(width int) string { if m.app.Session.ID == "" { return "" } t := theme.CurrentTheme() - width := layout.Current.Container.Width base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render headerLines := []string{} - headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background())) + headerLines = append( + headerLines, + util.ToMarkdown("# "+m.app.Session.Title, width-6, t.Background()), + ) if m.app.Session.Share.URL != "" { - headerLines = append(headerLines, muted(m.app.Session.Share.URL)) + headerLines = append(headerLines, muted(m.app.Session.Share.URL+" /unshare")) } else { headerLines = append(headerLines, base("/share")+muted(" to create a shareable link")) } @@ -290,31 +390,29 @@ func (m *messagesComponent) header() string { return "\n" + header + "\n" } -func (m *messagesComponent) View() string { +func (m *messagesComponent) View(width, height int) string { t := theme.CurrentTheme() if m.rendering { return lipgloss.Place( - m.width, - m.height+1, + width, + height, lipgloss.Center, lipgloss.Center, - styles.NewStyle().Background(t.Background()).Render("Loading session..."), + styles.NewStyle().Background(t.Background()).Render(""), styles.WhitespaceStyle(t.Background()), ) } - header := lipgloss.PlaceHorizontal( - m.width, - lipgloss.Center, - m.header(), - styles.WhitespaceStyle(t.Background()), - ) + header := m.header(width) + m.viewport.SetWidth(width) + m.viewport.SetHeight(height - lipgloss.Height(header)) + return styles.NewStyle(). Background(t.Background()). Render(header + "\n" + m.viewport.View()) } -func (m *messagesComponent) SetSize(width, height int) tea.Cmd { - if m.width == width && m.height == height { +func (m *messagesComponent) SetWidth(width int) tea.Cmd { + if m.width == width { return nil } // Clear cache on resize since width affects rendering @@ -322,23 +420,14 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd { m.cache.Clear() } m.width = width - m.height = height m.viewport.SetWidth(width) - m.viewport.SetHeight(height - lipgloss.Height(m.header())) - m.attachments.SetWidth(width + 40) - m.attachments.SetHeight(3) - m.renderView() + m.renderView(width) return nil } -func (m *messagesComponent) GetSize() (int, int) { - return m.width, m.height -} - func (m *messagesComponent) Reload() tea.Cmd { - m.rendering = true return func() tea.Msg { - m.renderView() + m.renderView(m.width) return renderFinishedMsg{} } } @@ -363,16 +452,45 @@ func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) { return m, nil } -func (m *messagesComponent) First() (tea.Model, tea.Cmd) { - m.viewport.GotoTop() +func (m *messagesComponent) Previous() (tea.Model, tea.Cmd) { m.tail = false - return m, nil + if m.selectedPart < 0 { + m.selectedPart = m.partCount + } + m.selectedPart-- + if m.selectedPart < 0 { + m.selectedPart = 0 + } + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) +} + +func (m *messagesComponent) Next() (tea.Model, tea.Cmd) { + m.tail = false + m.selectedPart++ + if m.selectedPart >= m.partCount { + m.selectedPart = m.partCount + } + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) +} + +func (m *messagesComponent) First() (tea.Model, tea.Cmd) { + m.selectedPart = 0 + m.tail = false + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) } func (m *messagesComponent) Last() (tea.Model, tea.Cmd) { - m.viewport.GotoBottom() + m.selectedPart = m.partCount - 1 m.tail = true - return m, nil + return m, util.CmdHandler(selectedMessagePartChangedMsg{ + part: m.selectedPart, + }) } func (m *messagesComponent) ToolDetailsVisible() bool { @@ -381,15 +499,14 @@ func (m *messagesComponent) ToolDetailsVisible() bool { func NewMessagesComponent(app *app.App) MessagesComponent { vp := viewport.New() - attachments := viewport.New() vp.KeyMap = viewport.KeyMap{} return &messagesComponent{ app: app, viewport: vp, - attachments: attachments, showToolDetails: true, cache: NewMessageCache(), tail: true, + selectedPart: -1, } } diff --git a/packages/tui/internal/components/commands/commands.go b/packages/tui/internal/components/commands/commands.go index dbd00149..f3080b38 100644 --- a/packages/tui/internal/components/commands/commands.go +++ b/packages/tui/internal/components/commands/commands.go @@ -34,10 +34,6 @@ func (c *commandsComponent) SetSize(width, height int) tea.Cmd { return nil } -func (c *commandsComponent) GetSize() (int, int) { - return c.width, c.height -} - func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) { c.background = &color } diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go index dbf0082d..ca9df7a4 100644 --- a/packages/tui/internal/components/dialog/complete.go +++ b/packages/tui/internal/components/dialog/complete.go @@ -7,7 +7,6 @@ import ( "github.com/charmbracelet/bubbles/v2/textarea" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" - "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" @@ -41,7 +40,6 @@ func (ci *CompletionItem) Render(selected bool, width int) string { title := itemStyle.Render( ci.DisplayValue(), ) - return title } @@ -59,7 +57,6 @@ func NewCompletionItem(completionItem CompletionItem) CompletionItemI { type CompletionProvider interface { GetId() string - GetEntry() CompletionItemI GetChildEntries(query string) ([]CompletionItemI, error) GetEmptyMessage() string } @@ -67,7 +64,7 @@ type CompletionProvider interface { type CompletionSelectedMsg struct { SearchString string CompletionValue string - IsCommand bool + ProviderID string } type CompletionFilledMsg struct { @@ -87,7 +84,6 @@ type CompletionDialog interface { tea.ViewModel SetWidth(width int) IsEmpty() bool - SetProvider(provider CompletionProvider) } type completionDialogComponent struct { @@ -126,8 +122,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case []CompletionItemI: c.list.SetItems(msg) - case app.CompletionDialogTriggeredMsg: - c.pseudoSearchTextArea.SetValue(msg.InitialValue) case tea.KeyMsg: if c.pseudoSearchTextArea.Focused() { if !key.Matches(msg, completionDialogKeys.Complete) { @@ -137,9 +131,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var query string query = c.pseudoSearchTextArea.Value() - if query != "" { - query = query[1:] - } if query != c.query { c.query = query @@ -191,9 +182,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, c.pseudoSearchTextArea.Focus()) return c, tea.Batch(cmds...) } - case tea.WindowSizeMsg: - c.width = msg.Width - c.height = msg.Height } return c, tea.Batch(cmds...) @@ -208,8 +196,9 @@ func (c *completionDialogComponent) View() string { for _, cmd := range completions { title := cmd.DisplayValue() - if len(title) > maxWidth-4 { - maxWidth = len(title) + 4 + width := lipgloss.Width(title) + if width > maxWidth-4 { + maxWidth = width + 4 } } @@ -235,28 +224,14 @@ func (c *completionDialogComponent) IsEmpty() bool { return c.list.IsEmpty() } -func (c *completionDialogComponent) SetProvider(provider CompletionProvider) { - if c.completionProvider.GetId() != provider.GetId() { - c.completionProvider = provider - c.list.SetEmptyMessage(" " + provider.GetEmptyMessage()) - c.list.SetItems([]CompletionItemI{}) - } -} - func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd { value := c.pseudoSearchTextArea.Value() - if value == "" { - return nil - } - - // Check if this is a command completion - isCommand := c.completionProvider.GetId() == "commands" return tea.Batch( util.CmdHandler(CompletionSelectedMsg{ SearchString: value, CompletionValue: item.GetValue(), - IsCommand: isCommand, + ProviderID: c.completionProvider.GetId(), }), c.close(), ) diff --git a/packages/tui/internal/components/dialog/find.go b/packages/tui/internal/components/dialog/find.go new file mode 100644 index 00000000..3fc6e599 --- /dev/null +++ b/packages/tui/internal/components/dialog/find.go @@ -0,0 +1,233 @@ +package dialog + +import ( + "log/slog" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/sst/opencode/internal/components/list" + "github.com/sst/opencode/internal/components/modal" + "github.com/sst/opencode/internal/layout" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/util" +) + +type FindSelectedMsg struct { + FilePath string +} + +type FindDialogCloseMsg struct{} + +type FindDialog interface { + layout.Modal + tea.Model + tea.ViewModel + SetWidth(width int) + SetHeight(height int) + IsEmpty() bool +} + +type findDialogComponent struct { + query string + completionProvider CompletionProvider + width, height int + modal *modal.Modal + textInput textinput.Model + list list.List[CompletionItemI] +} + +type findDialogKeyMap struct { + Select key.Binding + Cancel key.Binding +} + +var findDialogKeys = findDialogKeyMap{ + Select: key.NewBinding( + key.WithKeys("enter"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + ), +} + +func (f *findDialogComponent) Init() tea.Cmd { + return textinput.Blink +} + +func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case []CompletionItemI: + f.list.SetItems(msg) + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + if f.textInput.Value() == "" { + return f, nil + } + f.textInput.SetValue("") + return f.update(msg) + } + + switch { + case key.Matches(msg, findDialogKeys.Select): + item, i := f.list.GetSelectedItem() + if i == -1 { + return f, nil + } + return f, f.selectFile(item) + case key.Matches(msg, findDialogKeys.Cancel): + return f, f.Close() + default: + f.textInput, cmd = f.textInput.Update(msg) + cmds = append(cmds, cmd) + + f, cmd = f.update(msg) + cmds = append(cmds, cmd) + } + } + + return f, tea.Batch(cmds...) +} + +func (f *findDialogComponent) update(msg tea.Msg) (*findDialogComponent, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + query := f.textInput.Value() + if query != f.query { + f.query = query + cmd = func() tea.Msg { + items, err := f.completionProvider.GetChildEntries(query) + if err != nil { + slog.Error("Failed to get completion items", "error", err) + } + return items + } + cmds = append(cmds, cmd) + } + + u, cmd := f.list.Update(msg) + f.list = u.(list.List[CompletionItemI]) + cmds = append(cmds, cmd) + + return f, tea.Batch(cmds...) +} + +func (f *findDialogComponent) View() string { + t := theme.CurrentTheme() + f.textInput.SetWidth(f.width - 8) + f.list.SetMaxWidth(f.width - 4) + inputView := f.textInput.View() + inputView = styles.NewStyle(). + Background(t.BackgroundElement()). + Height(1). + Width(f.width-4). + Padding(0, 0). + Render(inputView) + + listView := f.list.View() + return styles.NewStyle().Height(12).Render(inputView + "\n" + listView) +} + +func (f *findDialogComponent) SetWidth(width int) { + f.width = width + if width > 4 { + f.textInput.SetWidth(width - 4) + f.list.SetMaxWidth(width - 4) + } +} + +func (f *findDialogComponent) SetHeight(height int) { + f.height = height +} + +func (f *findDialogComponent) IsEmpty() bool { + return f.list.IsEmpty() +} + +func (f *findDialogComponent) selectFile(item CompletionItemI) tea.Cmd { + return tea.Sequence( + f.Close(), + util.CmdHandler(FindSelectedMsg{ + FilePath: item.GetValue(), + }), + ) +} + +func (f *findDialogComponent) Render(background string) string { + return f.modal.Render(f.View(), background) +} + +func (f *findDialogComponent) Close() tea.Cmd { + f.textInput.Reset() + f.textInput.Blur() + return util.CmdHandler(modal.CloseModalMsg{}) +} + +func createTextInput(existing *textinput.Model) textinput.Model { + t := theme.CurrentTheme() + bgColor := t.BackgroundElement() + textColor := t.Text() + textMutedColor := t.TextMuted() + + ti := textinput.New() + + ti.Styles.Blurred.Placeholder = styles.NewStyle(). + Foreground(textMutedColor). + Background(bgColor). + Lipgloss() + ti.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() + ti.Styles.Focused.Placeholder = styles.NewStyle(). + Foreground(textMutedColor). + Background(bgColor). + Lipgloss() + ti.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() + ti.Styles.Cursor.Color = t.Primary() + ti.VirtualCursor = true + + ti.Prompt = " " + ti.CharLimit = -1 + ti.Focus() + + if existing != nil { + ti.SetValue(existing.Value()) + ti.SetWidth(existing.Width()) + } + + return ti +} + +func NewFindDialog(completionProvider CompletionProvider) FindDialog { + ti := createTextInput(nil) + + li := list.NewListComponent( + []CompletionItemI{}, + 10, // max visible items + completionProvider.GetEmptyMessage(), + false, + ) + + go func() { + items, err := completionProvider.GetChildEntries("") + if err != nil { + slog.Error("Failed to get completion items", "error", err) + } + li.SetItems(items) + }() + + return &findDialogComponent{ + query: "", + completionProvider: completionProvider, + textInput: ti, + list: li, + modal: modal.New( + modal.WithTitle("Find Files"), + modal.WithMaxWidth(80), + ), + } +} diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index 4ebf572e..f8cda82a 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -56,24 +56,24 @@ func (m ModelItem) Render(selected bool, width int) string { displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName) return styles.NewStyle(). Background(t.Primary()). - Foreground(t.BackgroundElement()). + Foreground(t.BackgroundPanel()). Width(width). PaddingLeft(1). Render(displayText) } else { modelStyle := styles.NewStyle(). Foreground(t.Text()). - Background(t.BackgroundElement()) + Background(t.BackgroundPanel()) providerStyle := styles.NewStyle(). Foreground(t.TextMuted()). - Background(t.BackgroundElement()) + Background(t.BackgroundPanel()) modelPart := modelStyle.Render(m.ModelName) providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName)) combinedText := modelPart + providerPart return styles.NewStyle(). - Background(t.BackgroundElement()). + Background(t.BackgroundPanel()). PaddingLeft(1). Render(combinedText) } diff --git a/packages/tui/internal/components/diff/diff.go b/packages/tui/internal/components/diff/diff.go index 3d0e41fc..03f58cc2 100644 --- a/packages/tui/internal/components/diff/diff.go +++ b/packages/tui/internal/components/diff/diff.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" "sync" + "unicode/utf8" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/formatters" @@ -73,44 +74,6 @@ type linePair struct { right *DiffLine } -// ------------------------------------------------------------------------- -// Side-by-Side Configuration -// ------------------------------------------------------------------------- - -// SideBySideConfig configures the rendering of side-by-side diffs -type SideBySideConfig struct { - TotalWidth int -} - -// SideBySideOption modifies a SideBySideConfig -type SideBySideOption func(*SideBySideConfig) - -// NewSideBySideConfig creates a SideBySideConfig with default values -func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig { - config := SideBySideConfig{ - TotalWidth: 160, // Default width for side-by-side view - } - - for _, opt := range opts { - opt(&config) - } - - return config -} - -// WithTotalWidth sets the total width for side-by-side view -func WithTotalWidth(width int) SideBySideOption { - return func(s *SideBySideConfig) { - if width > 0 { - s.TotalWidth = width - } - } -} - -// ------------------------------------------------------------------------- -// Unified Configuration -// ------------------------------------------------------------------------- - // UnifiedConfig configures the rendering of unified diffs type UnifiedConfig struct { Width int @@ -122,13 +85,22 @@ type UnifiedOption func(*UnifiedConfig) // NewUnifiedConfig creates a UnifiedConfig with default values func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig { config := UnifiedConfig{ - Width: 80, // Default width for unified view + Width: 80, } - for _, opt := range opts { opt(&config) } + return config +} +// NewSideBySideConfig creates a SideBySideConfig with default values +func NewSideBySideConfig(opts ...UnifiedOption) UnifiedConfig { + config := UnifiedConfig{ + Width: 160, + } + for _, opt := range opts { + opt(&config) + } return config } @@ -604,7 +576,10 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType, ansiSequences[visibleIdx] = lastAnsiSeq } visibleIdx++ - i++ + + // Properly advance by UTF-8 rune, not byte + _, size := utf8.DecodeRuneInString(content[i:]) + i += size } // Apply highlighting @@ -651,8 +626,9 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType, } } - // Get current character - char := string(content[i]) + // Get current character (properly handle UTF-8) + r, size := utf8.DecodeRuneInString(content[i:]) + char := string(r) if inSelection { // Get the current styling @@ -686,7 +662,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType, } currentPos++ - i++ + i += size } return sb.String() @@ -907,7 +883,7 @@ func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string { } // RenderSideBySideHunk formats a hunk for side-by-side display -func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string { +func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string { // Apply options to create the configuration config := NewSideBySideConfig(opts...) @@ -922,10 +898,10 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str pairs := pairLines(hunkCopy.Lines) // Calculate column width - colWidth := config.TotalWidth / 2 + colWidth := config.Width / 2 leftWidth := colWidth - rightWidth := config.TotalWidth - colWidth + rightWidth := config.Width - colWidth var sb strings.Builder util.WriteStringsPar(&sb, pairs, func(p linePair) string { @@ -963,7 +939,7 @@ func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption) } // FormatDiff creates a side-by-side formatted view of a diff -func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) { +func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) { diffResult, err := ParseUnifiedDiff(diffText) if err != nil { return "", err diff --git a/packages/tui/internal/components/fileviewer/fileviewer.go b/packages/tui/internal/components/fileviewer/fileviewer.go new file mode 100644 index 00000000..6627bc3f --- /dev/null +++ b/packages/tui/internal/components/fileviewer/fileviewer.go @@ -0,0 +1,281 @@ +package fileviewer + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/v2/viewport" + tea "github.com/charmbracelet/bubbletea/v2" + + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/commands" + "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/internal/components/diff" + "github.com/sst/opencode/internal/layout" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/util" +) + +type DiffStyle int + +const ( + DiffStyleSplit DiffStyle = iota + DiffStyleUnified +) + +type Model struct { + app *app.App + width, height int + viewport viewport.Model + filename *string + content *string + isDiff *bool + diffStyle DiffStyle +} + +type fileRenderedMsg struct { + content string +} + +func New(app *app.App) Model { + vp := viewport.New() + m := Model{ + app: app, + viewport: vp, + diffStyle: DiffStyleUnified, + } + if app.State.SplitDiff { + m.diffStyle = DiffStyleSplit + } + return m +} + +func (m Model) Init() tea.Cmd { + return m.viewport.Init() +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case fileRenderedMsg: + m.viewport.SetContent(msg.content) + return m, util.CmdHandler(app.FileRenderedMsg{ + FilePath: *m.filename, + }) + case dialog.ThemeSelectedMsg: + return m, m.render() + case tea.KeyMsg: + switch msg.String() { + // TODO + } + } + + vp, cmd := m.viewport.Update(msg) + m.viewport = vp + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + if !m.HasFile() { + return "" + } + + header := *m.filename + header = styles.NewStyle(). + Padding(1, 2). + Width(m.width). + Background(theme.CurrentTheme().BackgroundElement()). + Foreground(theme.CurrentTheme().Text()). + Render(header) + + t := theme.CurrentTheme() + + close := m.app.Key(commands.FileCloseCommand) + diffToggle := m.app.Key(commands.FileDiffToggleCommand) + if m.isDiff == nil || *m.isDiff == false { + diffToggle = "" + } + layoutToggle := m.app.Key(commands.MessagesLayoutToggleCommand) + + background := t.Background() + footer := layout.Render( + layout.FlexOptions{ + Background: &background, + Direction: layout.Row, + Justify: layout.JustifyCenter, + Align: layout.AlignStretch, + Width: m.width - 2, + Gap: 5, + }, + layout.FlexItem{ + View: close, + }, + layout.FlexItem{ + View: layoutToggle, + }, + layout.FlexItem{ + View: diffToggle, + }, + ) + footer = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(footer) + + return header + "\n" + m.viewport.View() + "\n" + footer +} + +func (m *Model) Clear() (Model, tea.Cmd) { + m.filename = nil + m.content = nil + m.isDiff = nil + return *m, m.render() +} + +func (m *Model) ToggleDiff() (Model, tea.Cmd) { + switch m.diffStyle { + case DiffStyleSplit: + m.diffStyle = DiffStyleUnified + default: + m.diffStyle = DiffStyleSplit + } + return *m, m.render() +} + +func (m *Model) DiffStyle() DiffStyle { + return m.diffStyle +} + +func (m Model) HasFile() bool { + return m.filename != nil && m.content != nil +} + +func (m Model) Filename() string { + if m.filename == nil { + return "" + } + return *m.filename +} + +func (m *Model) SetSize(width, height int) (Model, tea.Cmd) { + if m.width != width || m.height != height { + m.width = width + m.height = height + m.viewport.SetWidth(width) + m.viewport.SetHeight(height - 4) + return *m, m.render() + } + return *m, nil +} + +func (m *Model) SetFile(filename string, content string, isDiff bool) (Model, tea.Cmd) { + m.filename = &filename + m.content = &content + m.isDiff = &isDiff + return *m, m.render() +} + +func (m *Model) render() tea.Cmd { + if m.filename == nil || m.content == nil { + m.viewport.SetContent("") + return nil + } + + return func() tea.Msg { + t := theme.CurrentTheme() + var rendered string + + if m.isDiff != nil && *m.isDiff { + diffResult := "" + var err error + if m.diffStyle == DiffStyleSplit { + diffResult, err = diff.FormatDiff( + *m.filename, + *m.content, + diff.WithWidth(m.width), + ) + } else if m.diffStyle == DiffStyleUnified { + diffResult, err = diff.FormatUnifiedDiff( + *m.filename, + *m.content, + diff.WithWidth(m.width), + ) + } + if err != nil { + rendered = styles.NewStyle(). + Foreground(t.Error()). + Render(fmt.Sprintf("Error rendering diff: %v", err)) + } else { + rendered = strings.TrimRight(diffResult, "\n") + } + } else { + rendered = util.RenderFile( + *m.filename, + *m.content, + m.width, + ) + } + + rendered = styles.NewStyle(). + Width(m.width). + Background(t.BackgroundPanel()). + Render(rendered) + + return fileRenderedMsg{ + content: rendered, + } + } +} + +func (m *Model) ScrollTo(line int) { + m.viewport.SetYOffset(line) +} + +func (m *Model) ScrollToBottom() { + m.viewport.GotoBottom() +} + +func (m *Model) ScrollToTop() { + m.viewport.GotoTop() +} + +func (m *Model) PageUp() (Model, tea.Cmd) { + m.viewport.ViewUp() + return *m, nil +} + +func (m *Model) PageDown() (Model, tea.Cmd) { + m.viewport.ViewDown() + return *m, nil +} + +func (m *Model) HalfPageUp() (Model, tea.Cmd) { + m.viewport.HalfViewUp() + return *m, nil +} + +func (m *Model) HalfPageDown() (Model, tea.Cmd) { + m.viewport.HalfViewDown() + return *m, nil +} + +func (m Model) AtTop() bool { + return m.viewport.AtTop() +} + +func (m Model) AtBottom() bool { + return m.viewport.AtBottom() +} + +func (m Model) ScrollPercent() float64 { + return m.viewport.ScrollPercent() +} + +func (m Model) TotalLineCount() int { + return m.viewport.TotalLineCount() +} + +func (m Model) VisibleLineCount() int { + return m.viewport.VisibleLineCount() +} diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go index a7ea3458..16bc73ca 100644 --- a/packages/tui/internal/components/list/list.go +++ b/packages/tui/internal/components/list/list.go @@ -158,7 +158,12 @@ func (c *listComponent[T]) View() string { return strings.Join(listItems, "\n") } -func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] { +func NewListComponent[T ListItem]( + items []T, + maxVisibleItems int, + fallbackMsg string, + useAlphaNumericKeys bool, +) List[T] { return &listComponent[T]{ fallbackMsg: fallbackMsg, items: items, @@ -194,7 +199,12 @@ func (s StringItem) Render(selected bool, width int) string { } // NewStringList creates a new list component with string items -func NewStringList(items []string, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[StringItem] { +func NewStringList( + items []string, + maxVisibleItems int, + fallbackMsg string, + useAlphaNumericKeys bool, +) List[StringItem] { stringItems := make([]StringItem, len(items)) for i, item := range items { stringItems[i] = StringItem(item) diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go index 6bce6424..5c2fbf8b 100644 --- a/packages/tui/internal/components/modal/modal.go +++ b/packages/tui/internal/components/modal/modal.go @@ -90,7 +90,7 @@ func (m *Modal) Render(contentView string, background string) string { innerWidth := outerWidth - 4 - baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()) + baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()) var finalContent string if m.title != "" { @@ -135,7 +135,7 @@ func (m *Modal) Render(contentView string, background string) string { col := (bgWidth - modalWidth) / 2 return layout.PlaceOverlay( - col, + col-1, // TODO: whyyyyy row, modalView, background, diff --git a/packages/tui/internal/components/qr/qr.go b/packages/tui/internal/components/qr/qr.go index ccf28200..233bcf52 100644 --- a/packages/tui/internal/components/qr/qr.go +++ b/packages/tui/internal/components/qr/qr.go @@ -23,7 +23,7 @@ func Generate(text string) (string, int, error) { } // Create lipgloss style for QR code with theme colors - qrStyle := styles.NewStyleWithColors(t.Text(), t.Background()) + qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background()) var result strings.Builder diff --git a/packages/tui/internal/components/status/status.go b/packages/tui/internal/components/status/status.go index 68da3ccb..791267e0 100644 --- a/packages/tui/internal/components/status/status.go +++ b/packages/tui/internal/components/status/status.go @@ -37,7 +37,11 @@ func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m statusComponent) logo() string { t := theme.CurrentTheme() base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render - emphasis := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundElement()).Bold(true).Render + emphasis := styles.NewStyle(). + Foreground(t.Text()). + Background(t.BackgroundElement()). + Bold(true). + Render open := base("open") code := emphasis("code ") @@ -72,19 +76,16 @@ func formatTokensAndCost(tokens float64, contextWindow float64, cost float64) st formattedCost := fmt.Sprintf("$%.2f", cost) percentage := (float64(tokens) / float64(contextWindow)) * 100 - return fmt.Sprintf("Context: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost) + return fmt.Sprintf( + "Context: %s (%d%%), Cost: %s", + formattedTokens, + int(percentage), + formattedCost, + ) } func (m statusComponent) View() string { t := theme.CurrentTheme() - if m.app.Session.ID == "" { - return styles.NewStyle(). - Background(t.Background()). - Width(m.width). - Height(2). - Render("") - } - logo := m.logo() cwd := styles.NewStyle(). diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index 2ca08bb8..5ff936f1 100644 --- a/packages/tui/internal/components/textarea/textarea.go +++ b/packages/tui/internal/components/textarea/textarea.go @@ -9,6 +9,8 @@ import ( "time" "unicode" + "slices" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/v2/cursor" "github.com/charmbracelet/bubbles/v2/key" @@ -17,7 +19,6 @@ import ( "github.com/charmbracelet/x/ansi" rw "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" - "slices" ) const ( @@ -32,6 +33,145 @@ const ( maxLines = 10000 ) +// Attachment represents a special object within the text, distinct from regular characters. +type Attachment struct { + ID string // A unique identifier for this attachment instance + Display string // e.g., "@filename.txt" + URL string + Filename string + MediaType string +} + +// Helper functions for converting between runes and any slices + +// runesToInterfaces converts a slice of runes to a slice of interfaces +func runesToInterfaces(runes []rune) []any { + result := make([]any, len(runes)) + for i, r := range runes { + result[i] = r + } + return result +} + +// interfacesToRunes converts a slice of interfaces to a slice of runes (for display purposes) +func interfacesToRunes(items []any) []rune { + var result []rune + for _, item := range items { + switch val := item.(type) { + case rune: + result = append(result, val) + case *Attachment: + result = append(result, []rune(val.Display)...) + } + } + return result +} + +// copyInterfaceSlice creates a copy of an any slice +func copyInterfaceSlice(src []any) []any { + dst := make([]any, len(src)) + copy(dst, src) + return dst +} + +// interfacesToString converts a slice of interfaces to a string for display +func interfacesToString(items []any) string { + var s strings.Builder + for _, item := range items { + switch val := item.(type) { + case rune: + s.WriteRune(val) + case *Attachment: + s.WriteString(val.Display) + } + } + return s.String() +} + +// isAttachmentAtCursor checks if the cursor is positioned on or immediately after an attachment. +// This allows for proper highlighting even when the cursor is technically at the position +// after the attachment object in the underlying slice. +func (m Model) isAttachmentAtCursor() (*Attachment, int, int) { + if m.row >= len(m.value) { + return nil, -1, -1 + } + + row := m.value[m.row] + col := m.col + + if col < 0 || col > len(row) { + return nil, -1, -1 + } + + // Check if the cursor is at the same index as an attachment. + if col < len(row) { + if att, ok := row[col].(*Attachment); ok { + return att, col, col + } + } + + // Check if the cursor is immediately after an attachment. This is a common + // state, for example, after just inserting one. + if col > 0 && col <= len(row) { + if att, ok := row[col-1].(*Attachment); ok { + return att, col - 1, col - 1 + } + } + + return nil, -1, -1 +} + +// renderLineWithAttachments renders a line with proper attachment highlighting +func (m Model) renderLineWithAttachments( + items []any, + style lipgloss.Style, +) string { + var s strings.Builder + currentAttachment, _, _ := m.isAttachmentAtCursor() + + for _, item := range items { + switch val := item.(type) { + case rune: + s.WriteString(style.Render(string(val))) + case *Attachment: + // Check if this is the attachment the cursor is currently on + if currentAttachment != nil && currentAttachment.ID == val.ID { + // Cursor is on this attachment, highlight it + s.WriteString(m.Styles.SelectedAttachment.Render(val.Display)) + } else { + s.WriteString(m.Styles.Attachment.Render(val.Display)) + } + } + } + return s.String() +} + +// getRuneAt safely gets a rune at a specific position, returns 0 if not a rune +func getRuneAt(items []any, index int) rune { + if index < 0 || index >= len(items) { + return 0 + } + if r, ok := items[index].(rune); ok { + return r + } + return 0 +} + +// isSpaceAt checks if the item at index is a space rune +func isSpaceAt(items []any, index int) bool { + r := getRuneAt(items, index) + return r != 0 && unicode.IsSpace(r) +} + +// setRuneAt safely sets a rune at a specific position if it's a rune +func setRuneAt(items []any, index int, r rune) { + if index >= 0 && index < len(items) { + if _, ok := items[index].(rune); ok { + items[index] = r + } + } +} + // Internal messages for clipboard operations. type ( pasteMsg string @@ -70,30 +210,96 @@ type KeyMap struct { // upon the textarea. func DefaultKeyMap() KeyMap { return KeyMap{ - CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f"), key.WithHelp("right", "character forward")), - CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b"), key.WithHelp("left", "character backward")), - WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f"), key.WithHelp("alt+right", "word forward")), - WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b"), key.WithHelp("alt+left", "word backward")), - LineNext: key.NewBinding(key.WithKeys("down", "ctrl+n"), key.WithHelp("down", "next line")), - LinePrevious: key.NewBinding(key.WithKeys("up", "ctrl+p"), key.WithHelp("up", "previous line")), - DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w"), key.WithHelp("alt+backspace", "delete word backward")), - DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d"), key.WithHelp("alt+delete", "delete word forward")), - DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "delete after cursor")), - DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "delete before cursor")), - InsertNewline: key.NewBinding(key.WithKeys("enter", "ctrl+m"), key.WithHelp("enter", "insert newline")), - DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h"), key.WithHelp("backspace", "delete character backward")), - DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d"), key.WithHelp("delete", "delete character forward")), - LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a"), key.WithHelp("home", "line start")), - LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e"), key.WithHelp("end", "line end")), - Paste: key.NewBinding(key.WithKeys("ctrl+v"), key.WithHelp("ctrl+v", "paste")), - InputBegin: key.NewBinding(key.WithKeys("alt+<", "ctrl+home"), key.WithHelp("alt+<", "input begin")), - InputEnd: key.NewBinding(key.WithKeys("alt+>", "ctrl+end"), key.WithHelp("alt+>", "input end")), + CharacterForward: key.NewBinding( + key.WithKeys("right", "ctrl+f"), + key.WithHelp("right", "character forward"), + ), + CharacterBackward: key.NewBinding( + key.WithKeys("left", "ctrl+b"), + key.WithHelp("left", "character backward"), + ), + WordForward: key.NewBinding( + key.WithKeys("alt+right", "alt+f"), + key.WithHelp("alt+right", "word forward"), + ), + WordBackward: key.NewBinding( + key.WithKeys("alt+left", "alt+b"), + key.WithHelp("alt+left", "word backward"), + ), + LineNext: key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("down", "next line"), + ), + LinePrevious: key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("up", "previous line"), + ), + DeleteWordBackward: key.NewBinding( + key.WithKeys("alt+backspace", "ctrl+w"), + key.WithHelp("alt+backspace", "delete word backward"), + ), + DeleteWordForward: key.NewBinding( + key.WithKeys("alt+delete", "alt+d"), + key.WithHelp("alt+delete", "delete word forward"), + ), + DeleteAfterCursor: key.NewBinding( + key.WithKeys("ctrl+k"), + key.WithHelp("ctrl+k", "delete after cursor"), + ), + DeleteBeforeCursor: key.NewBinding( + key.WithKeys("ctrl+u"), + key.WithHelp("ctrl+u", "delete before cursor"), + ), + InsertNewline: key.NewBinding( + key.WithKeys("enter", "ctrl+m"), + key.WithHelp("enter", "insert newline"), + ), + DeleteCharacterBackward: key.NewBinding( + key.WithKeys("backspace", "ctrl+h"), + key.WithHelp("backspace", "delete character backward"), + ), + DeleteCharacterForward: key.NewBinding( + key.WithKeys("delete", "ctrl+d"), + key.WithHelp("delete", "delete character forward"), + ), + LineStart: key.NewBinding( + key.WithKeys("home", "ctrl+a"), + key.WithHelp("home", "line start"), + ), + LineEnd: key.NewBinding( + key.WithKeys("end", "ctrl+e"), + key.WithHelp("end", "line end"), + ), + Paste: key.NewBinding( + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "paste"), + ), + InputBegin: key.NewBinding( + key.WithKeys("alt+<", "ctrl+home"), + key.WithHelp("alt+<", "input begin"), + ), + InputEnd: key.NewBinding( + key.WithKeys("alt+>", "ctrl+end"), + key.WithHelp("alt+>", "input end"), + ), - CapitalizeWordForward: key.NewBinding(key.WithKeys("alt+c"), key.WithHelp("alt+c", "capitalize word forward")), - LowercaseWordForward: key.NewBinding(key.WithKeys("alt+l"), key.WithHelp("alt+l", "lowercase word forward")), - UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u"), key.WithHelp("alt+u", "uppercase word forward")), + CapitalizeWordForward: key.NewBinding( + key.WithKeys("alt+c"), + key.WithHelp("alt+c", "capitalize word forward"), + ), + LowercaseWordForward: key.NewBinding( + key.WithKeys("alt+l"), + key.WithHelp("alt+l", "lowercase word forward"), + ), + UppercaseWordForward: key.NewBinding( + key.WithKeys("alt+u"), + key.WithHelp("alt+u", "uppercase word forward"), + ), - TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "transpose character backward")), + TransposeCharacterBackward: key.NewBinding( + key.WithKeys("ctrl+t"), + key.WithHelp("ctrl+t", "transpose character backward"), + ), } } @@ -160,9 +366,11 @@ type CursorStyle struct { // states. The appropriate styles will be chosen based on the focus state of // the textarea. type Styles struct { - Focused StyleState - Blurred StyleState - Cursor CursorStyle + Focused StyleState + Blurred StyleState + Cursor CursorStyle + Attachment lipgloss.Style + SelectedAttachment lipgloss.Style } // StyleState that will be applied to the text area. @@ -217,13 +425,22 @@ func (s StyleState) computedText() lipgloss.Style { // line is the input to the text wrapping function. This is stored in a struct // so that it can be hashed and memoized. type line struct { - runes []rune - width int + content []any // Contains runes and *Attachment + width int } // Hash returns a hash of the line. func (w line) Hash() string { - v := fmt.Sprintf("%s:%d", string(w.runes), w.width) + var s strings.Builder + for _, item := range w.content { + switch v := item.(type) { + case rune: + s.WriteRune(v) + case *Attachment: + s.WriteString(v.ID) + } + } + v := fmt.Sprintf("%s:%d", s.String(), w.width) return fmt.Sprintf("%x", sha256.Sum256([]byte(v))) } @@ -232,7 +449,7 @@ type Model struct { Err error // General settings. - cache *MemoCache[line, [][]rune] + cache *MemoCache[line, [][]any] // Prompt is printed at the beginning of each line. // @@ -295,14 +512,14 @@ type Model struct { // if there are more lines than the permitted height. height int - // Underlying text value. - value [][]rune + // Underlying text value. Contains either rune or *Attachment types. + value [][]any // focus indicates whether user input focus should be on this input // component. When false, ignore keyboard input and hide the cursor. focus bool - // Cursor column. + // Cursor column (slice index). col int // Cursor row. @@ -328,14 +545,14 @@ func New() Model { MaxWidth: defaultMaxWidth, Prompt: lipgloss.ThickBorder().Left + " ", Styles: styles, - cache: NewMemoCache[line, [][]rune](maxLines), + cache: NewMemoCache[line, [][]any](maxLines), EndOfBufferCharacter: ' ', ShowLineNumbers: true, VirtualCursor: true, virtualCursor: cur, KeyMap: DefaultKeyMap(), - value: make([][]rune, minHeight, maxLines), + value: make([][]any, minHeight, maxLines), focus: false, col: 0, row: 0, @@ -354,25 +571,40 @@ func DefaultStyles(isDark bool) Styles { var s Styles s.Focused = StyleState{ - Base: lipgloss.NewStyle(), - CursorLine: lipgloss.NewStyle().Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))), - CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))), - EndOfBuffer: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), - LineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), - Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), - Text: lipgloss.NewStyle(), + Base: lipgloss.NewStyle(), + CursorLine: lipgloss.NewStyle(). + Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))), + CursorLineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))), + EndOfBuffer: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), + LineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), + Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), + Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + Text: lipgloss.NewStyle(), } s.Blurred = StyleState{ - Base: lipgloss.NewStyle(), - CursorLine: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), - CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - EndOfBuffer: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), - LineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), - Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), - Text: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), + Base: lipgloss.NewStyle(), + CursorLine: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), + CursorLineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), + EndOfBuffer: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), + LineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), + Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), + Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + Text: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), } + s.Attachment = lipgloss.NewStyle(). + Background(lipgloss.Color("11")). + Foreground(lipgloss.Color("0")) + s.SelectedAttachment = lipgloss.NewStyle(). + Background(lipgloss.Color("11")). + Foreground(lipgloss.Color("0")) s.Cursor = CursorStyle{ Color: lipgloss.Color("7"), Shape: tea.CursorBlock, @@ -429,6 +661,75 @@ func (m *Model) InsertRune(r rune) { m.insertRunesFromUserInput([]rune{r}) } +// InsertAttachment inserts an attachment at the cursor position. +func (m *Model) InsertAttachment(att *Attachment) { + if m.CharLimit > 0 { + availSpace := m.CharLimit - m.Length() + // If the char limit's been reached, cancel. + if availSpace <= 0 { + return + } + } + + // Insert the attachment at the current cursor position + m.value[m.row] = append( + m.value[m.row][:m.col], + append([]any{att}, m.value[m.row][m.col:]...)...) + m.col++ + m.SetCursorColumn(m.col) +} + +// ReplaceRange replaces text from startCol to endCol on the current row with the given string. +// This preserves attachments outside the replaced range. +func (m *Model) ReplaceRange(startCol, endCol int, replacement string) { + if m.row >= len(m.value) || startCol < 0 || endCol < startCol { + return + } + + // Ensure bounds are within the current row + rowLen := len(m.value[m.row]) + startCol = max(0, min(startCol, rowLen)) + endCol = max(startCol, min(endCol, rowLen)) + + // Create new row content: before + replacement + after + before := m.value[m.row][:startCol] + after := m.value[m.row][endCol:] + replacementRunes := runesToInterfaces([]rune(replacement)) + + // Combine the parts + newRow := make([]any, 0, len(before)+len(replacementRunes)+len(after)) + newRow = append(newRow, before...) + newRow = append(newRow, replacementRunes...) + newRow = append(newRow, after...) + + m.value[m.row] = newRow + + // Position cursor at end of replacement + m.col = startCol + len(replacementRunes) + m.SetCursorColumn(m.col) +} + +// CurrentRowLength returns the length of the current row. +func (m *Model) CurrentRowLength() int { + if m.row >= len(m.value) { + return 0 + } + return len(m.value[m.row]) +} + +// GetAttachments returns all attachments in the textarea. +func (m Model) GetAttachments() []*Attachment { + var attachments []*Attachment + for _, row := range m.value { + for _, item := range row { + if att, ok := item.(*Attachment); ok { + attachments = append(attachments, att) + } + } + } + return attachments +} + // insertRunesFromUserInput inserts runes at the current cursor position. func (m *Model) insertRunesFromUserInput(runes []rune) { // Clean up any special characters in the input provided by the @@ -481,23 +782,22 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { // Save the remainder of the original line at the current // cursor position. - tail := make([]rune, len(m.value[m.row][m.col:])) - copy(tail, m.value[m.row][m.col:]) + tail := copyInterfaceSlice(m.value[m.row][m.col:]) // Paste the first line at the current cursor position. - m.value[m.row] = append(m.value[m.row][:m.col], lines[0]...) + m.value[m.row] = append(m.value[m.row][:m.col], runesToInterfaces(lines[0])...) m.col += len(lines[0]) if numExtraLines := len(lines) - 1; numExtraLines > 0 { // Add the new lines. // We try to reuse the slice if there's already space. - var newGrid [][]rune + var newGrid [][]any if cap(m.value) >= len(m.value)+numExtraLines { // Can reuse the extra space. newGrid = m.value[:len(m.value)+numExtraLines] } else { // No space left; need a new slice. - newGrid = make([][]rune, len(m.value)+numExtraLines) + newGrid = make([][]any, len(m.value)+numExtraLines) copy(newGrid, m.value[:m.row+1]) } // Add all the rows that were after the cursor in the original @@ -507,7 +807,7 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { // Insert all the new lines in the middle. for _, l := range lines[1:] { m.row++ - m.value[m.row] = l + m.value[m.row] = runesToInterfaces(l) m.col = len(l) } } @@ -526,7 +826,14 @@ func (m Model) Value() string { var v strings.Builder for _, l := range m.value { - v.WriteString(string(l)) + for _, item := range l { + switch val := item.(type) { + case rune: + v.WriteRune(val) + case *Attachment: + v.WriteString(val.Display) + } + } v.WriteByte('\n') } @@ -537,7 +844,14 @@ func (m Model) Value() string { func (m *Model) Length() int { var l int for _, row := range m.value { - l += uniseg.StringWidth(string(row)) + for _, item := range row { + switch val := item.(type) { + case rune: + l += rw.RuneWidth(val) + case *Attachment: + l += uniseg.StringWidth(val.Display) + } + } } // We add len(m.value) to include the newline characters. return l + len(m.value) - 1 @@ -553,6 +867,29 @@ func (m Model) Line() int { return m.row } +// CursorColumn returns the cursor's column position (slice index). +func (m Model) CursorColumn() int { + return m.col +} + +// LastRuneIndex returns the index of the last occurrence of a rune on the current line, +// searching backwards from the current cursor position. +// Returns -1 if the rune is not found before the cursor. +func (m Model) LastRuneIndex(r rune) int { + if m.row >= len(m.value) { + return -1 + } + // Iterate backwards from just before the cursor position + for i := m.col - 1; i >= 0; i-- { + if i < len(m.value[m.row]) { + if item, ok := m.value[m.row][i].(rune); ok && item == r { + return i + } + } + } + return -1 +} + func (m *Model) Newline() { if m.MaxHeight > 0 && len(m.value) >= m.MaxHeight { return @@ -561,6 +898,39 @@ func (m *Model) Newline() { m.splitLine(m.row, m.col) } +// mapVisualOffsetToSliceIndex converts a visual column offset to a slice index. +// This is used to maintain the cursor's horizontal position when moving vertically. +func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int { + if row < 0 || row >= len(m.value) { + return 0 + } + + offset := 0 + // Find the slice index that corresponds to the visual offset. + for i, item := range m.value[row] { + var itemWidth int + switch v := item.(type) { + case rune: + itemWidth = rw.RuneWidth(v) + case *Attachment: + itemWidth = uniseg.StringWidth(v.Display) + } + + // If the target offset falls within the current item, this is our index. + if offset+itemWidth > charOffset { + // Decide whether to stick with the previous index or move to the current + // one based on which is closer to the target offset. + if (charOffset - offset) > ((offset + itemWidth) - charOffset) { + return i + 1 + } + return i + } + offset += itemWidth + } + + return len(m.value[row]) +} + // CursorDown moves the cursor down by one line. // Returns whether or not the cursor blink should be reset. func (m *Model) CursorDown() { @@ -569,31 +939,15 @@ func (m *Model) CursorDown() { m.lastCharOffset = charOffset if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 { + // Move to the next model line m.row++ - m.col = 0 - } else { - // Move the cursor to the start of the next line so that we can get - // the line information. We need to add 2 columns to account for the - // trailing space wrapping. - const trailingSpace = 2 - m.col = min(li.StartColumn+li.Width+trailingSpace, len(m.value[m.row])-1) - } - - nli := m.LineInfo() - m.col = nli.StartColumn - - if nli.Width <= 0 { - return - } - - offset := 0 - for offset < charOffset { - if m.row >= len(m.value) || m.col >= len(m.value[m.row]) || offset >= nli.CharWidth-1 { - break - } - offset += rw.RuneWidth(m.value[m.row][m.col]) - m.col++ + m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset) + } else if li.RowOffset+1 < li.Height { + // Move to the next wrapped line within the same model line + startOfNextWrappedLine := li.StartColumn + li.Width + m.col = startOfNextWrappedLine + m.mapVisualOffsetToSliceIndex(m.row, charOffset) } + m.SetCursorColumn(m.col) } // CursorUp moves the cursor up by one line. @@ -603,32 +957,24 @@ func (m *Model) CursorUp() { m.lastCharOffset = charOffset if li.RowOffset <= 0 && m.row > 0 { + // Move to the previous model line m.row-- - m.col = len(m.value[m.row]) - } else { - // Move the cursor to the end of the previous line. - // This can be done by moving the cursor to the start of the line and - // then subtracting 2 to account for the trailing space we keep on - // soft-wrapped lines. - const trailingSpace = 2 - m.col = li.StartColumn - trailingSpace - } - - nli := m.LineInfo() - m.col = nli.StartColumn - - if nli.Width <= 0 { - return - } - - offset := 0 - for offset < charOffset { - if m.col >= len(m.value[m.row]) || offset >= nli.CharWidth-1 { - break + m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset) + } else if li.RowOffset > 0 { + // Move to the previous wrapped line within the same model line + // To do this, we need to find the start of the previous wrapped line. + prevLineInfo := m.LineInfo() + // prevLineStart := 0 + if prevLineInfo.RowOffset > 0 { + // This is complex, so we'll approximate by moving to the start of the current wrapped line + // and then letting characterLeft handle it. A more precise calculation would + // require re-wrapping to find the previous line's start. + // For now, a simpler approach: + m.col = li.StartColumn - 1 } - offset += rw.RuneWidth(m.value[m.row][m.col]) - m.col++ + m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset) } + m.SetCursorColumn(m.col) } // SetCursorColumn moves the cursor to the given position. If the position is @@ -680,7 +1026,7 @@ func (m *Model) Blur() { // Reset sets the input to its default state with no input. func (m *Model) Reset() { - m.value = make([][]rune, minHeight, maxLines) + m.value = make([][]any, minHeight, maxLines) m.col = 0 m.row = 0 m.SetCursorColumn(0) @@ -741,7 +1087,7 @@ func (m *Model) deleteWordLeft() { oldCol := m.col //nolint:ifshort m.SetCursorColumn(m.col - 1) - for unicode.IsSpace(m.value[m.row][m.col]) { + for isSpaceAt(m.value[m.row], m.col) { if m.col <= 0 { break } @@ -750,7 +1096,7 @@ func (m *Model) deleteWordLeft() { } for m.col > 0 { - if !unicode.IsSpace(m.value[m.row][m.col]) { + if !isSpaceAt(m.value[m.row], m.col) { m.SetCursorColumn(m.col - 1) } else { if m.col > 0 { @@ -776,13 +1122,13 @@ func (m *Model) deleteWordRight() { oldCol := m.col - for m.col < len(m.value[m.row]) && unicode.IsSpace(m.value[m.row][m.col]) { + for m.col < len(m.value[m.row]) && isSpaceAt(m.value[m.row], m.col) { // ignore series of whitespace after cursor m.SetCursorColumn(m.col + 1) } for m.col < len(m.value[m.row]) { - if !unicode.IsSpace(m.value[m.row][m.col]) { + if !isSpaceAt(m.value[m.row], m.col) { m.SetCursorColumn(m.col + 1) } else { break @@ -832,13 +1178,13 @@ func (m *Model) characterLeft(insideLine bool) { func (m *Model) wordLeft() { for { m.characterLeft(true /* insideLine */) - if m.col < len(m.value[m.row]) && !unicode.IsSpace(m.value[m.row][m.col]) { + if m.col < len(m.value[m.row]) && !isSpaceAt(m.value[m.row], m.col) { break } } for m.col > 0 { - if unicode.IsSpace(m.value[m.row][m.col-1]) { + if isSpaceAt(m.value[m.row], m.col-1) { break } m.SetCursorColumn(m.col - 1) @@ -854,7 +1200,7 @@ func (m *Model) wordRight() { func (m *Model) doWordRight(fn func(charIdx int, pos int)) { // Skip spaces forward. - for m.col >= len(m.value[m.row]) || unicode.IsSpace(m.value[m.row][m.col]) { + for m.col >= len(m.value[m.row]) || isSpaceAt(m.value[m.row], m.col) { if m.row == len(m.value)-1 && m.col == len(m.value[m.row]) { // End of text. break @@ -864,7 +1210,7 @@ func (m *Model) doWordRight(fn func(charIdx int, pos int)) { charIdx := 0 for m.col < len(m.value[m.row]) { - if unicode.IsSpace(m.value[m.row][m.col]) { + if isSpaceAt(m.value[m.row], m.col) { break } fn(charIdx, m.col) @@ -876,14 +1222,18 @@ func (m *Model) doWordRight(fn func(charIdx int, pos int)) { // uppercaseRight changes the word to the right to uppercase. func (m *Model) uppercaseRight() { m.doWordRight(func(_ int, i int) { - m.value[m.row][i] = unicode.ToUpper(m.value[m.row][i]) + if r, ok := m.value[m.row][i].(rune); ok { + m.value[m.row][i] = unicode.ToUpper(r) + } }) } // lowercaseRight changes the word to the right to lowercase. func (m *Model) lowercaseRight() { m.doWordRight(func(_ int, i int) { - m.value[m.row][i] = unicode.ToLower(m.value[m.row][i]) + if r, ok := m.value[m.row][i].(rune); ok { + m.value[m.row][i] = unicode.ToLower(r) + } }) } @@ -891,7 +1241,9 @@ func (m *Model) lowercaseRight() { func (m *Model) capitalizeRight() { m.doWordRight(func(charIdx int, i int) { if charIdx == 0 { - m.value[m.row][i] = unicode.ToTitle(m.value[m.row][i]) + if r, ok := m.value[m.row][i].(rune); ok { + m.value[m.row][i] = unicode.ToTitle(r) + } } }) } @@ -905,34 +1257,39 @@ func (m Model) LineInfo() LineInfo { // m.col and counting the number of runes that we need to skip. var counter int for i, line := range grid { - // We've found the line that we are on - if counter+len(line) == m.col && i+1 < len(grid) { - // We wrap around to the next line if we are at the end of the - // previous line so that we can be at the very beginning of the row - return LineInfo{ - CharOffset: 0, - ColumnOffset: 0, - Height: len(grid), - RowOffset: i + 1, - StartColumn: m.col, - Width: len(grid[i+1]), - CharWidth: uniseg.StringWidth(string(line)), - } - } + start := counter + end := counter + len(line) + + if m.col >= start && m.col <= end { + // This is the wrapped line the cursor is on. + + // Special case: if the cursor is at the end of a wrapped line, + // and there's another wrapped line after it, the cursor should + // be considered at the beginning of the next line. + if m.col == end && i < len(grid)-1 { + nextLine := grid[i+1] + return LineInfo{ + CharOffset: 0, + ColumnOffset: 0, + Height: len(grid), + RowOffset: i + 1, + StartColumn: end, + Width: len(nextLine), + CharWidth: uniseg.StringWidth(interfacesToString(nextLine)), + } + } - if counter+len(line) >= m.col { return LineInfo{ - CharOffset: uniseg.StringWidth(string(line[:max(0, m.col-counter)])), - ColumnOffset: m.col - counter, + CharOffset: uniseg.StringWidth(interfacesToString(line[:max(0, m.col-start)])), + ColumnOffset: m.col - start, Height: len(grid), RowOffset: i, - StartColumn: counter, + StartColumn: start, Width: len(line), - CharWidth: uniseg.StringWidth(string(line)), + CharWidth: uniseg.StringWidth(interfacesToString(line)), } } - - counter += len(line) + counter = end } return LineInfo{} } @@ -1060,12 +1417,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd + if m.row >= len(m.value) { + m.value = append(m.value, make([]any, 0)) + } if m.value[m.row] == nil { - m.value[m.row] = make([]rune, 0) + m.value[m.row] = make([]any, 0) } if m.MaxHeight > 0 && m.MaxHeight != m.cache.Capacity() { - m.cache = NewMemoCache[line, [][]rune](m.MaxHeight) + m.cache = NewMemoCache[line, [][]any](m.MaxHeight) } switch msg := msg.(type) { @@ -1093,11 +1453,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.mergeLineAbove(m.row) break } - if len(m.value[m.row]) > 0 { - m.value[m.row] = append(m.value[m.row][:max(0, m.col-1)], m.value[m.row][m.col:]...) - if m.col > 0 { - m.SetCursorColumn(m.col - 1) - } + if len(m.value[m.row]) > 0 && m.col > 0 { + m.value[m.row] = slices.Delete(m.value[m.row], m.col-1, m.col) + m.SetCursorColumn(m.col - 1) } case key.Matches(msg, m.KeyMap.DeleteCharacterForward): if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) { @@ -1226,7 +1584,8 @@ func (m Model) View() string { widestLineNumber = lnw } - strwidth := uniseg.StringWidth(string(wrappedLine)) + wrappedLineStr := interfacesToString(wrappedLine) + strwidth := uniseg.StringWidth(wrappedLineStr) padding := m.width - strwidth // If the trailing space causes the line to be wider than the // width, we should not draw it to the screen since it will result @@ -1236,22 +1595,46 @@ func (m Model) View() string { // The character causing the line to be wider than the width is // guaranteed to be a space since any other character would // have been wrapped. - wrappedLine = []rune(strings.TrimSuffix(string(wrappedLine), " ")) + wrappedLineStr = strings.TrimSuffix(wrappedLineStr, " ") padding -= m.width - strwidth } + if m.row == l && lineInfo.RowOffset == wl { - s.WriteString(style.Render(string(wrappedLine[:lineInfo.ColumnOffset]))) + // Render the part of the line before the cursor + s.WriteString( + m.renderLineWithAttachments( + wrappedLine[:lineInfo.ColumnOffset], + style, + ), + ) + if m.col >= len(line) && lineInfo.CharOffset >= m.width { m.virtualCursor.SetChar(" ") s.WriteString(m.virtualCursor.View()) + } else if lineInfo.ColumnOffset < len(wrappedLine) { + // Render the item under the cursor + item := wrappedLine[lineInfo.ColumnOffset] + if att, ok := item.(*Attachment); ok { + // Item at cursor is an attachment. Render it with the selection style. + // This becomes the "cursor" visually. + s.WriteString(m.Styles.SelectedAttachment.Render(att.Display)) + } else { + // Item at cursor is a rune. Render it with the virtual cursor. + m.virtualCursor.SetChar(string(item.(rune))) + s.WriteString(style.Render(m.virtualCursor.View())) + } + + // Render the part of the line after the cursor + s.WriteString(m.renderLineWithAttachments(wrappedLine[lineInfo.ColumnOffset+1:], style)) } else { - m.virtualCursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) + // Cursor is at the end of the line + m.virtualCursor.SetChar(" ") s.WriteString(style.Render(m.virtualCursor.View())) - s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:]))) } } else { - s.WriteString(style.Render(string(wrappedLine))) + s.WriteString(m.renderLineWithAttachments(wrappedLine, style)) } + s.WriteString(style.Render(strings.Repeat(" ", max(0, padding)))) s.WriteRune('\n') newLines++ @@ -1443,12 +1826,12 @@ func (m Model) Cursor() *tea.Cursor { return c } -func (m Model) memoizedWrap(runes []rune, width int) [][]rune { - input := line{runes: runes, width: width} +func (m Model) memoizedWrap(content []any, width int) [][]any { + input := line{content: content, width: width} if v, ok := m.cache.Get(input); ok { return v } - v := wrap(runes, width) + v := wrapInterfaces(content, width) m.cache.Set(input, v) return v } @@ -1514,8 +1897,7 @@ func (m *Model) splitLine(row, col int) { // the cursor, take the content after the cursor and make it the content of // the line underneath, and shift the remaining lines down by one head, tailSrc := m.value[row][:col], m.value[row][col:] - tail := make([]rune, len(tailSrc)) - copy(tail, tailSrc) + tail := copyInterfaceSlice(tailSrc) m.value = append(m.value[:row+1], m.value[row:]...) @@ -1535,66 +1917,84 @@ func Paste() tea.Msg { return pasteMsg(str) } -func wrap(runes []rune, width int) [][]rune { +func wrapInterfaces(content []any, width int) [][]any { + if width <= 0 { + return [][]any{content} + } + var ( - lines = [][]rune{{}} - word = []rune{} - row int - spaces int + lines = [][]any{{}} + word = []any{} + wordW int + lineW int + spaceW int + inSpaces bool ) - // Word wrap the runes - for _, r := range runes { - if unicode.IsSpace(r) { - spaces++ - } else { - word = append(word, r) + for _, item := range content { + itemW := 0 + isSpace := false + + if r, ok := item.(rune); ok { + if unicode.IsSpace(r) { + isSpace = true + } + itemW = rw.RuneWidth(r) + } else if att, ok := item.(*Attachment); ok { + itemW = uniseg.StringWidth(att.Display) } - if spaces > 0 { //nolint:nestif - if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces > width { - row++ - lines = append(lines, []rune{}) - lines[row] = append(lines[row], word...) - lines[row] = append(lines[row], repeatSpaces(spaces)...) - spaces = 0 - word = nil - } else { - lines[row] = append(lines[row], word...) - lines[row] = append(lines[row], repeatSpaces(spaces)...) - spaces = 0 - word = nil - } - } else { - // If the last character is a double-width rune, then we may not be able to add it to this line - // as it might cause us to go past the width. - lastCharLen := rw.RuneWidth(word[len(word)-1]) - if uniseg.StringWidth(string(word))+lastCharLen > width { - // If the current line has any content, let's move to the next - // line because the current word fills up the entire line. - if len(lines[row]) > 0 { - row++ - lines = append(lines, []rune{}) + if isSpace { + if !inSpaces { + // End of a word + if lineW > 0 && lineW+wordW > width { + lines = append(lines, word) + lineW = wordW + } else { + lines[len(lines)-1] = append(lines[len(lines)-1], word...) + lineW += wordW } - lines[row] = append(lines[row], word...) word = nil + wordW = 0 } + inSpaces = true + spaceW += itemW + } else { + if inSpaces { + // End of spaces + if lineW > 0 && lineW+spaceW > width { + lines = append(lines, []any{}) + lineW = 0 + } else { + lineW += spaceW + } + // Add spaces to current line + for i := 0; i < spaceW; i++ { + lines[len(lines)-1] = append(lines[len(lines)-1], rune(' ')) + } + spaceW = 0 + } + inSpaces = false + word = append(word, item) + wordW += itemW } } - if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces >= width { - lines = append(lines, []rune{}) - lines[row+1] = append(lines[row+1], word...) - // We add an extra space at the end of the line to account for the - // trailing space at the end of the previous soft-wrapped lines so that - // behaviour when navigating is consistent and so that we don't need to - // continually add edges to handle the last line of the wrapped input. - spaces++ - lines[row+1] = append(lines[row+1], repeatSpaces(spaces)...) - } else { - lines[row] = append(lines[row], word...) - spaces++ - lines[row] = append(lines[row], repeatSpaces(spaces)...) + // Handle any remaining word/spaces + if wordW > 0 { + if lineW > 0 && lineW+wordW > width { + lines = append(lines, word) + } else { + lines[len(lines)-1] = append(lines[len(lines)-1], word...) + } + } + if spaceW > 0 { + if lineW > 0 && lineW+spaceW > width { + lines = append(lines, []any{}) + } + for i := 0; i < spaceW; i++ { + lines[len(lines)-1] = append(lines[len(lines)-1], rune(' ')) + } } return lines diff --git a/packages/tui/internal/config/config.go b/packages/tui/internal/config/config.go index 502f5531..3dd6fcf5 100644 --- a/packages/tui/internal/config/config.go +++ b/packages/tui/internal/config/config.go @@ -21,6 +21,8 @@ type State struct { Provider string `toml:"provider"` Model string `toml:"model"` RecentlyUsedModels []ModelUsage `toml:"recently_used_models"` + MessagesRight bool `toml:"messages_right"` + SplitDiff bool `toml:"split_diff"` } func NewState() *State { diff --git a/packages/tui/internal/layout/flex.go b/packages/tui/internal/layout/flex.go index c7d9ee1b..5b10a952 100644 --- a/packages/tui/internal/layout/flex.go +++ b/packages/tui/internal/layout/flex.go @@ -4,7 +4,9 @@ import ( "strings" "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/lipgloss/v2/compat" "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" ) type Direction int @@ -34,11 +36,13 @@ const ( ) type FlexOptions struct { - Direction Direction - Justify Justify - Align Align - Width int - Height int + Background *compat.AdaptiveColor + Direction Direction + Justify Justify + Align Align + Width int + Height int + Gap int } type FlexItem struct { @@ -53,6 +57,12 @@ func Render(opts FlexOptions, items ...FlexItem) string { return "" } + t := theme.CurrentTheme() + if opts.Background == nil { + background := t.Background() + opts.Background = &background + } + // Calculate dimensions for each item mainAxisSize := opts.Width crossAxisSize := opts.Height @@ -72,8 +82,14 @@ func Render(opts FlexOptions, items ...FlexItem) string { } } + // Account for gaps between items + totalGapSize := 0 + if len(items) > 1 && opts.Gap > 0 { + totalGapSize = opts.Gap * (len(items) - 1) + } + // Calculate available space for grow items - availableSpace := max(mainAxisSize-totalFixedSize, 0) + availableSpace := max(mainAxisSize-totalFixedSize-totalGapSize, 0) // Calculate size for each grow item growItemSize := 0 @@ -108,6 +124,7 @@ func Render(opts FlexOptions, items ...FlexItem) string { // For row direction, constrain width and handle height alignment if itemSize > 0 { view = styles.NewStyle(). + Background(*opts.Background). Width(itemSize). Height(crossAxisSize). Render(view) @@ -116,31 +133,65 @@ func Render(opts FlexOptions, items ...FlexItem) string { // Apply cross-axis alignment switch opts.Align { case AlignCenter: - view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Center, view) + view = lipgloss.PlaceVertical( + crossAxisSize, + lipgloss.Center, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignEnd: - view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Bottom, view) + view = lipgloss.PlaceVertical( + crossAxisSize, + lipgloss.Bottom, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignStart: - view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Top, view) + view = lipgloss.PlaceVertical( + crossAxisSize, + lipgloss.Top, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignStretch: // Already stretched by Height setting above } } else { // For column direction, constrain height and handle width alignment if itemSize > 0 { - view = styles.NewStyle(). - Height(itemSize). - Width(crossAxisSize). - Render(view) + style := styles.NewStyle(). + Background(*opts.Background). + Height(itemSize) + // Only set width for stretch alignment + if opts.Align == AlignStretch { + style = style.Width(crossAxisSize) + } + view = style.Render(view) } // Apply cross-axis alignment switch opts.Align { case AlignCenter: - view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Center, view) + view = lipgloss.PlaceHorizontal( + crossAxisSize, + lipgloss.Center, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignEnd: - view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Right, view) + view = lipgloss.PlaceHorizontal( + crossAxisSize, + lipgloss.Right, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignStart: - view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Left, view) + view = lipgloss.PlaceHorizontal( + crossAxisSize, + lipgloss.Left, + view, + styles.WhitespaceStyle(*opts.Background), + ) case AlignStretch: // Already stretched by Width setting above } @@ -154,11 +205,14 @@ func Render(opts FlexOptions, items ...FlexItem) string { } } - // Calculate total actual size + // Calculate total actual size including gaps totalActualSize := 0 for _, size := range actualSizes { totalActualSize += size } + if len(items) > 1 && opts.Gap > 0 { + totalActualSize += opts.Gap * (len(items) - 1) + } // Apply justification remainingSpace := max(mainAxisSize-totalActualSize, 0) @@ -191,12 +245,17 @@ func Render(opts FlexOptions, items ...FlexItem) string { // Build the final layout var parts []string + spaceStyle := styles.NewStyle().Background(*opts.Background) // Add space before if needed if spaceBefore > 0 { if opts.Direction == Row { - parts = append(parts, strings.Repeat(" ", spaceBefore)) + space := strings.Repeat(" ", spaceBefore) + parts = append(parts, spaceStyle.Render(space)) } else { - parts = append(parts, strings.Repeat("\n", spaceBefore)) + // For vertical layout, add empty lines as separate parts + for range spaceBefore { + parts = append(parts, "") + } } } @@ -205,11 +264,19 @@ func Render(opts FlexOptions, items ...FlexItem) string { parts = append(parts, view) // Add space between items (not after the last one) - if i < len(sizedViews)-1 && spaceBetween > 0 { - if opts.Direction == Row { - parts = append(parts, strings.Repeat(" ", spaceBetween)) - } else { - parts = append(parts, strings.Repeat("\n", spaceBetween)) + if i < len(sizedViews)-1 { + // Add gap first, then any additional spacing from justification + totalSpacing := opts.Gap + spaceBetween + if totalSpacing > 0 { + if opts.Direction == Row { + space := strings.Repeat(" ", totalSpacing) + parts = append(parts, spaceStyle.Render(space)) + } else { + // For vertical layout, add empty lines as separate parts + for range totalSpacing { + parts = append(parts, "") + } + } } } } @@ -217,9 +284,13 @@ func Render(opts FlexOptions, items ...FlexItem) string { // Add space after if needed if spaceAfter > 0 { if opts.Direction == Row { - parts = append(parts, strings.Repeat(" ", spaceAfter)) + space := strings.Repeat(" ", spaceAfter) + parts = append(parts, spaceStyle.Render(space)) } else { - parts = append(parts, strings.Repeat("\n", spaceAfter)) + // For vertical layout, add empty lines as separate parts + for range spaceAfter { + parts = append(parts, "") + } } } diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index a4b77ac5..c92ce56b 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -23,6 +23,7 @@ import ( "github.com/sst/opencode/internal/components/chat" cmdcomp "github.com/sst/opencode/internal/components/commands" "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/internal/components/fileviewer" "github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/components/status" "github.com/sst/opencode/internal/components/toast" @@ -51,7 +52,10 @@ const ( InterruptKeyFirstPress ) -const interruptDebounceTimeout = 1 * time.Second +const ( + interruptDebounceTimeout = 1 * time.Second + fileViewerFullWidthCutoff = 160 +) type appModel struct { width, height int @@ -61,13 +65,21 @@ type appModel struct { editor chat.EditorComponent messages chat.MessagesComponent completions dialog.CompletionDialog - completionManager *completions.CompletionManager + commandProvider dialog.CompletionProvider + fileProvider dialog.CompletionProvider showCompletionDialog bool + fileCompletionActive bool leaderBinding *key.Binding isLeaderSequence bool toastManager *toast.ToastManager interruptKeyState InterruptKeyState lastScroll time.Time + messagesRight bool + fileViewer fileviewer.Model + lastMouse tea.Mouse + fileViewerStart int + fileViewerEnd int + fileViewerHit bool } func (a appModel) Init() tea.Cmd { @@ -83,6 +95,7 @@ func (a appModel) Init() tea.Cmd { cmds = append(cmds, a.status.Init()) cmds = append(cmds, a.completions.Init()) cmds = append(cmds, a.toastManager.Init()) + cmds = append(cmds, a.fileViewer.Init()) // Check if we should show the init dialog cmds = append(cmds, func() tea.Msg { @@ -108,15 +121,38 @@ var BUGGED_SCROLL_KEYS = map[string]bool{ "m": true, "[": true, ";": true, + "<": true, +} + +func isScrollRelatedInput(keyString string) bool { + if len(keyString) == 0 { + return false + } + + for _, char := range keyString { + charStr := string(char) + if !BUGGED_SCROLL_KEYS[charStr] { + return false + } + } + + if len(keyString) > 3 && + (keyString[len(keyString)-1] == 'M' || keyString[len(keyString)-1] == 'm') { + return true + } + + return len(keyString) > 1 } func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: keyString := msg.String() - if time.Since(a.lastScroll) < time.Millisecond*100 && BUGGED_SCROLL_KEYS[keyString] { + + if time.Since(a.lastScroll) < time.Millisecond*100 && (BUGGED_SCROLL_KEYS[keyString] || isScrollRelatedInput(keyString)) { return a, nil } @@ -124,10 +160,20 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.modal != nil { switch keyString { // Escape always closes current modal - case "esc", "ctrl+c": + case "esc": cmd := a.modal.Close() a.modal = nil return a, cmd + case "ctrl+c": + // give the modal a chance to handle the ctrl+c + updatedModal, cmd := a.modal.Update(msg) + a.modal = updatedModal.(layout.Modal) + if cmd != nil { + return a, cmd + } + cmd = a.modal.Close() + a.modal = nil + return a, cmd } // Pass all other key presses to the modal @@ -146,37 +192,38 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // 3. Handle completions trigger - if keyString == "/" && !a.showCompletionDialog { + if keyString == "/" && + !a.showCompletionDialog && + a.editor.Value() == "" { a.showCompletionDialog = true + a.fileCompletionActive = false - initialValue := "/" - currentInput := a.editor.Value() - - // if the input doesn't end with a space, - // then we want to include the last word - // (ie, `packages/`) - if !strings.HasSuffix(currentInput, " ") { - words := strings.Split(a.editor.Value(), " ") - if len(words) > 0 { - lastWord := words[len(words)-1] - lastWord = strings.TrimSpace(lastWord) - initialValue = lastWord + "/" - } - } - - updated, cmd := a.completions.Update( - app.CompletionDialogTriggeredMsg{ - InitialValue: initialValue, - }, - ) - a.completions = updated.(dialog.CompletionDialog) - cmds = append(cmds, cmd) - - updated, cmd = a.editor.Update(msg) + updated, cmd := a.editor.Update(msg) a.editor = updated.(chat.EditorComponent) cmds = append(cmds, cmd) - updated, cmd = a.updateCompletions(msg) + // Set command provider for command completion + a.completions = dialog.NewCompletionDialogComponent(a.commandProvider) + updated, cmd = a.completions.Update(msg) + a.completions = updated.(dialog.CompletionDialog) + cmds = append(cmds, cmd) + + return a, tea.Sequence(cmds...) + } + + // Handle file completions trigger + if keyString == "@" && + !a.showCompletionDialog { + a.showCompletionDialog = true + a.fileCompletionActive = true + + updated, cmd := a.editor.Update(msg) + a.editor = updated.(chat.EditorComponent) + cmds = append(cmds, cmd) + + // Set file provider for file completion + a.completions = dialog.NewCompletionDialogComponent(a.fileProvider) + updated, cmd = a.completions.Update(msg) a.completions = updated.(dialog.CompletionDialog) cmds = append(cmds, cmd) @@ -185,8 +232,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.showCompletionDialog { switch keyString { - case "tab", "enter", "esc", "ctrl+c": - updated, cmd := a.updateCompletions(msg) + case "tab", "enter", "esc", "ctrl+c", "up", "down": + updated, cmd := a.completions.Update(msg) a.completions = updated.(dialog.CompletionDialog) cmds = append(cmds, cmd) return a, tea.Batch(cmds...) @@ -196,7 +243,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.editor = updated.(chat.EditorComponent) cmds = append(cmds, cmd) - updated, cmd = a.updateCompletions(msg) + updated, cmd = a.completions.Update(msg) a.completions = updated.(dialog.CompletionDialog) cmds = append(cmds, cmd) @@ -258,10 +305,28 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.modal != nil { return a, nil } - updated, cmd := a.messages.Update(msg) - a.messages = updated.(chat.MessagesComponent) - cmds = append(cmds, cmd) + + var cmd tea.Cmd + if a.fileViewerHit { + a.fileViewer, cmd = a.fileViewer.Update(msg) + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.Update(msg) + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) + case tea.MouseMotionMsg: + a.lastMouse = msg.Mouse() + a.fileViewerHit = a.fileViewer.HasFile() && + a.lastMouse.X > a.fileViewerStart && + a.lastMouse.X < a.fileViewerEnd + case tea.MouseClickMsg: + a.lastMouse = msg.Mouse() + a.fileViewerHit = a.fileViewer.HasFile() && + a.lastMouse.X > a.fileViewerStart && + a.lastMouse.X < a.fileViewerEnd case tea.BackgroundColorMsg: styles.Terminal = &styles.TerminalInfo{ Background: msg.Color, @@ -278,6 +343,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case modal.CloseModalMsg: + a.editor.Focus() var cmd tea.Cmd if a.modal != nil { cmd = a.modal.Close() @@ -311,10 +377,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments) + a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments) cmds = append(cmds, cmd) case dialog.CompletionDialogCloseMsg: a.showCompletionDialog = false + a.fileCompletionActive = false case opencode.EventListResponseEventInstallationUpdated: return a, toast.NewSuccessToast( "opencode updated to "+msg.Properties.Version+", restart to apply.", @@ -374,22 +441,47 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { slog.Error("Server error", "name", err.Name, "message", err.Data.Message) return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name))) } + case opencode.EventListResponseEventFileWatcherUpdated: + if a.fileViewer.HasFile() { + if a.fileViewer.Filename() == msg.Properties.File { + return a.openFile(msg.Properties.File) + } + } case tea.WindowSizeMsg: msg.Height -= 2 // Make space for the status bar a.width, a.height = msg.Width, msg.Height + container := min(a.width, 84) + if a.fileViewer.HasFile() { + if a.width < fileViewerFullWidthCutoff { + container = a.width + } else { + container = min(min(a.width, max(a.width/2, 50)), 84) + } + } layout.Current = &layout.LayoutInfo{ Viewport: layout.Dimensions{ Width: a.width, Height: a.height, }, Container: layout.Dimensions{ - Width: min(a.width, 80), + Width: container, }, } - // Update child component sizes - messagesHeight := a.height - 6 // Leave room for editor and status bar - a.messages.SetSize(a.width, messagesHeight) - a.editor.SetSize(min(a.width, 80), 5) + mainWidth := layout.Current.Container.Width + a.messages.SetWidth(mainWidth - 4) + + sideWidth := a.width - mainWidth + if a.width < fileViewerFullWidthCutoff { + sideWidth = a.width + } + a.fileViewerStart = mainWidth + a.fileViewerEnd = a.fileViewerStart + sideWidth + if a.messagesRight { + a.fileViewerStart = 0 + a.fileViewerEnd = sideWidth + } + a.fileViewer, cmd = a.fileViewer.SetSize(sideWidth, layout.Current.Viewport.Height) + cmds = append(cmds, cmd) case app.SessionSelectedMsg: messages, err := a.app.ListMessages(context.Background(), msg.ID) if err != nil { @@ -398,6 +490,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.app.Session = msg a.app.Messages = messages + return a, util.CmdHandler(app.SessionLoadedMsg{}) case app.ModelSelectedMsg: a.app.Provider = &msg.Provider a.app.Model = &msg.Model @@ -420,24 +513,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Reset interrupt key state after timeout a.interruptKeyState = InterruptKeyIdle a.editor.SetInterruptKeyInDebounce(false) + case dialog.FindSelectedMsg: + return a.openFile(msg.FilePath) } - // update status bar s, cmd := a.status.Update(msg) cmds = append(cmds, cmd) a.status = s.(status.StatusComponent) - // update editor u, cmd := a.editor.Update(msg) a.editor = u.(chat.EditorComponent) cmds = append(cmds, cmd) - // update messages u, cmd = a.messages.Update(msg) a.messages = u.(chat.MessagesComponent) cmds = append(cmds, cmd) - // update modal if a.modal != nil { u, cmd := a.modal.Update(msg) a.modal = u.(layout.Modal) @@ -450,86 +541,95 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } + fv, cmd := a.fileViewer.Update(msg) + a.fileViewer = fv + cmds = append(cmds, cmd) + return a, tea.Batch(cmds...) } func (a appModel) View() string { - mainLayout := a.chat(layout.Current.Container.Width, lipgloss.Center) + t := theme.CurrentTheme() + + var mainLayout string + mainWidth := layout.Current.Container.Width - 4 + if a.app.Session.ID == "" { + mainLayout = a.home(mainWidth) + } else { + mainLayout = a.chat(mainWidth) + } + mainLayout = styles.NewStyle(). + Background(t.Background()). + Padding(0, 2). + Render(mainLayout) + + mainHeight := lipgloss.Height(mainLayout) + + if a.fileViewer.HasFile() { + file := a.fileViewer.View() + baseStyle := styles.NewStyle().Background(t.BackgroundPanel()) + sidePanel := baseStyle.Height(mainHeight).Render(file) + if a.width >= fileViewerFullWidthCutoff { + if a.messagesRight { + mainLayout = lipgloss.JoinHorizontal( + lipgloss.Top, + sidePanel, + mainLayout, + ) + } else { + mainLayout = lipgloss.JoinHorizontal( + lipgloss.Top, + mainLayout, + sidePanel, + ) + } + } else { + mainLayout = sidePanel + } + } else { + mainLayout = lipgloss.PlaceHorizontal( + a.width, + lipgloss.Center, + mainLayout, + styles.WhitespaceStyle(t.Background()), + ) + } + + mainStyle := styles.NewStyle().Background(t.Background()) + mainLayout = mainStyle.Render(mainLayout) + if a.modal != nil { mainLayout = a.modal.Render(mainLayout) } mainLayout = a.toastManager.RenderOverlay(mainLayout) + if theme.CurrentThemeUsesAnsiColors() { mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout) } return mainLayout + "\n" + a.status.View() } -func (a appModel) chat(width int, align lipgloss.Position) string { - editorView := a.editor.View(width, align) - lines := a.editor.Lines() - messagesView := a.messages.View() - if a.app.Session.ID == "" { - messagesView = a.home() - } - editorHeight := max(lines, 5) - - t := theme.CurrentTheme() - centeredEditorView := lipgloss.PlaceHorizontal( - a.width, - align, - editorView, - styles.WhitespaceStyle(t.Background()), - ) - - mainLayout := layout.Render( - layout.FlexOptions{ - Direction: layout.Column, - Width: a.width, - Height: a.height, - }, - layout.FlexItem{ - View: messagesView, - Grow: true, - }, - layout.FlexItem{ - View: centeredEditorView, - FixedSize: 5, +func (a appModel) openFile(filepath string) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + response, err := a.app.Client.File.Read( + context.Background(), + opencode.FileReadParams{ + Path: opencode.F(filepath), }, ) - - if lines > 1 { - editorWidth := min(a.width, 80) - editorX := (a.width - editorWidth) / 2 - editorY := a.height - editorHeight - mainLayout = layout.PlaceOverlay( - editorX, - editorY, - a.editor.Content(width, align), - mainLayout, - ) + if err != nil { + slog.Error("Failed to read file", "error", err) + return a, toast.NewErrorToast("Failed to read file") } - - if a.showCompletionDialog { - editorWidth := min(a.width, 80) - editorX := (a.width - editorWidth) / 2 - a.completions.SetWidth(editorWidth) - overlay := a.completions.View() - overlayHeight := lipgloss.Height(overlay) - editorY := a.height - editorHeight + 1 - - mainLayout = layout.PlaceOverlay( - editorX, - editorY-overlayHeight, - overlay, - mainLayout, - ) - } - - return mainLayout + a.fileViewer, cmd = a.fileViewer.SetFile( + filepath, + response.Content, + response.Type == "patch", + ) + return a, cmd } -func (a appModel) home() string { +func (a appModel) home(width int) string { t := theme.CurrentTheme() baseStyle := styles.NewStyle().Background(t.Background()) base := baseStyle.Render @@ -561,7 +661,7 @@ func (a appModel) home() string { logoAndVersion := strings.Join([]string{logo, version}, "\n") logoAndVersion = lipgloss.PlaceHorizontal( - a.width, + width, lipgloss.Center, logoAndVersion, styles.WhitespaceStyle(t.Background()), @@ -572,13 +672,15 @@ func (a appModel) home() string { cmdcomp.WithLimit(6), ) cmds := lipgloss.PlaceHorizontal( - a.width, + width, lipgloss.Center, commandsView.View(), styles.WhitespaceStyle(t.Background()), ) lines := []string{} + lines = append(lines, "") + lines = append(lines, "") lines = append(lines, logoAndVersion) lines = append(lines, "") lines = append(lines, "") @@ -586,18 +688,100 @@ func (a appModel) home() string { // lines = append(lines, base("config ")+muted(config)) // lines = append(lines, "") lines = append(lines, cmds) + lines = append(lines, "") + lines = append(lines, "") - return lipgloss.Place( - a.width, - a.height-5, + mainHeight := lipgloss.Height(strings.Join(lines, "\n")) + + editorWidth := min(width, 80) + editorView := a.editor.View(editorWidth) + editorView = lipgloss.PlaceHorizontal( + width, + lipgloss.Center, + editorView, + styles.WhitespaceStyle(t.Background()), + ) + lines = append(lines, editorView) + + editorLines := a.editor.Lines() + + mainLayout := lipgloss.Place( + width, + a.height, lipgloss.Center, lipgloss.Center, baseStyle.Render(strings.Join(lines, "\n")), styles.WhitespaceStyle(t.Background()), ) + + editorX := (width - editorWidth) / 2 + editorY := (a.height / 2) + (mainHeight / 2) - 2 + + if editorLines > 1 { + mainLayout = layout.PlaceOverlay( + editorX, + editorY, + a.editor.Content(editorWidth), + mainLayout, + ) + } + + if a.showCompletionDialog { + a.completions.SetWidth(editorWidth) + overlay := a.completions.View() + overlayHeight := lipgloss.Height(overlay) + + mainLayout = layout.PlaceOverlay( + editorX, + editorY-overlayHeight+1, + overlay, + mainLayout, + ) + } + + return mainLayout +} + +func (a appModel) chat(width int) string { + editorView := a.editor.View(width) + lines := a.editor.Lines() + messagesView := a.messages.View(width, a.height-5) + + editorWidth := lipgloss.Width(editorView) + editorHeight := max(lines, 5) + + mainLayout := messagesView + "\n" + editorView + editorX := (a.width - editorWidth) / 2 + + if lines > 1 { + editorY := a.height - editorHeight + mainLayout = layout.PlaceOverlay( + editorX, + editorY, + a.editor.Content(width), + mainLayout, + ) + } + + if a.showCompletionDialog { + a.completions.SetWidth(editorWidth) + overlay := a.completions.View() + overlayHeight := lipgloss.Height(overlay) + editorY := a.height - editorHeight + 1 + + mainLayout = layout.PlaceOverlay( + editorX, + editorY-overlayHeight, + overlay, + mainLayout, + ) + } + + return mainLayout } func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) { + var cmd tea.Cmd cmds := []tea.Cmd{ util.CmdHandler(commands.CommandExecutedMsg(command)), } @@ -646,11 +830,8 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) return nil } os.Remove(tmpfile.Name()) - // attachments := m.attachments - // m.attachments = nil return app.SendMsg{ - Text: string(content), - Attachments: []app.Attachment{}, // attachments, + Text: string(content), } }) cmds = append(cmds, cmd) @@ -676,6 +857,17 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) shareUrl := response.Share.URL cmds = append(cmds, tea.SetClipboard(shareUrl)) cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!")) + case commands.SessionUnshareCommand: + if a.app.Session.ID == "" { + return a, nil + } + _, err := a.app.Client.Session.Unshare(context.Background(), a.app.Session.ID) + if err != nil { + slog.Error("Failed to unshare session", "error", err) + return a, toast.NewErrorToast("Failed to unshare session") + } + a.app.Session.Share.URL = "" + cmds = append(cmds, toast.NewSuccessToast("Session unshared successfully")) case commands.SessionInterruptCommand: if a.app.Session.ID == "" { return a, nil @@ -701,6 +893,22 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) case commands.ThemeListCommand: themeDialog := dialog.NewThemeDialog() a.modal = themeDialog + case commands.FileListCommand: + a.editor.Blur() + provider := completions.NewFileAndFolderContextGroup(a.app) + findDialog := dialog.NewFindDialog(provider) + findDialog.SetWidth(layout.Current.Container.Width - 8) + a.modal = findDialog + case commands.FileCloseCommand: + a.fileViewer, cmd = a.fileViewer.Clear() + cmds = append(cmds, cmd) + case commands.FileDiffToggleCommand: + a.fileViewer, cmd = a.fileViewer.ToggleDiff() + a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit + a.app.SaveState() + cmds = append(cmds, cmd) + case commands.FileSearchCommand: + return a, nil case commands.ProjectInitCommand: cmds = append(cmds, a.app.InitializeProject(context.Background())) case commands.InputClearCommand: @@ -722,20 +930,6 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) updated, cmd := a.editor.Newline() a.editor = updated.(chat.EditorComponent) cmds = append(cmds, cmd) - case commands.HistoryPreviousCommand: - if a.showCompletionDialog { - return a, nil - } - updated, cmd := a.editor.Previous() - a.editor = updated.(chat.EditorComponent) - cmds = append(cmds, cmd) - case commands.HistoryNextCommand: - if a.showCompletionDialog { - return a, nil - } - updated, cmd := a.editor.Next() - a.editor = updated.(chat.EditorComponent) - cmds = append(cmds, cmd) case commands.MessagesFirstCommand: updated, cmd := a.messages.First() a.messages = updated.(chat.MessagesComponent) @@ -745,21 +939,62 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) a.messages = updated.(chat.MessagesComponent) cmds = append(cmds, cmd) case commands.MessagesPageUpCommand: - updated, cmd := a.messages.PageUp() - a.messages = updated.(chat.MessagesComponent) - cmds = append(cmds, cmd) + if a.fileViewer.HasFile() { + a.fileViewer, cmd = a.fileViewer.PageUp() + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.PageUp() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } case commands.MessagesPageDownCommand: - updated, cmd := a.messages.PageDown() - a.messages = updated.(chat.MessagesComponent) - cmds = append(cmds, cmd) + if a.fileViewer.HasFile() { + a.fileViewer, cmd = a.fileViewer.PageDown() + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.PageDown() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } case commands.MessagesHalfPageUpCommand: - updated, cmd := a.messages.HalfPageUp() - a.messages = updated.(chat.MessagesComponent) - cmds = append(cmds, cmd) + if a.fileViewer.HasFile() { + a.fileViewer, cmd = a.fileViewer.HalfPageUp() + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.HalfPageUp() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } case commands.MessagesHalfPageDownCommand: - updated, cmd := a.messages.HalfPageDown() + if a.fileViewer.HasFile() { + a.fileViewer, cmd = a.fileViewer.HalfPageDown() + cmds = append(cmds, cmd) + } else { + updated, cmd := a.messages.HalfPageDown() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + } + case commands.MessagesPreviousCommand: + updated, cmd := a.messages.Previous() a.messages = updated.(chat.MessagesComponent) cmds = append(cmds, cmd) + case commands.MessagesNextCommand: + updated, cmd := a.messages.Next() + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + case commands.MessagesLayoutToggleCommand: + a.messagesRight = !a.messagesRight + a.app.State.MessagesRight = a.messagesRight + a.app.SaveState() + case commands.MessagesCopyCommand: + selected := a.messages.Selected() + if selected != "" { + cmd = tea.SetClipboard(selected) + cmds = append(cmds, cmd) + cmd = toast.NewSuccessToast("Message copied to clipboard") + cmds = append(cmds, cmd) + } + case commands.MessagesRevertCommand: case commands.AppExitCommand: return a, tea.Quit } @@ -1067,12 +1302,12 @@ func (a appModel) updateCompletions(msg tea.Msg) (tea.Model, tea.Cmd) { } func NewModel(app *app.App) tea.Model { - completionManager := completions.NewCompletionManager(app) - initialProvider := completionManager.DefaultProvider() + commandProvider := completions.NewCommandCompletionProvider(app) + fileProvider := completions.NewFileAndFolderContextGroup(app) messages := chat.NewMessagesComponent(app) editor := chat.NewEditorComponent(app) - completions := dialog.NewCompletionDialogComponent(initialProvider) + completions := dialog.NewCompletionDialogComponent(commandProvider) var leaderBinding *key.Binding if app.Config.Keybinds.Leader != "" { @@ -1086,12 +1321,16 @@ func NewModel(app *app.App) tea.Model { editor: editor, messages: messages, completions: completions, - completionManager: completionManager, + commandProvider: commandProvider, + fileProvider: fileProvider, leaderBinding: leaderBinding, isLeaderSequence: false, showCompletionDialog: false, + fileCompletionActive: false, toastManager: toast.NewToastManager(), interruptKeyState: InterruptKeyIdle, + fileViewer: fileviewer.New(app), + messagesRight: app.State.MessagesRight, } return model diff --git a/packages/tui/internal/util/concurrency.go b/packages/tui/internal/util/concurrency.go index fb6eecec..d24c7f97 100644 --- a/packages/tui/internal/util/concurrency.go +++ b/packages/tui/internal/util/concurrency.go @@ -2,49 +2,39 @@ package util import ( "strings" - "sync" ) -// MapReducePar performs a parallel map-reduce operation on a slice of items. -// It applies a function to each item in the slice concurrently, -// and combines the results serially using a reducer returned from -// each one of the functions, allowing the use of closures. -func MapReducePar[a, b any](items []a, init b, fn func(a) func(b) b) b { - itemCount := len(items) - locks := make([]*sync.Mutex, itemCount) - mapped := make([]func(b) b, itemCount) +func mapParallel[in, out any](items []in, fn func(in) out) chan out { + mapChans := make([]chan out, 0, len(items)) - for i, value := range items { - lock := &sync.Mutex{} - lock.Lock() - locks[i] = lock + for _, v := range items { + ch := make(chan out) + mapChans = append(mapChans, ch) go func() { - defer lock.Unlock() - mapped[i] = fn(value) + defer close(ch) + ch <- fn(v) }() } - result := init - for i := range itemCount { - locks[i].Lock() - defer locks[i].Unlock() - f := mapped[i] - if f != nil { - result = f(result) - } - } + resultChan := make(chan out) - return result + go func() { + defer close(resultChan) + for _, ch := range mapChans { + v := <-ch + resultChan <- v + } + }() + + return resultChan } // WriteStringsPar allows to iterate over a list and compute strings in parallel, // yet write them in order. func WriteStringsPar[a any](sb *strings.Builder, items []a, fn func(a) string) { - MapReducePar(items, sb, func(item a) func(*strings.Builder) *strings.Builder { - str := fn(item) - return func(sbdr *strings.Builder) *strings.Builder { - sbdr.WriteString(str) - return sbdr - } - }) + ch := mapParallel(items, fn) + + for v := range ch { + sb.WriteString(v) + } } diff --git a/packages/tui/internal/util/concurrency_test.go b/packages/tui/internal/util/concurrency_test.go new file mode 100644 index 00000000..6512882f --- /dev/null +++ b/packages/tui/internal/util/concurrency_test.go @@ -0,0 +1,23 @@ +package util_test + +import ( + "strconv" + "strings" + "testing" + "time" + + "github.com/sst/opencode/internal/util" +) + +func TestWriteStringsPar(t *testing.T) { + items := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} + sb := strings.Builder{} + util.WriteStringsPar(&sb, items, func(i int) string { + // sleep for the inverse duration so that later items finish first + time.Sleep(time.Duration(10-i) * time.Millisecond) + return strconv.Itoa(i) + }) + if sb.String() != "0123456789" { + t.Fatalf("expected 0123456789, got %s", sb.String()) + } +} diff --git a/packages/tui/internal/util/file.go b/packages/tui/internal/util/file.go new file mode 100644 index 00000000..b079f24c --- /dev/null +++ b/packages/tui/internal/util/file.go @@ -0,0 +1,109 @@ +package util + +import ( + "fmt" + "path/filepath" + "strings" + "unicode" + + "github.com/charmbracelet/lipgloss/v2/compat" + "github.com/charmbracelet/x/ansi" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" +) + +var RootPath string +var CwdPath string + +type fileRenderer struct { + filename string + content string + height int +} + +type fileRenderingOption func(*fileRenderer) + +func WithTruncate(height int) fileRenderingOption { + return func(c *fileRenderer) { + c.height = height + } +} + +func RenderFile( + filename string, + content string, + width int, + options ...fileRenderingOption) string { + t := theme.CurrentTheme() + renderer := &fileRenderer{ + filename: filename, + content: content, + } + for _, option := range options { + option(renderer) + } + + lines := []string{} + for line := range strings.SplitSeq(content, "\n") { + line = strings.TrimRightFunc(line, unicode.IsSpace) + line = strings.ReplaceAll(line, "\t", " ") + lines = append(lines, line) + } + content = strings.Join(lines, "\n") + + if renderer.height > 0 { + content = TruncateHeight(content, renderer.height) + } + content = fmt.Sprintf("```%s\n%s\n```", Extension(renderer.filename), content) + content = ToMarkdown(content, width, t.BackgroundPanel()) + return content +} + +func TruncateHeight(content string, height int) string { + lines := strings.Split(content, "\n") + if len(lines) > height { + return strings.Join(lines[:height], "\n") + } + return content +} + +func Relative(path string) string { + path = strings.TrimPrefix(path, CwdPath+"/") + return strings.TrimPrefix(path, RootPath+"/") +} + +func Extension(path string) string { + ext := filepath.Ext(path) + if ext == "" { + ext = "" + } else { + ext = strings.ToLower(ext[1:]) + } + return ext +} + +func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string { + r := styles.GetMarkdownRenderer(width-6, backgroundColor) + content = strings.ReplaceAll(content, RootPath+"/", "") + rendered, _ := r.Render(content) + lines := strings.Split(rendered, "\n") + + if len(lines) > 0 { + firstLine := lines[0] + cleaned := ansi.Strip(firstLine) + nospace := strings.ReplaceAll(cleaned, " ", "") + if nospace == "" { + lines = lines[1:] + } + if len(lines) > 0 { + lastLine := lines[len(lines)-1] + cleaned = ansi.Strip(lastLine) + nospace = strings.ReplaceAll(cleaned, " ", "") + if nospace == "" { + lines = lines[:len(lines)-1] + } + } + } + content = strings.Join(lines, "\n") + return strings.TrimSuffix(content, "\n") +} diff --git a/packages/tui/sdk/.devcontainer/devcontainer.json b/packages/tui/sdk/.devcontainer/devcontainer.json new file mode 100644 index 00000000..889ae347 --- /dev/null +++ b/packages/tui/sdk/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/debian +{ + "name": "Development", + "image": "mcr.microsoft.com/devcontainers/go:1.23-bookworm", + "postCreateCommand": "go mod tidy" +} diff --git a/packages/tui/sdk/.github/workflows/ci.yml b/packages/tui/sdk/.github/workflows/ci.yml new file mode 100644 index 00000000..4bf1e907 --- /dev/null +++ b/packages/tui/sdk/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI +on: + push: + branches-ignore: + - 'generated' + - 'codegen/**' + - 'integrated/**' + - 'stl-preview-head/**' + - 'stl-preview-base/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' + +jobs: + lint: + timeout-minutes: 10 + name: lint + runs-on: ${{ github.repository == 'stainless-sdks/opencode-go' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + + steps: + - uses: actions/checkout@v4 + + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version-file: ./go.mod + + - name: Run lints + run: ./scripts/lint + test: + timeout-minutes: 10 + name: test + runs-on: ${{ github.repository == 'stainless-sdks/opencode-go' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + steps: + - uses: actions/checkout@v4 + + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version-file: ./go.mod + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Run tests + run: ./scripts/test diff --git a/packages/tui/sdk/.gitignore b/packages/tui/sdk/.gitignore new file mode 100644 index 00000000..c6d05015 --- /dev/null +++ b/packages/tui/sdk/.gitignore @@ -0,0 +1,4 @@ +.prism.log +codegen.log +Brewfile.lock.json +.idea/ diff --git a/packages/tui/sdk/.release-please-manifest.json b/packages/tui/sdk/.release-please-manifest.json new file mode 100644 index 00000000..c373724d --- /dev/null +++ b/packages/tui/sdk/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0-alpha.8" +} \ No newline at end of file diff --git a/packages/tui/sdk/.stats.yml b/packages/tui/sdk/.stats.yml new file mode 100644 index 00000000..ab6c4a20 --- /dev/null +++ b/packages/tui/sdk/.stats.yml @@ -0,0 +1,4 @@ +configured_endpoints: 20 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-945f9da9e9a4c4008834deef63e4346c0076e020eed3d3c98c249095033c1ac5.yml +openapi_spec_hash: 522a44f6cb0677435fe2ac7693848ad7 +config_hash: 6c8822d278ba83456e5eed6d774ca230 diff --git a/packages/tui/sdk/Brewfile b/packages/tui/sdk/Brewfile new file mode 100644 index 00000000..577e34a4 --- /dev/null +++ b/packages/tui/sdk/Brewfile @@ -0,0 +1 @@ +brew "go" diff --git a/packages/tui/sdk/CHANGELOG.md b/packages/tui/sdk/CHANGELOG.md new file mode 100644 index 00000000..bc407fad --- /dev/null +++ b/packages/tui/sdk/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog + +## 0.1.0-alpha.8 (2025-07-02) + +Full Changelog: [v0.1.0-alpha.7...v0.1.0-alpha.8](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.7...v0.1.0-alpha.8) + +### Features + +* **api:** update via SDK Studio ([651e937](https://github.com/sst/opencode-sdk-go/commit/651e937c334e1caba3b968e6cac865c219879519)) + +## 0.1.0-alpha.7 (2025-06-30) + +Full Changelog: [v0.1.0-alpha.6...v0.1.0-alpha.7](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.6...v0.1.0-alpha.7) + +### Features + +* **api:** update via SDK Studio ([13550a5](https://github.com/sst/opencode-sdk-go/commit/13550a5c65d77325e945ed99fe0799cd1107b775)) +* **api:** update via SDK Studio ([7b73730](https://github.com/sst/opencode-sdk-go/commit/7b73730c7fa62ba966dda3541c3e97b49be8d2bf)) + + +### Chores + +* **ci:** only run for pushes and fork pull requests ([bea59b8](https://github.com/sst/opencode-sdk-go/commit/bea59b886800ef555f89c47a9256d6392ed2e53d)) + +## 0.1.0-alpha.6 (2025-06-28) + +Full Changelog: [v0.1.0-alpha.5...v0.1.0-alpha.6](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.5...v0.1.0-alpha.6) + +### Bug Fixes + +* don't try to deserialize as json when ResponseBodyInto is []byte ([5988d04](https://github.com/sst/opencode-sdk-go/commit/5988d04839cb78b6613057280b91b72a60fef33d)) + +## 0.1.0-alpha.5 (2025-06-27) + +Full Changelog: [v0.1.0-alpha.4...v0.1.0-alpha.5](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.4...v0.1.0-alpha.5) + +### Features + +* **api:** update via SDK Studio ([9e39a59](https://github.com/sst/opencode-sdk-go/commit/9e39a59b3d5d1bd5e64633732521fb28362cc70e)) + +## 0.1.0-alpha.4 (2025-06-27) + +Full Changelog: [v0.1.0-alpha.3...v0.1.0-alpha.4](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.3...v0.1.0-alpha.4) + +### Features + +* **api:** update via SDK Studio ([9609d1b](https://github.com/sst/opencode-sdk-go/commit/9609d1b1db7806d00cb846c9914cb4935cdedf52)) + +## 0.1.0-alpha.3 (2025-06-27) + +Full Changelog: [v0.1.0-alpha.2...v0.1.0-alpha.3](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.2...v0.1.0-alpha.3) + +### Features + +* **api:** update via SDK Studio ([57f3230](https://github.com/sst/opencode-sdk-go/commit/57f32309023cc1f0f20c20d02a3907e390a71f61)) + +## 0.1.0-alpha.2 (2025-06-27) + +Full Changelog: [v0.1.0-alpha.1...v0.1.0-alpha.2](https://github.com/sst/opencode-sdk-go/compare/v0.1.0-alpha.1...v0.1.0-alpha.2) + +### Features + +* **api:** update via SDK Studio ([a766f1c](https://github.com/sst/opencode-sdk-go/commit/a766f1c54f02bbc1380151b0e22d97cc2c5892e6)) + +## 0.1.0-alpha.1 (2025-06-27) + +Full Changelog: [v0.0.1-alpha.0...v0.1.0-alpha.1](https://github.com/sst/opencode-sdk-go/compare/v0.0.1-alpha.0...v0.1.0-alpha.1) + +### Features + +* **api:** update via SDK Studio ([27b7376](https://github.com/sst/opencode-sdk-go/commit/27b7376310466ee17a63f2104f546b53a2b8361a)) +* **api:** update via SDK Studio ([0a73e04](https://github.com/sst/opencode-sdk-go/commit/0a73e04c23c90b2061611edaa8fd6282dc0ce397)) +* **api:** update via SDK Studio ([9b7883a](https://github.com/sst/opencode-sdk-go/commit/9b7883a144eeac526d9d04538e0876a9d18bb844)) diff --git a/packages/tui/sdk/CONTRIBUTING.md b/packages/tui/sdk/CONTRIBUTING.md new file mode 100644 index 00000000..34620a3c --- /dev/null +++ b/packages/tui/sdk/CONTRIBUTING.md @@ -0,0 +1,66 @@ +## Setting up the environment + +To set up the repository, run: + +```sh +$ ./scripts/bootstrap +$ ./scripts/build +``` + +This will install all the required dependencies and build the SDK. + +You can also [install go 1.18+ manually](https://go.dev/doc/install). + +## Modifying/Adding code + +Most of the SDK is generated code. Modifications to code will be persisted between generations, but may +result in merge conflicts between manual patches and changes from the generator. The generator will never +modify the contents of the `lib/` and `examples/` directories. + +## Adding and running examples + +All files in the `examples/` directory are not modified by the generator and can be freely edited or added to. + +```go +# add an example to examples//main.go + +package main + +func main() { + // ... +} +``` + +```sh +$ go run ./examples/ +``` + +## Using the repository from source + +To use a local version of this library from source in another project, edit the `go.mod` with a replace +directive. This can be done through the CLI with the following: + +```sh +$ go mod edit -replace github.com/sst/opencode-sdk-go=/path/to/opencode-sdk-go +``` + +## Running tests + +Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. + +```sh +# you will need npm installed +$ npx prism mock path/to/your/openapi.yml +``` + +```sh +$ ./scripts/test +``` + +## Formatting + +This library uses the standard gofmt code formatter: + +```sh +$ ./scripts/format +``` diff --git a/packages/tui/sdk/LICENSE b/packages/tui/sdk/LICENSE new file mode 100644 index 00000000..a56ceacd --- /dev/null +++ b/packages/tui/sdk/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Opencode + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/tui/sdk/README.md b/packages/tui/sdk/README.md new file mode 100644 index 00000000..2b578234 --- /dev/null +++ b/packages/tui/sdk/README.md @@ -0,0 +1,354 @@ +# Opencode Go API Library + +Go Reference + +The Opencode Go library provides convenient access to the [Opencode REST API](https://opencode.ai/docs) +from applications written in Go. + +It is generated with [Stainless](https://www.stainless.com/). + +## Installation + + + +```go +import ( + "github.com/sst/opencode-sdk-go" // imported as opencode +) +``` + + + +Or to pin the version: + + + +```sh +go get -u 'github.com/sst/opencode-sdk-go@v0.1.0-alpha.8' +``` + + + +## Requirements + +This library requires Go 1.18+. + +## Usage + +The full API of this library can be found in [api.md](api.md). + +```go +package main + +import ( + "context" + "fmt" + + "github.com/sst/opencode-sdk-go" +) + +func main() { + client := opencode.NewClient() + events, err := client.Event.List(context.TODO()) + if err != nil { + panic(err.Error()) + } + fmt.Printf("%+v\n", events) +} + +``` + +### Request fields + +All request parameters are wrapped in a generic `Field` type, +which we use to distinguish zero values from null or omitted fields. + +This prevents accidentally sending a zero value if you forget a required parameter, +and enables explicitly sending `null`, `false`, `''`, or `0` on optional parameters. +Any field not specified is not sent. + +To construct fields with values, use the helpers `String()`, `Int()`, `Float()`, or most commonly, the generic `F[T]()`. +To send a null, use `Null[T]()`, and to send a nonconforming value, use `Raw[T](any)`. For example: + +```go +params := FooParams{ + Name: opencode.F("hello"), + + // Explicitly send `"description": null` + Description: opencode.Null[string](), + + Point: opencode.F(opencode.Point{ + X: opencode.Int(0), + Y: opencode.Int(1), + + // In cases where the API specifies a given type, + // but you want to send something else, use `Raw`: + Z: opencode.Raw[int64](0.01), // sends a float + }), +} +``` + +### Response objects + +All fields in response structs are value types (not pointers or wrappers). + +If a given field is `null`, not present, or invalid, the corresponding field +will simply be its zero value. + +All response structs also include a special `JSON` field, containing more detailed +information about each property, which you can use like so: + +```go +if res.Name == "" { + // true if `"name"` is either not present or explicitly null + res.JSON.Name.IsNull() + + // true if the `"name"` key was not present in the response JSON at all + res.JSON.Name.IsMissing() + + // When the API returns data that cannot be coerced to the expected type: + if res.JSON.Name.IsInvalid() { + raw := res.JSON.Name.Raw() + + legacyName := struct{ + First string `json:"first"` + Last string `json:"last"` + }{} + json.Unmarshal([]byte(raw), &legacyName) + name = legacyName.First + " " + legacyName.Last + } +} +``` + +These `.JSON` structs also include an `Extras` map containing +any properties in the json response that were not specified +in the struct. This can be useful for API features not yet +present in the SDK. + +```go +body := res.JSON.ExtraFields["my_unexpected_field"].Raw() +``` + +### RequestOptions + +This library uses the functional options pattern. Functions defined in the +`option` package return a `RequestOption`, which is a closure that mutates a +`RequestConfig`. These options can be supplied to the client or at individual +requests. For example: + +```go +client := opencode.NewClient( + // Adds a header to every request made by the client + option.WithHeader("X-Some-Header", "custom_header_info"), +) + +client.Event.List(context.TODO(), ..., + // Override the header + option.WithHeader("X-Some-Header", "some_other_custom_header_info"), + // Add an undocumented field to the request body, using sjson syntax + option.WithJSONSet("some.json.path", map[string]string{"my": "object"}), +) +``` + +See the [full list of request options](https://pkg.go.dev/github.com/sst/opencode-sdk-go/option). + +### Pagination + +This library provides some conveniences for working with paginated list endpoints. + +You can use `.ListAutoPaging()` methods to iterate through items across all pages: + +Or you can use simple `.List()` methods to fetch a single page and receive a standard response object +with additional helper methods like `.GetNextPage()`, e.g.: + +### Errors + +When the API returns a non-success status code, we return an error with type +`*opencode.Error`. This contains the `StatusCode`, `*http.Request`, and +`*http.Response` values of the request, as well as the JSON of the error body +(much like other response objects in the SDK). + +To handle errors, we recommend that you use the `errors.As` pattern: + +```go +_, err := client.Event.List(context.TODO()) +if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + println(string(apierr.DumpRequest(true))) // Prints the serialized HTTP request + println(string(apierr.DumpResponse(true))) // Prints the serialized HTTP response + } + panic(err.Error()) // GET "/event": 400 Bad Request { ... } +} +``` + +When other errors occur, they are returned unwrapped; for example, +if HTTP transport fails, you might receive `*url.Error` wrapping `*net.OpError`. + +### Timeouts + +Requests do not time out by default; use context to configure a timeout for a request lifecycle. + +Note that if a request is [retried](#retries), the context timeout does not start over. +To set a per-retry timeout, use `option.WithRequestTimeout()`. + +```go +// This sets the timeout for the request, including all the retries. +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) +defer cancel() +client.Event.List( + ctx, + // This sets the per-retry timeout + option.WithRequestTimeout(20*time.Second), +) +``` + +### File uploads + +Request parameters that correspond to file uploads in multipart requests are typed as +`param.Field[io.Reader]`. The contents of the `io.Reader` will by default be sent as a multipart form +part with the file name of "anonymous_file" and content-type of "application/octet-stream". + +The file name and content-type can be customized by implementing `Name() string` or `ContentType() +string` on the run-time type of `io.Reader`. Note that `os.File` implements `Name() string`, so a +file returned by `os.Open` will be sent with the file name on disk. + +We also provide a helper `opencode.FileParam(reader io.Reader, filename string, contentType string)` +which can be used to wrap any `io.Reader` with the appropriate file name and content type. + +### Retries + +Certain errors will be automatically retried 2 times by default, with a short exponential backoff. +We retry by default all connection errors, 408 Request Timeout, 409 Conflict, 429 Rate Limit, +and >=500 Internal errors. + +You can use the `WithMaxRetries` option to configure or disable this: + +```go +// Configure the default for all requests: +client := opencode.NewClient( + option.WithMaxRetries(0), // default is 2 +) + +// Override per-request: +client.Event.List(context.TODO(), option.WithMaxRetries(5)) +``` + +### Accessing raw response data (e.g. response headers) + +You can access the raw HTTP response data by using the `option.WithResponseInto()` request option. This is useful when +you need to examine response headers, status codes, or other details. + +```go +// Create a variable to store the HTTP response +var response *http.Response +events, err := client.Event.List(context.TODO(), option.WithResponseInto(&response)) +if err != nil { + // handle error +} +fmt.Printf("%+v\n", events) + +fmt.Printf("Status Code: %d\n", response.StatusCode) +fmt.Printf("Headers: %+#v\n", response.Header) +``` + +### Making custom/undocumented requests + +This library is typed for convenient access to the documented API. If you need to access undocumented +endpoints, params, or response properties, the library can still be used. + +#### Undocumented endpoints + +To make requests to undocumented endpoints, you can use `client.Get`, `client.Post`, and other HTTP verbs. +`RequestOptions` on the client, such as retries, will be respected when making these requests. + +```go +var ( + // params can be an io.Reader, a []byte, an encoding/json serializable object, + // or a "…Params" struct defined in this library. + params map[string]interface{} + + // result can be an []byte, *http.Response, a encoding/json deserializable object, + // or a model defined in this library. + result *http.Response +) +err := client.Post(context.Background(), "/unspecified", params, &result) +if err != nil { + … +} +``` + +#### Undocumented request params + +To make requests using undocumented parameters, you may use either the `option.WithQuerySet()` +or the `option.WithJSONSet()` methods. + +```go +params := FooNewParams{ + ID: opencode.F("id_xxxx"), + Data: opencode.F(FooNewParamsData{ + FirstName: opencode.F("John"), + }), +} +client.Foo.New(context.Background(), params, option.WithJSONSet("data.last_name", "Doe")) +``` + +#### Undocumented response properties + +To access undocumented response properties, you may either access the raw JSON of the response as a string +with `result.JSON.RawJSON()`, or get the raw JSON of a particular field on the result with +`result.JSON.Foo.Raw()`. + +Any fields that are not present on the response struct will be saved and can be accessed by `result.JSON.ExtraFields()` which returns the extra fields as a `map[string]Field`. + +### Middleware + +We provide `option.WithMiddleware` which applies the given +middleware to requests. + +```go +func Logger(req *http.Request, next option.MiddlewareNext) (res *http.Response, err error) { + // Before the request + start := time.Now() + LogReq(req) + + // Forward the request to the next handler + res, err = next(req) + + // Handle stuff after the request + end := time.Now() + LogRes(res, err, start - end) + + return res, err +} + +client := opencode.NewClient( + option.WithMiddleware(Logger), +) +``` + +When multiple middlewares are provided as variadic arguments, the middlewares +are applied left to right. If `option.WithMiddleware` is given +multiple times, for example first in the client then the method, the +middleware in the client will run first and the middleware given in the method +will run next. + +You may also replace the default `http.Client` with +`option.WithHTTPClient(client)`. Only one http client is +accepted (this overwrites any previous client) and receives requests after any +middleware has been applied. + +## Semantic versioning + +This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: + +1. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ +2. Changes that we do not expect to impact the vast majority of users in practice. + +We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. + +We are keen for your feedback; please open an [issue](https://www.github.com/sst/opencode-sdk-go/issues) with questions, bugs, or suggestions. + +## Contributing + +See [the contributing documentation](./CONTRIBUTING.md). diff --git a/packages/tui/sdk/SECURITY.md b/packages/tui/sdk/SECURITY.md new file mode 100644 index 00000000..6912e12b --- /dev/null +++ b/packages/tui/sdk/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Reporting Security Issues + +This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. + +To report a security issue, please contact the Stainless team at security@stainless.com. + +## Responsible Disclosure + +We appreciate the efforts of security researchers and individuals who help us maintain the security of +SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible +disclosure practices by allowing us a reasonable amount of time to investigate and address the issue +before making any information public. + +## Reporting Non-SDK Related Security Issues + +If you encounter security issues that are not directly related to SDKs but pertain to the services +or products provided by Opencode, please follow the respective company's security reporting guidelines. + +### Opencode Terms and Policies + +Please contact support@sst.dev for any questions or concerns regarding the security of our services. + +--- + +Thank you for helping us keep the SDKs and systems they interact with secure. diff --git a/packages/tui/sdk/aliases.go b/packages/tui/sdk/aliases.go new file mode 100644 index 00000000..84dd614a --- /dev/null +++ b/packages/tui/sdk/aliases.go @@ -0,0 +1,34 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode + +import ( + "github.com/sst/opencode-sdk-go/internal/apierror" + "github.com/sst/opencode-sdk-go/shared" +) + +type Error = apierror.Error + +// This is an alias to an internal type. +type ProviderAuthError = shared.ProviderAuthError + +// This is an alias to an internal type. +type ProviderAuthErrorData = shared.ProviderAuthErrorData + +// This is an alias to an internal type. +type ProviderAuthErrorName = shared.ProviderAuthErrorName + +// This is an alias to an internal value. +const ProviderAuthErrorNameProviderAuthError = shared.ProviderAuthErrorNameProviderAuthError + +// This is an alias to an internal type. +type UnknownError = shared.UnknownError + +// This is an alias to an internal type. +type UnknownErrorData = shared.UnknownErrorData + +// This is an alias to an internal type. +type UnknownErrorName = shared.UnknownErrorName + +// This is an alias to an internal value. +const UnknownErrorNameUnknownError = shared.UnknownErrorNameUnknownError diff --git a/packages/tui/sdk/api.md b/packages/tui/sdk/api.md new file mode 100644 index 00000000..4ac9edcf --- /dev/null +++ b/packages/tui/sdk/api.md @@ -0,0 +1,110 @@ +# Shared Response Types + +- shared.ProviderAuthError +- shared.UnknownError + +# Event + +Response Types: + +- opencode.EventListResponse + +Methods: + +- client.Event.List(ctx context.Context) (opencode.EventListResponse, error) + +# App + +Response Types: + +- opencode.App + +Methods: + +- client.App.Get(ctx context.Context) (opencode.App, error) +- client.App.Init(ctx context.Context) (bool, error) + +# Find + +Response Types: + +- opencode.FindSymbolsResponse +- opencode.FindTextResponse + +Methods: + +- client.Find.Files(ctx context.Context, query opencode.FindFilesParams) ([]string, error) +- client.Find.Symbols(ctx context.Context, query opencode.FindSymbolsParams) ([]opencode.FindSymbolsResponse, error) +- client.Find.Text(ctx context.Context, query opencode.FindTextParams) ([]opencode.FindTextResponse, error) + +# File + +Response Types: + +- opencode.FileReadResponse +- opencode.FileStatusResponse + +Methods: + +- client.File.Read(ctx context.Context, query opencode.FileReadParams) (opencode.FileReadResponse, error) +- client.File.Status(ctx context.Context) ([]opencode.FileStatusResponse, error) + +# Config + +Response Types: + +- opencode.Config +- opencode.Keybinds +- opencode.McpLocal +- opencode.McpRemote +- opencode.Model +- opencode.Provider +- opencode.ConfigProvidersResponse + +Methods: + +- client.Config.Get(ctx context.Context) (opencode.Config, error) +- client.Config.Providers(ctx context.Context) (opencode.ConfigProvidersResponse, error) + +# Session + +Params Types: + +- opencode.FilePartParam +- opencode.MessagePartUnionParam +- opencode.ReasoningPartParam +- opencode.SourceURLPartParam +- opencode.StepStartPartParam +- opencode.TextPartParam +- opencode.ToolCallParam +- opencode.ToolInvocationPartParam +- opencode.ToolPartialCallParam +- opencode.ToolResultParam + +Response Types: + +- opencode.FilePart +- opencode.Message +- opencode.MessagePart +- opencode.ReasoningPart +- opencode.Session +- opencode.SourceURLPart +- opencode.StepStartPart +- opencode.TextPart +- opencode.ToolCall +- opencode.ToolInvocationPart +- opencode.ToolPartialCall +- opencode.ToolResult + +Methods: + +- client.Session.New(ctx context.Context) (opencode.Session, error) +- client.Session.List(ctx context.Context) ([]opencode.Session, error) +- client.Session.Delete(ctx context.Context, id string) (bool, error) +- client.Session.Abort(ctx context.Context, id string) (bool, error) +- client.Session.Chat(ctx context.Context, id string, body opencode.SessionChatParams) (opencode.Message, error) +- client.Session.Init(ctx context.Context, id string, body opencode.SessionInitParams) (bool, error) +- client.Session.Messages(ctx context.Context, id string) ([]opencode.Message, error) +- client.Session.Share(ctx context.Context, id string) (opencode.Session, error) +- client.Session.Summarize(ctx context.Context, id string, body opencode.SessionSummarizeParams) (bool, error) +- client.Session.Unshare(ctx context.Context, id string) (opencode.Session, error) diff --git a/packages/tui/sdk/app.go b/packages/tui/sdk/app.go new file mode 100644 index 00000000..dc44a74b --- /dev/null +++ b/packages/tui/sdk/app.go @@ -0,0 +1,123 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode + +import ( + "context" + "net/http" + + "github.com/sst/opencode-sdk-go/internal/apijson" + "github.com/sst/opencode-sdk-go/internal/requestconfig" + "github.com/sst/opencode-sdk-go/option" +) + +// AppService contains methods and other services that help with interacting with +// the opencode API. +// +// Note, unlike clients, this service does not read variables from the environment +// automatically. You should not instantiate this service directly, and instead use +// the [NewAppService] method instead. +type AppService struct { + Options []option.RequestOption +} + +// NewAppService generates a new service that applies the given options to each +// request. These options are applied after the parent client's options (if there +// is one), and before any request-specific options. +func NewAppService(opts ...option.RequestOption) (r *AppService) { + r = &AppService{} + r.Options = opts + return +} + +// Get app info +func (r *AppService) Get(ctx context.Context, opts ...option.RequestOption) (res *App, err error) { + opts = append(r.Options[:], opts...) + path := "app" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + +// Initialize the app +func (r *AppService) Init(ctx context.Context, opts ...option.RequestOption) (res *bool, err error) { + opts = append(r.Options[:], opts...) + path := "app/init" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...) + return +} + +type App struct { + Git bool `json:"git,required"` + Hostname string `json:"hostname,required"` + Path AppPath `json:"path,required"` + Time AppTime `json:"time,required"` + User string `json:"user,required"` + JSON appJSON `json:"-"` +} + +// appJSON contains the JSON metadata for the struct [App] +type appJSON struct { + Git apijson.Field + Hostname apijson.Field + Path apijson.Field + Time apijson.Field + User apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *App) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r appJSON) RawJSON() string { + return r.raw +} + +type AppPath struct { + Config string `json:"config,required"` + Cwd string `json:"cwd,required"` + Data string `json:"data,required"` + Root string `json:"root,required"` + State string `json:"state,required"` + JSON appPathJSON `json:"-"` +} + +// appPathJSON contains the JSON metadata for the struct [AppPath] +type appPathJSON struct { + Config apijson.Field + Cwd apijson.Field + Data apijson.Field + Root apijson.Field + State apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *AppPath) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r appPathJSON) RawJSON() string { + return r.raw +} + +type AppTime struct { + Initialized float64 `json:"initialized"` + JSON appTimeJSON `json:"-"` +} + +// appTimeJSON contains the JSON metadata for the struct [AppTime] +type appTimeJSON struct { + Initialized apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *AppTime) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r appTimeJSON) RawJSON() string { + return r.raw +} diff --git a/packages/tui/sdk/app_test.go b/packages/tui/sdk/app_test.go new file mode 100644 index 00000000..f96495f1 --- /dev/null +++ b/packages/tui/sdk/app_test.go @@ -0,0 +1,58 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/sst/opencode-sdk-go" + "github.com/sst/opencode-sdk-go/internal/testutil" + "github.com/sst/opencode-sdk-go/option" +) + +func TestAppGet(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.App.Get(context.TODO()) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestAppInit(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.App.Init(context.TODO()) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} diff --git a/packages/tui/sdk/client.go b/packages/tui/sdk/client.go new file mode 100644 index 00000000..955eb7d6 --- /dev/null +++ b/packages/tui/sdk/client.go @@ -0,0 +1,123 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode + +import ( + "context" + "net/http" + "os" + + "github.com/sst/opencode-sdk-go/internal/requestconfig" + "github.com/sst/opencode-sdk-go/option" +) + +// Client creates a struct with services and top level methods that help with +// interacting with the opencode API. You should not instantiate this client +// directly, and instead use the [NewClient] method instead. +type Client struct { + Options []option.RequestOption + Event *EventService + App *AppService + Find *FindService + File *FileService + Config *ConfigService + Session *SessionService +} + +// DefaultClientOptions read from the environment (OPENCODE_BASE_URL). This should +// be used to initialize new clients. +func DefaultClientOptions() []option.RequestOption { + defaults := []option.RequestOption{option.WithEnvironmentProduction()} + if o, ok := os.LookupEnv("OPENCODE_BASE_URL"); ok { + defaults = append(defaults, option.WithBaseURL(o)) + } + return defaults +} + +// NewClient generates a new client with the default option read from the +// environment (OPENCODE_BASE_URL). The option passed in as arguments are applied +// after these default arguments, and all option will be passed down to the +// services and requests that this client makes. +func NewClient(opts ...option.RequestOption) (r *Client) { + opts = append(DefaultClientOptions(), opts...) + + r = &Client{Options: opts} + + r.Event = NewEventService(opts...) + r.App = NewAppService(opts...) + r.Find = NewFindService(opts...) + r.File = NewFileService(opts...) + r.Config = NewConfigService(opts...) + r.Session = NewSessionService(opts...) + + return +} + +// Execute makes a request with the given context, method, URL, request params, +// response, and request options. This is useful for hitting undocumented endpoints +// while retaining the base URL, auth, retries, and other options from the client. +// +// If a byte slice or an [io.Reader] is supplied to params, it will be used as-is +// for the request body. +// +// The params is by default serialized into the body using [encoding/json]. If your +// type implements a MarshalJSON function, it will be used instead to serialize the +// request. If a URLQuery method is implemented, the returned [url.Values] will be +// used as query strings to the url. +// +// If your params struct uses [param.Field], you must provide either [MarshalJSON], +// [URLQuery], and/or [MarshalForm] functions. It is undefined behavior to use a +// struct uses [param.Field] without specifying how it is serialized. +// +// Any "…Params" object defined in this library can be used as the request +// argument. Note that 'path' arguments will not be forwarded into the url. +// +// The response body will be deserialized into the res variable, depending on its +// type: +// +// - A pointer to a [*http.Response] is populated by the raw response. +// - A pointer to a byte array will be populated with the contents of the request +// body. +// - A pointer to any other type uses this library's default JSON decoding, which +// respects UnmarshalJSON if it is defined on the type. +// - A nil value will not read the response body. +// +// For even greater flexibility, see [option.WithResponseInto] and +// [option.WithResponseBodyInto]. +func (r *Client) Execute(ctx context.Context, method string, path string, params interface{}, res interface{}, opts ...option.RequestOption) error { + opts = append(r.Options, opts...) + return requestconfig.ExecuteNewRequest(ctx, method, path, params, res, opts...) +} + +// Get makes a GET request with the given URL, params, and optionally deserializes +// to a response. See [Execute] documentation on the params and response. +func (r *Client) Get(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error { + return r.Execute(ctx, http.MethodGet, path, params, res, opts...) +} + +// Post makes a POST request with the given URL, params, and optionally +// deserializes to a response. See [Execute] documentation on the params and +// response. +func (r *Client) Post(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error { + return r.Execute(ctx, http.MethodPost, path, params, res, opts...) +} + +// Put makes a PUT request with the given URL, params, and optionally deserializes +// to a response. See [Execute] documentation on the params and response. +func (r *Client) Put(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error { + return r.Execute(ctx, http.MethodPut, path, params, res, opts...) +} + +// Patch makes a PATCH request with the given URL, params, and optionally +// deserializes to a response. See [Execute] documentation on the params and +// response. +func (r *Client) Patch(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error { + return r.Execute(ctx, http.MethodPatch, path, params, res, opts...) +} + +// Delete makes a DELETE request with the given URL, params, and optionally +// deserializes to a response. See [Execute] documentation on the params and +// response. +func (r *Client) Delete(ctx context.Context, path string, params interface{}, res interface{}, opts ...option.RequestOption) error { + return r.Execute(ctx, http.MethodDelete, path, params, res, opts...) +} diff --git a/packages/tui/sdk/client_test.go b/packages/tui/sdk/client_test.go new file mode 100644 index 00000000..e75d6492 --- /dev/null +++ b/packages/tui/sdk/client_test.go @@ -0,0 +1,332 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode_test + +import ( + "context" + "fmt" + "io" + "net/http" + "reflect" + "testing" + "time" + + "github.com/sst/opencode-sdk-go" + "github.com/sst/opencode-sdk-go/internal" + "github.com/sst/opencode-sdk-go/option" +) + +type closureTransport struct { + fn func(req *http.Request) (*http.Response, error) +} + +func (t *closureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return t.fn(req) +} + +func TestUserAgentHeader(t *testing.T) { + var userAgent string + client := opencode.NewClient( + option.WithHTTPClient(&http.Client{ + Transport: &closureTransport{ + fn: func(req *http.Request) (*http.Response, error) { + userAgent = req.Header.Get("User-Agent") + return &http.Response{ + StatusCode: http.StatusOK, + }, nil + }, + }, + }), + ) + client.Event.List(context.Background()) + if userAgent != fmt.Sprintf("Opencode/Go %s", internal.PackageVersion) { + t.Errorf("Expected User-Agent to be correct, but got: %#v", userAgent) + } +} + +func TestRetryAfter(t *testing.T) { + retryCountHeaders := make([]string, 0) + client := opencode.NewClient( + option.WithHTTPClient(&http.Client{ + Transport: &closureTransport{ + fn: func(req *http.Request) (*http.Response, error) { + retryCountHeaders = append(retryCountHeaders, req.Header.Get("X-Stainless-Retry-Count")) + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{ + http.CanonicalHeaderKey("Retry-After"): []string{"0.1"}, + }, + }, nil + }, + }, + }), + ) + _, err := client.Event.List(context.Background()) + if err == nil { + t.Error("Expected there to be a cancel error") + } + + attempts := len(retryCountHeaders) + if attempts != 3 { + t.Errorf("Expected %d attempts, got %d", 3, attempts) + } + + expectedRetryCountHeaders := []string{"0", "1", "2"} + if !reflect.DeepEqual(retryCountHeaders, expectedRetryCountHeaders) { + t.Errorf("Expected %v retry count headers, got %v", expectedRetryCountHeaders, retryCountHeaders) + } +} + +func TestDeleteRetryCountHeader(t *testing.T) { + retryCountHeaders := make([]string, 0) + client := opencode.NewClient( + option.WithHTTPClient(&http.Client{ + Transport: &closureTransport{ + fn: func(req *http.Request) (*http.Response, error) { + retryCountHeaders = append(retryCountHeaders, req.Header.Get("X-Stainless-Retry-Count")) + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{ + http.CanonicalHeaderKey("Retry-After"): []string{"0.1"}, + }, + }, nil + }, + }, + }), + option.WithHeaderDel("X-Stainless-Retry-Count"), + ) + _, err := client.Event.List(context.Background()) + if err == nil { + t.Error("Expected there to be a cancel error") + } + + expectedRetryCountHeaders := []string{"", "", ""} + if !reflect.DeepEqual(retryCountHeaders, expectedRetryCountHeaders) { + t.Errorf("Expected %v retry count headers, got %v", expectedRetryCountHeaders, retryCountHeaders) + } +} + +func TestOverwriteRetryCountHeader(t *testing.T) { + retryCountHeaders := make([]string, 0) + client := opencode.NewClient( + option.WithHTTPClient(&http.Client{ + Transport: &closureTransport{ + fn: func(req *http.Request) (*http.Response, error) { + retryCountHeaders = append(retryCountHeaders, req.Header.Get("X-Stainless-Retry-Count")) + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{ + http.CanonicalHeaderKey("Retry-After"): []string{"0.1"}, + }, + }, nil + }, + }, + }), + option.WithHeader("X-Stainless-Retry-Count", "42"), + ) + _, err := client.Event.List(context.Background()) + if err == nil { + t.Error("Expected there to be a cancel error") + } + + expectedRetryCountHeaders := []string{"42", "42", "42"} + if !reflect.DeepEqual(retryCountHeaders, expectedRetryCountHeaders) { + t.Errorf("Expected %v retry count headers, got %v", expectedRetryCountHeaders, retryCountHeaders) + } +} + +func TestRetryAfterMs(t *testing.T) { + attempts := 0 + client := opencode.NewClient( + option.WithHTTPClient(&http.Client{ + Transport: &closureTransport{ + fn: func(req *http.Request) (*http.Response, error) { + attempts++ + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{ + http.CanonicalHeaderKey("Retry-After-Ms"): []string{"100"}, + }, + }, nil + }, + }, + }), + ) + _, err := client.Event.List(context.Background()) + if err == nil { + t.Error("Expected there to be a cancel error") + } + if want := 3; attempts != want { + t.Errorf("Expected %d attempts, got %d", want, attempts) + } +} + +func TestContextCancel(t *testing.T) { + client := opencode.NewClient( + option.WithHTTPClient(&http.Client{ + Transport: &closureTransport{ + fn: func(req *http.Request) (*http.Response, error) { + <-req.Context().Done() + return nil, req.Context().Err() + }, + }, + }), + ) + cancelCtx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := client.Event.List(cancelCtx) + if err == nil { + t.Error("Expected there to be a cancel error") + } +} + +func TestContextCancelDelay(t *testing.T) { + client := opencode.NewClient( + option.WithHTTPClient(&http.Client{ + Transport: &closureTransport{ + fn: func(req *http.Request) (*http.Response, error) { + <-req.Context().Done() + return nil, req.Context().Err() + }, + }, + }), + ) + cancelCtx, cancel := context.WithTimeout(context.Background(), 2*time.Millisecond) + defer cancel() + _, err := client.Event.List(cancelCtx) + if err == nil { + t.Error("expected there to be a cancel error") + } +} + +func TestContextDeadline(t *testing.T) { + testTimeout := time.After(3 * time.Second) + testDone := make(chan struct{}) + + deadline := time.Now().Add(100 * time.Millisecond) + deadlineCtx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + + go func() { + client := opencode.NewClient( + option.WithHTTPClient(&http.Client{ + Transport: &closureTransport{ + fn: func(req *http.Request) (*http.Response, error) { + <-req.Context().Done() + return nil, req.Context().Err() + }, + }, + }), + ) + _, err := client.Event.List(deadlineCtx) + if err == nil { + t.Error("expected there to be a deadline error") + } + close(testDone) + }() + + select { + case <-testTimeout: + t.Fatal("client didn't finish in time") + case <-testDone: + if diff := time.Since(deadline); diff < -30*time.Millisecond || 30*time.Millisecond < diff { + t.Fatalf("client did not return within 30ms of context deadline, got %s", diff) + } + } +} + +func TestContextDeadlineStreaming(t *testing.T) { + testTimeout := time.After(3 * time.Second) + testDone := make(chan struct{}) + + deadline := time.Now().Add(100 * time.Millisecond) + deadlineCtx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + + go func() { + client := opencode.NewClient( + option.WithHTTPClient(&http.Client{ + Transport: &closureTransport{ + fn: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Status: "200 OK", + Body: io.NopCloser( + io.Reader(readerFunc(func([]byte) (int, error) { + <-req.Context().Done() + return 0, req.Context().Err() + })), + ), + }, nil + }, + }, + }), + ) + stream := client.Event.ListStreaming(deadlineCtx) + for stream.Next() { + _ = stream.Current() + } + if stream.Err() == nil { + t.Error("expected there to be a deadline error") + } + close(testDone) + }() + + select { + case <-testTimeout: + t.Fatal("client didn't finish in time") + case <-testDone: + if diff := time.Since(deadline); diff < -30*time.Millisecond || 30*time.Millisecond < diff { + t.Fatalf("client did not return within 30ms of context deadline, got %s", diff) + } + } +} + +func TestContextDeadlineStreamingWithRequestTimeout(t *testing.T) { + testTimeout := time.After(3 * time.Second) + testDone := make(chan struct{}) + deadline := time.Now().Add(100 * time.Millisecond) + + go func() { + client := opencode.NewClient( + option.WithHTTPClient(&http.Client{ + Transport: &closureTransport{ + fn: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Status: "200 OK", + Body: io.NopCloser( + io.Reader(readerFunc(func([]byte) (int, error) { + <-req.Context().Done() + return 0, req.Context().Err() + })), + ), + }, nil + }, + }, + }), + ) + stream := client.Event.ListStreaming(context.Background(), option.WithRequestTimeout((100 * time.Millisecond))) + for stream.Next() { + _ = stream.Current() + } + if stream.Err() == nil { + t.Error("expected there to be a deadline error") + } + close(testDone) + }() + + select { + case <-testTimeout: + t.Fatal("client didn't finish in time") + case <-testDone: + if diff := time.Since(deadline); diff < -30*time.Millisecond || 30*time.Millisecond < diff { + t.Fatalf("client did not return within 30ms of context deadline, got %s", diff) + } + } +} + +type readerFunc func([]byte) (int, error) + +func (f readerFunc) Read(p []byte) (int, error) { return f(p) } +func (f readerFunc) Close() error { return nil } diff --git a/packages/tui/sdk/config.go b/packages/tui/sdk/config.go new file mode 100644 index 00000000..39da2f94 --- /dev/null +++ b/packages/tui/sdk/config.go @@ -0,0 +1,724 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode + +import ( + "context" + "net/http" + "reflect" + + "github.com/sst/opencode-sdk-go/internal/apijson" + "github.com/sst/opencode-sdk-go/internal/requestconfig" + "github.com/sst/opencode-sdk-go/option" + "github.com/tidwall/gjson" +) + +// ConfigService contains methods and other services that help with interacting +// with the opencode API. +// +// Note, unlike clients, this service does not read variables from the environment +// automatically. You should not instantiate this service directly, and instead use +// the [NewConfigService] method instead. +type ConfigService struct { + Options []option.RequestOption +} + +// NewConfigService generates a new service that applies the given options to each +// request. These options are applied after the parent client's options (if there +// is one), and before any request-specific options. +func NewConfigService(opts ...option.RequestOption) (r *ConfigService) { + r = &ConfigService{} + r.Options = opts + return +} + +// Get config info +func (r *ConfigService) Get(ctx context.Context, opts ...option.RequestOption) (res *Config, err error) { + opts = append(r.Options[:], opts...) + path := "config" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + +// List all providers +func (r *ConfigService) Providers(ctx context.Context, opts ...option.RequestOption) (res *ConfigProvidersResponse, err error) { + opts = append(r.Options[:], opts...) + path := "config/providers" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + +type Config struct { + // JSON schema reference for configuration validation + Schema string `json:"$schema"` + // Share newly created sessions automatically + Autoshare bool `json:"autoshare"` + // Automatically update to the latest version + Autoupdate bool `json:"autoupdate"` + // Disable providers that are loaded automatically + DisabledProviders []string `json:"disabled_providers"` + Experimental ConfigExperimental `json:"experimental"` + // Additional instruction files or patterns to include + Instructions []string `json:"instructions"` + // Custom keybind configurations + Keybinds Keybinds `json:"keybinds"` + // MCP (Model Context Protocol) server configurations + Mcp map[string]ConfigMcp `json:"mcp"` + // Model to use in the format of provider/model, eg anthropic/claude-2 + Model string `json:"model"` + // Custom provider configurations and model overrides + Provider map[string]ConfigProvider `json:"provider"` + // Theme name to use for the interface + Theme string `json:"theme"` + JSON configJSON `json:"-"` +} + +// configJSON contains the JSON metadata for the struct [Config] +type configJSON struct { + Schema apijson.Field + Autoshare apijson.Field + Autoupdate apijson.Field + DisabledProviders apijson.Field + Experimental apijson.Field + Instructions apijson.Field + Keybinds apijson.Field + Mcp apijson.Field + Model apijson.Field + Provider apijson.Field + Theme apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *Config) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r configJSON) RawJSON() string { + return r.raw +} + +type ConfigExperimental struct { + Hook ConfigExperimentalHook `json:"hook"` + JSON configExperimentalJSON `json:"-"` +} + +// configExperimentalJSON contains the JSON metadata for the struct +// [ConfigExperimental] +type configExperimentalJSON struct { + Hook apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ConfigExperimental) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r configExperimentalJSON) RawJSON() string { + return r.raw +} + +type ConfigExperimentalHook struct { + FileEdited map[string][]ConfigExperimentalHookFileEdited `json:"file_edited"` + SessionCompleted []ConfigExperimentalHookSessionCompleted `json:"session_completed"` + JSON configExperimentalHookJSON `json:"-"` +} + +// configExperimentalHookJSON contains the JSON metadata for the struct +// [ConfigExperimentalHook] +type configExperimentalHookJSON struct { + FileEdited apijson.Field + SessionCompleted apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ConfigExperimentalHook) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r configExperimentalHookJSON) RawJSON() string { + return r.raw +} + +type ConfigExperimentalHookFileEdited struct { + Command []string `json:"command,required"` + Environment map[string]string `json:"environment"` + JSON configExperimentalHookFileEditedJSON `json:"-"` +} + +// configExperimentalHookFileEditedJSON contains the JSON metadata for the struct +// [ConfigExperimentalHookFileEdited] +type configExperimentalHookFileEditedJSON struct { + Command apijson.Field + Environment apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ConfigExperimentalHookFileEdited) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r configExperimentalHookFileEditedJSON) RawJSON() string { + return r.raw +} + +type ConfigExperimentalHookSessionCompleted struct { + Command []string `json:"command,required"` + Environment map[string]string `json:"environment"` + JSON configExperimentalHookSessionCompletedJSON `json:"-"` +} + +// configExperimentalHookSessionCompletedJSON contains the JSON metadata for the +// struct [ConfigExperimentalHookSessionCompleted] +type configExperimentalHookSessionCompletedJSON struct { + Command apijson.Field + Environment apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ConfigExperimentalHookSessionCompleted) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r configExperimentalHookSessionCompletedJSON) RawJSON() string { + return r.raw +} + +type ConfigMcp struct { + // Type of MCP server connection + Type ConfigMcpType `json:"type,required"` + // This field can have the runtime type of [[]string]. + Command interface{} `json:"command"` + // Enable or disable the MCP server on startup + Enabled bool `json:"enabled"` + // This field can have the runtime type of [map[string]string]. + Environment interface{} `json:"environment"` + // URL of the remote MCP server + URL string `json:"url"` + JSON configMcpJSON `json:"-"` + union ConfigMcpUnion +} + +// configMcpJSON contains the JSON metadata for the struct [ConfigMcp] +type configMcpJSON struct { + Type apijson.Field + Command apijson.Field + Enabled apijson.Field + Environment apijson.Field + URL apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r configMcpJSON) RawJSON() string { + return r.raw +} + +func (r *ConfigMcp) UnmarshalJSON(data []byte) (err error) { + *r = ConfigMcp{} + err = apijson.UnmarshalRoot(data, &r.union) + if err != nil { + return err + } + return apijson.Port(r.union, &r) +} + +// AsUnion returns a [ConfigMcpUnion] interface which you can cast to the specific +// types for more type safety. +// +// Possible runtime types of the union are [McpLocal], [McpRemote]. +func (r ConfigMcp) AsUnion() ConfigMcpUnion { + return r.union +} + +// Union satisfied by [McpLocal] or [McpRemote]. +type ConfigMcpUnion interface { + implementsConfigMcp() +} + +func init() { + apijson.RegisterUnion( + reflect.TypeOf((*ConfigMcpUnion)(nil)).Elem(), + "type", + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(McpLocal{}), + DiscriminatorValue: "local", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(McpRemote{}), + DiscriminatorValue: "remote", + }, + ) +} + +// Type of MCP server connection +type ConfigMcpType string + +const ( + ConfigMcpTypeLocal ConfigMcpType = "local" + ConfigMcpTypeRemote ConfigMcpType = "remote" +) + +func (r ConfigMcpType) IsKnown() bool { + switch r { + case ConfigMcpTypeLocal, ConfigMcpTypeRemote: + return true + } + return false +} + +type ConfigProvider struct { + Models map[string]ConfigProviderModel `json:"models,required"` + ID string `json:"id"` + API string `json:"api"` + Env []string `json:"env"` + Name string `json:"name"` + Npm string `json:"npm"` + Options map[string]interface{} `json:"options"` + JSON configProviderJSON `json:"-"` +} + +// configProviderJSON contains the JSON metadata for the struct [ConfigProvider] +type configProviderJSON struct { + Models apijson.Field + ID apijson.Field + API apijson.Field + Env apijson.Field + Name apijson.Field + Npm apijson.Field + Options apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ConfigProvider) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r configProviderJSON) RawJSON() string { + return r.raw +} + +type ConfigProviderModel struct { + ID string `json:"id"` + Attachment bool `json:"attachment"` + Cost ConfigProviderModelsCost `json:"cost"` + Limit ConfigProviderModelsLimit `json:"limit"` + Name string `json:"name"` + Options map[string]interface{} `json:"options"` + Reasoning bool `json:"reasoning"` + ReleaseDate string `json:"release_date"` + Temperature bool `json:"temperature"` + ToolCall bool `json:"tool_call"` + JSON configProviderModelJSON `json:"-"` +} + +// configProviderModelJSON contains the JSON metadata for the struct +// [ConfigProviderModel] +type configProviderModelJSON struct { + ID apijson.Field + Attachment apijson.Field + Cost apijson.Field + Limit apijson.Field + Name apijson.Field + Options apijson.Field + Reasoning apijson.Field + ReleaseDate apijson.Field + Temperature apijson.Field + ToolCall apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ConfigProviderModel) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r configProviderModelJSON) RawJSON() string { + return r.raw +} + +type ConfigProviderModelsCost struct { + Input float64 `json:"input,required"` + Output float64 `json:"output,required"` + CacheRead float64 `json:"cache_read"` + CacheWrite float64 `json:"cache_write"` + JSON configProviderModelsCostJSON `json:"-"` +} + +// configProviderModelsCostJSON contains the JSON metadata for the struct +// [ConfigProviderModelsCost] +type configProviderModelsCostJSON struct { + Input apijson.Field + Output apijson.Field + CacheRead apijson.Field + CacheWrite apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ConfigProviderModelsCost) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r configProviderModelsCostJSON) RawJSON() string { + return r.raw +} + +type ConfigProviderModelsLimit struct { + Context float64 `json:"context,required"` + Output float64 `json:"output,required"` + JSON configProviderModelsLimitJSON `json:"-"` +} + +// configProviderModelsLimitJSON contains the JSON metadata for the struct +// [ConfigProviderModelsLimit] +type configProviderModelsLimitJSON struct { + Context apijson.Field + Output apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ConfigProviderModelsLimit) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r configProviderModelsLimitJSON) RawJSON() string { + return r.raw +} + +type Keybinds struct { + // Exit the application + AppExit string `json:"app_exit"` + // Open external editor + EditorOpen string `json:"editor_open"` + // Show help dialog + Help string `json:"help"` + // Navigate to next history item + HistoryNext string `json:"history_next"` + // Navigate to previous history item + HistoryPrevious string `json:"history_previous"` + // Clear input field + InputClear string `json:"input_clear"` + // Insert newline in input + InputNewline string `json:"input_newline"` + // Paste from clipboard + InputPaste string `json:"input_paste"` + // Submit input + InputSubmit string `json:"input_submit"` + // Leader key for keybind combinations + Leader string `json:"leader"` + // Navigate to first message + MessagesFirst string `json:"messages_first"` + // Scroll messages down by half page + MessagesHalfPageDown string `json:"messages_half_page_down"` + // Scroll messages up by half page + MessagesHalfPageUp string `json:"messages_half_page_up"` + // Navigate to last message + MessagesLast string `json:"messages_last"` + // Navigate to next message + MessagesNext string `json:"messages_next"` + // Scroll messages down by one page + MessagesPageDown string `json:"messages_page_down"` + // Scroll messages up by one page + MessagesPageUp string `json:"messages_page_up"` + // Navigate to previous message + MessagesPrevious string `json:"messages_previous"` + // List available models + ModelList string `json:"model_list"` + // Initialize project configuration + ProjectInit string `json:"project_init"` + // Toggle compact mode for session + SessionCompact string `json:"session_compact"` + // Interrupt current session + SessionInterrupt string `json:"session_interrupt"` + // List all sessions + SessionList string `json:"session_list"` + // Create a new session + SessionNew string `json:"session_new"` + // Share current session + SessionShare string `json:"session_share"` + // List available themes + ThemeList string `json:"theme_list"` + // Show tool details + ToolDetails string `json:"tool_details"` + JSON keybindsJSON `json:"-"` +} + +// keybindsJSON contains the JSON metadata for the struct [Keybinds] +type keybindsJSON struct { + AppExit apijson.Field + EditorOpen apijson.Field + Help apijson.Field + HistoryNext apijson.Field + HistoryPrevious apijson.Field + InputClear apijson.Field + InputNewline apijson.Field + InputPaste apijson.Field + InputSubmit apijson.Field + Leader apijson.Field + MessagesFirst apijson.Field + MessagesHalfPageDown apijson.Field + MessagesHalfPageUp apijson.Field + MessagesLast apijson.Field + MessagesNext apijson.Field + MessagesPageDown apijson.Field + MessagesPageUp apijson.Field + MessagesPrevious apijson.Field + ModelList apijson.Field + ProjectInit apijson.Field + SessionCompact apijson.Field + SessionInterrupt apijson.Field + SessionList apijson.Field + SessionNew apijson.Field + SessionShare apijson.Field + ThemeList apijson.Field + ToolDetails apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *Keybinds) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r keybindsJSON) RawJSON() string { + return r.raw +} + +type McpLocal struct { + // Command and arguments to run the MCP server + Command []string `json:"command,required"` + // Type of MCP server connection + Type McpLocalType `json:"type,required"` + // Enable or disable the MCP server on startup + Enabled bool `json:"enabled"` + // Environment variables to set when running the MCP server + Environment map[string]string `json:"environment"` + JSON mcpLocalJSON `json:"-"` +} + +// mcpLocalJSON contains the JSON metadata for the struct [McpLocal] +type mcpLocalJSON struct { + Command apijson.Field + Type apijson.Field + Enabled apijson.Field + Environment apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *McpLocal) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r mcpLocalJSON) RawJSON() string { + return r.raw +} + +func (r McpLocal) implementsConfigMcp() {} + +// Type of MCP server connection +type McpLocalType string + +const ( + McpLocalTypeLocal McpLocalType = "local" +) + +func (r McpLocalType) IsKnown() bool { + switch r { + case McpLocalTypeLocal: + return true + } + return false +} + +type McpRemote struct { + // Type of MCP server connection + Type McpRemoteType `json:"type,required"` + // URL of the remote MCP server + URL string `json:"url,required"` + // Enable or disable the MCP server on startup + Enabled bool `json:"enabled"` + JSON mcpRemoteJSON `json:"-"` +} + +// mcpRemoteJSON contains the JSON metadata for the struct [McpRemote] +type mcpRemoteJSON struct { + Type apijson.Field + URL apijson.Field + Enabled apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *McpRemote) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r mcpRemoteJSON) RawJSON() string { + return r.raw +} + +func (r McpRemote) implementsConfigMcp() {} + +// Type of MCP server connection +type McpRemoteType string + +const ( + McpRemoteTypeRemote McpRemoteType = "remote" +) + +func (r McpRemoteType) IsKnown() bool { + switch r { + case McpRemoteTypeRemote: + return true + } + return false +} + +type Model struct { + ID string `json:"id,required"` + Attachment bool `json:"attachment,required"` + Cost ModelCost `json:"cost,required"` + Limit ModelLimit `json:"limit,required"` + Name string `json:"name,required"` + Options map[string]interface{} `json:"options,required"` + Reasoning bool `json:"reasoning,required"` + ReleaseDate string `json:"release_date,required"` + Temperature bool `json:"temperature,required"` + ToolCall bool `json:"tool_call,required"` + JSON modelJSON `json:"-"` +} + +// modelJSON contains the JSON metadata for the struct [Model] +type modelJSON struct { + ID apijson.Field + Attachment apijson.Field + Cost apijson.Field + Limit apijson.Field + Name apijson.Field + Options apijson.Field + Reasoning apijson.Field + ReleaseDate apijson.Field + Temperature apijson.Field + ToolCall apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *Model) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r modelJSON) RawJSON() string { + return r.raw +} + +type ModelCost struct { + Input float64 `json:"input,required"` + Output float64 `json:"output,required"` + CacheRead float64 `json:"cache_read"` + CacheWrite float64 `json:"cache_write"` + JSON modelCostJSON `json:"-"` +} + +// modelCostJSON contains the JSON metadata for the struct [ModelCost] +type modelCostJSON struct { + Input apijson.Field + Output apijson.Field + CacheRead apijson.Field + CacheWrite apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ModelCost) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r modelCostJSON) RawJSON() string { + return r.raw +} + +type ModelLimit struct { + Context float64 `json:"context,required"` + Output float64 `json:"output,required"` + JSON modelLimitJSON `json:"-"` +} + +// modelLimitJSON contains the JSON metadata for the struct [ModelLimit] +type modelLimitJSON struct { + Context apijson.Field + Output apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ModelLimit) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r modelLimitJSON) RawJSON() string { + return r.raw +} + +type Provider struct { + ID string `json:"id,required"` + Env []string `json:"env,required"` + Models map[string]Model `json:"models,required"` + Name string `json:"name,required"` + API string `json:"api"` + Npm string `json:"npm"` + JSON providerJSON `json:"-"` +} + +// providerJSON contains the JSON metadata for the struct [Provider] +type providerJSON struct { + ID apijson.Field + Env apijson.Field + Models apijson.Field + Name apijson.Field + API apijson.Field + Npm apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *Provider) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r providerJSON) RawJSON() string { + return r.raw +} + +type ConfigProvidersResponse struct { + Default map[string]string `json:"default,required"` + Providers []Provider `json:"providers,required"` + JSON configProvidersResponseJSON `json:"-"` +} + +// configProvidersResponseJSON contains the JSON metadata for the struct +// [ConfigProvidersResponse] +type configProvidersResponseJSON struct { + Default apijson.Field + Providers apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ConfigProvidersResponse) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r configProvidersResponseJSON) RawJSON() string { + return r.raw +} diff --git a/packages/tui/sdk/config_test.go b/packages/tui/sdk/config_test.go new file mode 100644 index 00000000..57a1d158 --- /dev/null +++ b/packages/tui/sdk/config_test.go @@ -0,0 +1,58 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/sst/opencode-sdk-go" + "github.com/sst/opencode-sdk-go/internal/testutil" + "github.com/sst/opencode-sdk-go/option" +) + +func TestConfigGet(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Config.Get(context.TODO()) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestConfigProviders(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Config.Providers(context.TODO()) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} diff --git a/packages/tui/sdk/event.go b/packages/tui/sdk/event.go new file mode 100644 index 00000000..ed92b2ae --- /dev/null +++ b/packages/tui/sdk/event.go @@ -0,0 +1,1180 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode + +import ( + "context" + "net/http" + "reflect" + + "github.com/sst/opencode-sdk-go/internal/apijson" + "github.com/sst/opencode-sdk-go/internal/requestconfig" + "github.com/sst/opencode-sdk-go/option" + "github.com/sst/opencode-sdk-go/packages/ssestream" + "github.com/sst/opencode-sdk-go/shared" + "github.com/tidwall/gjson" +) + +// EventService contains methods and other services that help with interacting with +// the opencode API. +// +// Note, unlike clients, this service does not read variables from the environment +// automatically. You should not instantiate this service directly, and instead use +// the [NewEventService] method instead. +type EventService struct { + Options []option.RequestOption +} + +// NewEventService generates a new service that applies the given options to each +// request. These options are applied after the parent client's options (if there +// is one), and before any request-specific options. +func NewEventService(opts ...option.RequestOption) (r *EventService) { + r = &EventService{} + r.Options = opts + return +} + +// Get events +func (r *EventService) ListStreaming(ctx context.Context, opts ...option.RequestOption) (stream *ssestream.Stream[EventListResponse]) { + var ( + raw *http.Response + err error + ) + opts = append(r.Options[:], opts...) + path := "event" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &raw, opts...) + return ssestream.NewStream[EventListResponse](ssestream.NewDecoder(raw), err) +} + +type EventListResponse struct { + // This field can have the runtime type of + // [EventListResponseEventLspClientDiagnosticsProperties], + // [EventListResponseEventPermissionUpdatedProperties], + // [EventListResponseEventFileEditedProperties], + // [EventListResponseEventStorageWriteProperties], + // [EventListResponseEventInstallationUpdatedProperties], + // [EventListResponseEventMessageUpdatedProperties], + // [EventListResponseEventMessageRemovedProperties], + // [EventListResponseEventMessagePartUpdatedProperties], + // [EventListResponseEventSessionUpdatedProperties], + // [EventListResponseEventSessionDeletedProperties], + // [EventListResponseEventSessionIdleProperties], + // [EventListResponseEventSessionErrorProperties], + // [EventListResponseEventFileWatcherUpdatedProperties]. + Properties interface{} `json:"properties,required"` + Type EventListResponseType `json:"type,required"` + JSON eventListResponseJSON `json:"-"` + union EventListResponseUnion +} + +// eventListResponseJSON contains the JSON metadata for the struct +// [EventListResponse] +type eventListResponseJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r eventListResponseJSON) RawJSON() string { + return r.raw +} + +func (r *EventListResponse) UnmarshalJSON(data []byte) (err error) { + *r = EventListResponse{} + err = apijson.UnmarshalRoot(data, &r.union) + if err != nil { + return err + } + return apijson.Port(r.union, &r) +} + +// AsUnion returns a [EventListResponseUnion] interface which you can cast to the +// specific types for more type safety. +// +// Possible runtime types of the union are +// [EventListResponseEventLspClientDiagnostics], +// [EventListResponseEventPermissionUpdated], [EventListResponseEventFileEdited], +// [EventListResponseEventStorageWrite], +// [EventListResponseEventInstallationUpdated], +// [EventListResponseEventMessageUpdated], [EventListResponseEventMessageRemoved], +// [EventListResponseEventMessagePartUpdated], +// [EventListResponseEventSessionUpdated], [EventListResponseEventSessionDeleted], +// [EventListResponseEventSessionIdle], [EventListResponseEventSessionError], +// [EventListResponseEventFileWatcherUpdated]. +func (r EventListResponse) AsUnion() EventListResponseUnion { + return r.union +} + +// Union satisfied by [EventListResponseEventLspClientDiagnostics], +// [EventListResponseEventPermissionUpdated], [EventListResponseEventFileEdited], +// [EventListResponseEventStorageWrite], +// [EventListResponseEventInstallationUpdated], +// [EventListResponseEventMessageUpdated], [EventListResponseEventMessageRemoved], +// [EventListResponseEventMessagePartUpdated], +// [EventListResponseEventSessionUpdated], [EventListResponseEventSessionDeleted], +// [EventListResponseEventSessionIdle], [EventListResponseEventSessionError] or +// [EventListResponseEventFileWatcherUpdated]. +type EventListResponseUnion interface { + implementsEventListResponse() +} + +func init() { + apijson.RegisterUnion( + reflect.TypeOf((*EventListResponseUnion)(nil)).Elem(), + "type", + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventLspClientDiagnostics{}), + DiscriminatorValue: "lsp.client.diagnostics", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventPermissionUpdated{}), + DiscriminatorValue: "permission.updated", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventFileEdited{}), + DiscriminatorValue: "file.edited", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventStorageWrite{}), + DiscriminatorValue: "storage.write", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventInstallationUpdated{}), + DiscriminatorValue: "installation.updated", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventMessageUpdated{}), + DiscriminatorValue: "message.updated", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventMessageRemoved{}), + DiscriminatorValue: "message.removed", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventMessagePartUpdated{}), + DiscriminatorValue: "message.part.updated", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventSessionUpdated{}), + DiscriminatorValue: "session.updated", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventSessionDeleted{}), + DiscriminatorValue: "session.deleted", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventSessionIdle{}), + DiscriminatorValue: "session.idle", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventSessionError{}), + DiscriminatorValue: "session.error", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventFileWatcherUpdated{}), + DiscriminatorValue: "file.watcher.updated", + }, + ) +} + +type EventListResponseEventLspClientDiagnostics struct { + Properties EventListResponseEventLspClientDiagnosticsProperties `json:"properties,required"` + Type EventListResponseEventLspClientDiagnosticsType `json:"type,required"` + JSON eventListResponseEventLspClientDiagnosticsJSON `json:"-"` +} + +// eventListResponseEventLspClientDiagnosticsJSON contains the JSON metadata for +// the struct [EventListResponseEventLspClientDiagnostics] +type eventListResponseEventLspClientDiagnosticsJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventLspClientDiagnostics) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventLspClientDiagnosticsJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventLspClientDiagnostics) implementsEventListResponse() {} + +type EventListResponseEventLspClientDiagnosticsProperties struct { + Path string `json:"path,required"` + ServerID string `json:"serverID,required"` + JSON eventListResponseEventLspClientDiagnosticsPropertiesJSON `json:"-"` +} + +// eventListResponseEventLspClientDiagnosticsPropertiesJSON contains the JSON +// metadata for the struct [EventListResponseEventLspClientDiagnosticsProperties] +type eventListResponseEventLspClientDiagnosticsPropertiesJSON struct { + Path apijson.Field + ServerID apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventLspClientDiagnosticsProperties) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventLspClientDiagnosticsPropertiesJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventLspClientDiagnosticsType string + +const ( + EventListResponseEventLspClientDiagnosticsTypeLspClientDiagnostics EventListResponseEventLspClientDiagnosticsType = "lsp.client.diagnostics" +) + +func (r EventListResponseEventLspClientDiagnosticsType) IsKnown() bool { + switch r { + case EventListResponseEventLspClientDiagnosticsTypeLspClientDiagnostics: + return true + } + return false +} + +type EventListResponseEventPermissionUpdated struct { + Properties EventListResponseEventPermissionUpdatedProperties `json:"properties,required"` + Type EventListResponseEventPermissionUpdatedType `json:"type,required"` + JSON eventListResponseEventPermissionUpdatedJSON `json:"-"` +} + +// eventListResponseEventPermissionUpdatedJSON contains the JSON metadata for the +// struct [EventListResponseEventPermissionUpdated] +type eventListResponseEventPermissionUpdatedJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventPermissionUpdated) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventPermissionUpdatedJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventPermissionUpdated) implementsEventListResponse() {} + +type EventListResponseEventPermissionUpdatedProperties struct { + ID string `json:"id,required"` + Metadata map[string]interface{} `json:"metadata,required"` + SessionID string `json:"sessionID,required"` + Time EventListResponseEventPermissionUpdatedPropertiesTime `json:"time,required"` + Title string `json:"title,required"` + JSON eventListResponseEventPermissionUpdatedPropertiesJSON `json:"-"` +} + +// eventListResponseEventPermissionUpdatedPropertiesJSON contains the JSON metadata +// for the struct [EventListResponseEventPermissionUpdatedProperties] +type eventListResponseEventPermissionUpdatedPropertiesJSON struct { + ID apijson.Field + Metadata apijson.Field + SessionID apijson.Field + Time apijson.Field + Title apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventPermissionUpdatedProperties) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventPermissionUpdatedPropertiesJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventPermissionUpdatedPropertiesTime struct { + Created float64 `json:"created,required"` + JSON eventListResponseEventPermissionUpdatedPropertiesTimeJSON `json:"-"` +} + +// eventListResponseEventPermissionUpdatedPropertiesTimeJSON contains the JSON +// metadata for the struct [EventListResponseEventPermissionUpdatedPropertiesTime] +type eventListResponseEventPermissionUpdatedPropertiesTimeJSON struct { + Created apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventPermissionUpdatedPropertiesTime) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventPermissionUpdatedPropertiesTimeJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventPermissionUpdatedType string + +const ( + EventListResponseEventPermissionUpdatedTypePermissionUpdated EventListResponseEventPermissionUpdatedType = "permission.updated" +) + +func (r EventListResponseEventPermissionUpdatedType) IsKnown() bool { + switch r { + case EventListResponseEventPermissionUpdatedTypePermissionUpdated: + return true + } + return false +} + +type EventListResponseEventFileEdited struct { + Properties EventListResponseEventFileEditedProperties `json:"properties,required"` + Type EventListResponseEventFileEditedType `json:"type,required"` + JSON eventListResponseEventFileEditedJSON `json:"-"` +} + +// eventListResponseEventFileEditedJSON contains the JSON metadata for the struct +// [EventListResponseEventFileEdited] +type eventListResponseEventFileEditedJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventFileEdited) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventFileEditedJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventFileEdited) implementsEventListResponse() {} + +type EventListResponseEventFileEditedProperties struct { + File string `json:"file,required"` + JSON eventListResponseEventFileEditedPropertiesJSON `json:"-"` +} + +// eventListResponseEventFileEditedPropertiesJSON contains the JSON metadata for +// the struct [EventListResponseEventFileEditedProperties] +type eventListResponseEventFileEditedPropertiesJSON struct { + File apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventFileEditedProperties) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventFileEditedPropertiesJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventFileEditedType string + +const ( + EventListResponseEventFileEditedTypeFileEdited EventListResponseEventFileEditedType = "file.edited" +) + +func (r EventListResponseEventFileEditedType) IsKnown() bool { + switch r { + case EventListResponseEventFileEditedTypeFileEdited: + return true + } + return false +} + +type EventListResponseEventStorageWrite struct { + Properties EventListResponseEventStorageWriteProperties `json:"properties,required"` + Type EventListResponseEventStorageWriteType `json:"type,required"` + JSON eventListResponseEventStorageWriteJSON `json:"-"` +} + +// eventListResponseEventStorageWriteJSON contains the JSON metadata for the struct +// [EventListResponseEventStorageWrite] +type eventListResponseEventStorageWriteJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventStorageWrite) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventStorageWriteJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventStorageWrite) implementsEventListResponse() {} + +type EventListResponseEventStorageWriteProperties struct { + Key string `json:"key,required"` + Content interface{} `json:"content"` + JSON eventListResponseEventStorageWritePropertiesJSON `json:"-"` +} + +// eventListResponseEventStorageWritePropertiesJSON contains the JSON metadata for +// the struct [EventListResponseEventStorageWriteProperties] +type eventListResponseEventStorageWritePropertiesJSON struct { + Key apijson.Field + Content apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventStorageWriteProperties) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventStorageWritePropertiesJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventStorageWriteType string + +const ( + EventListResponseEventStorageWriteTypeStorageWrite EventListResponseEventStorageWriteType = "storage.write" +) + +func (r EventListResponseEventStorageWriteType) IsKnown() bool { + switch r { + case EventListResponseEventStorageWriteTypeStorageWrite: + return true + } + return false +} + +type EventListResponseEventInstallationUpdated struct { + Properties EventListResponseEventInstallationUpdatedProperties `json:"properties,required"` + Type EventListResponseEventInstallationUpdatedType `json:"type,required"` + JSON eventListResponseEventInstallationUpdatedJSON `json:"-"` +} + +// eventListResponseEventInstallationUpdatedJSON contains the JSON metadata for the +// struct [EventListResponseEventInstallationUpdated] +type eventListResponseEventInstallationUpdatedJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventInstallationUpdated) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventInstallationUpdatedJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventInstallationUpdated) implementsEventListResponse() {} + +type EventListResponseEventInstallationUpdatedProperties struct { + Version string `json:"version,required"` + JSON eventListResponseEventInstallationUpdatedPropertiesJSON `json:"-"` +} + +// eventListResponseEventInstallationUpdatedPropertiesJSON contains the JSON +// metadata for the struct [EventListResponseEventInstallationUpdatedProperties] +type eventListResponseEventInstallationUpdatedPropertiesJSON struct { + Version apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventInstallationUpdatedProperties) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventInstallationUpdatedPropertiesJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventInstallationUpdatedType string + +const ( + EventListResponseEventInstallationUpdatedTypeInstallationUpdated EventListResponseEventInstallationUpdatedType = "installation.updated" +) + +func (r EventListResponseEventInstallationUpdatedType) IsKnown() bool { + switch r { + case EventListResponseEventInstallationUpdatedTypeInstallationUpdated: + return true + } + return false +} + +type EventListResponseEventMessageUpdated struct { + Properties EventListResponseEventMessageUpdatedProperties `json:"properties,required"` + Type EventListResponseEventMessageUpdatedType `json:"type,required"` + JSON eventListResponseEventMessageUpdatedJSON `json:"-"` +} + +// eventListResponseEventMessageUpdatedJSON contains the JSON metadata for the +// struct [EventListResponseEventMessageUpdated] +type eventListResponseEventMessageUpdatedJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventMessageUpdated) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventMessageUpdatedJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventMessageUpdated) implementsEventListResponse() {} + +type EventListResponseEventMessageUpdatedProperties struct { + Info Message `json:"info,required"` + JSON eventListResponseEventMessageUpdatedPropertiesJSON `json:"-"` +} + +// eventListResponseEventMessageUpdatedPropertiesJSON contains the JSON metadata +// for the struct [EventListResponseEventMessageUpdatedProperties] +type eventListResponseEventMessageUpdatedPropertiesJSON struct { + Info apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventMessageUpdatedProperties) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventMessageUpdatedPropertiesJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventMessageUpdatedType string + +const ( + EventListResponseEventMessageUpdatedTypeMessageUpdated EventListResponseEventMessageUpdatedType = "message.updated" +) + +func (r EventListResponseEventMessageUpdatedType) IsKnown() bool { + switch r { + case EventListResponseEventMessageUpdatedTypeMessageUpdated: + return true + } + return false +} + +type EventListResponseEventMessageRemoved struct { + Properties EventListResponseEventMessageRemovedProperties `json:"properties,required"` + Type EventListResponseEventMessageRemovedType `json:"type,required"` + JSON eventListResponseEventMessageRemovedJSON `json:"-"` +} + +// eventListResponseEventMessageRemovedJSON contains the JSON metadata for the +// struct [EventListResponseEventMessageRemoved] +type eventListResponseEventMessageRemovedJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventMessageRemoved) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventMessageRemovedJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventMessageRemoved) implementsEventListResponse() {} + +type EventListResponseEventMessageRemovedProperties struct { + MessageID string `json:"messageID,required"` + SessionID string `json:"sessionID,required"` + JSON eventListResponseEventMessageRemovedPropertiesJSON `json:"-"` +} + +// eventListResponseEventMessageRemovedPropertiesJSON contains the JSON metadata +// for the struct [EventListResponseEventMessageRemovedProperties] +type eventListResponseEventMessageRemovedPropertiesJSON struct { + MessageID apijson.Field + SessionID apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventMessageRemovedProperties) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventMessageRemovedPropertiesJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventMessageRemovedType string + +const ( + EventListResponseEventMessageRemovedTypeMessageRemoved EventListResponseEventMessageRemovedType = "message.removed" +) + +func (r EventListResponseEventMessageRemovedType) IsKnown() bool { + switch r { + case EventListResponseEventMessageRemovedTypeMessageRemoved: + return true + } + return false +} + +type EventListResponseEventMessagePartUpdated struct { + Properties EventListResponseEventMessagePartUpdatedProperties `json:"properties,required"` + Type EventListResponseEventMessagePartUpdatedType `json:"type,required"` + JSON eventListResponseEventMessagePartUpdatedJSON `json:"-"` +} + +// eventListResponseEventMessagePartUpdatedJSON contains the JSON metadata for the +// struct [EventListResponseEventMessagePartUpdated] +type eventListResponseEventMessagePartUpdatedJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventMessagePartUpdated) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventMessagePartUpdatedJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventMessagePartUpdated) implementsEventListResponse() {} + +type EventListResponseEventMessagePartUpdatedProperties struct { + MessageID string `json:"messageID,required"` + Part MessagePart `json:"part,required"` + SessionID string `json:"sessionID,required"` + JSON eventListResponseEventMessagePartUpdatedPropertiesJSON `json:"-"` +} + +// eventListResponseEventMessagePartUpdatedPropertiesJSON contains the JSON +// metadata for the struct [EventListResponseEventMessagePartUpdatedProperties] +type eventListResponseEventMessagePartUpdatedPropertiesJSON struct { + MessageID apijson.Field + Part apijson.Field + SessionID apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventMessagePartUpdatedProperties) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventMessagePartUpdatedPropertiesJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventMessagePartUpdatedType string + +const ( + EventListResponseEventMessagePartUpdatedTypeMessagePartUpdated EventListResponseEventMessagePartUpdatedType = "message.part.updated" +) + +func (r EventListResponseEventMessagePartUpdatedType) IsKnown() bool { + switch r { + case EventListResponseEventMessagePartUpdatedTypeMessagePartUpdated: + return true + } + return false +} + +type EventListResponseEventSessionUpdated struct { + Properties EventListResponseEventSessionUpdatedProperties `json:"properties,required"` + Type EventListResponseEventSessionUpdatedType `json:"type,required"` + JSON eventListResponseEventSessionUpdatedJSON `json:"-"` +} + +// eventListResponseEventSessionUpdatedJSON contains the JSON metadata for the +// struct [EventListResponseEventSessionUpdated] +type eventListResponseEventSessionUpdatedJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventSessionUpdated) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventSessionUpdatedJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventSessionUpdated) implementsEventListResponse() {} + +type EventListResponseEventSessionUpdatedProperties struct { + Info Session `json:"info,required"` + JSON eventListResponseEventSessionUpdatedPropertiesJSON `json:"-"` +} + +// eventListResponseEventSessionUpdatedPropertiesJSON contains the JSON metadata +// for the struct [EventListResponseEventSessionUpdatedProperties] +type eventListResponseEventSessionUpdatedPropertiesJSON struct { + Info apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventSessionUpdatedProperties) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventSessionUpdatedPropertiesJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventSessionUpdatedType string + +const ( + EventListResponseEventSessionUpdatedTypeSessionUpdated EventListResponseEventSessionUpdatedType = "session.updated" +) + +func (r EventListResponseEventSessionUpdatedType) IsKnown() bool { + switch r { + case EventListResponseEventSessionUpdatedTypeSessionUpdated: + return true + } + return false +} + +type EventListResponseEventSessionDeleted struct { + Properties EventListResponseEventSessionDeletedProperties `json:"properties,required"` + Type EventListResponseEventSessionDeletedType `json:"type,required"` + JSON eventListResponseEventSessionDeletedJSON `json:"-"` +} + +// eventListResponseEventSessionDeletedJSON contains the JSON metadata for the +// struct [EventListResponseEventSessionDeleted] +type eventListResponseEventSessionDeletedJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventSessionDeleted) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventSessionDeletedJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventSessionDeleted) implementsEventListResponse() {} + +type EventListResponseEventSessionDeletedProperties struct { + Info Session `json:"info,required"` + JSON eventListResponseEventSessionDeletedPropertiesJSON `json:"-"` +} + +// eventListResponseEventSessionDeletedPropertiesJSON contains the JSON metadata +// for the struct [EventListResponseEventSessionDeletedProperties] +type eventListResponseEventSessionDeletedPropertiesJSON struct { + Info apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventSessionDeletedProperties) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventSessionDeletedPropertiesJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventSessionDeletedType string + +const ( + EventListResponseEventSessionDeletedTypeSessionDeleted EventListResponseEventSessionDeletedType = "session.deleted" +) + +func (r EventListResponseEventSessionDeletedType) IsKnown() bool { + switch r { + case EventListResponseEventSessionDeletedTypeSessionDeleted: + return true + } + return false +} + +type EventListResponseEventSessionIdle struct { + Properties EventListResponseEventSessionIdleProperties `json:"properties,required"` + Type EventListResponseEventSessionIdleType `json:"type,required"` + JSON eventListResponseEventSessionIdleJSON `json:"-"` +} + +// eventListResponseEventSessionIdleJSON contains the JSON metadata for the struct +// [EventListResponseEventSessionIdle] +type eventListResponseEventSessionIdleJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventSessionIdle) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventSessionIdleJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventSessionIdle) implementsEventListResponse() {} + +type EventListResponseEventSessionIdleProperties struct { + SessionID string `json:"sessionID,required"` + JSON eventListResponseEventSessionIdlePropertiesJSON `json:"-"` +} + +// eventListResponseEventSessionIdlePropertiesJSON contains the JSON metadata for +// the struct [EventListResponseEventSessionIdleProperties] +type eventListResponseEventSessionIdlePropertiesJSON struct { + SessionID apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventSessionIdleProperties) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventSessionIdlePropertiesJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventSessionIdleType string + +const ( + EventListResponseEventSessionIdleTypeSessionIdle EventListResponseEventSessionIdleType = "session.idle" +) + +func (r EventListResponseEventSessionIdleType) IsKnown() bool { + switch r { + case EventListResponseEventSessionIdleTypeSessionIdle: + return true + } + return false +} + +type EventListResponseEventSessionError struct { + Properties EventListResponseEventSessionErrorProperties `json:"properties,required"` + Type EventListResponseEventSessionErrorType `json:"type,required"` + JSON eventListResponseEventSessionErrorJSON `json:"-"` +} + +// eventListResponseEventSessionErrorJSON contains the JSON metadata for the struct +// [EventListResponseEventSessionError] +type eventListResponseEventSessionErrorJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventSessionError) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventSessionErrorJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventSessionError) implementsEventListResponse() {} + +type EventListResponseEventSessionErrorProperties struct { + Error EventListResponseEventSessionErrorPropertiesError `json:"error"` + JSON eventListResponseEventSessionErrorPropertiesJSON `json:"-"` +} + +// eventListResponseEventSessionErrorPropertiesJSON contains the JSON metadata for +// the struct [EventListResponseEventSessionErrorProperties] +type eventListResponseEventSessionErrorPropertiesJSON struct { + Error apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventSessionErrorProperties) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventSessionErrorPropertiesJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventSessionErrorPropertiesError struct { + // This field can have the runtime type of [shared.ProviderAuthErrorData], + // [shared.UnknownErrorData], [interface{}]. + Data interface{} `json:"data,required"` + Name EventListResponseEventSessionErrorPropertiesErrorName `json:"name,required"` + JSON eventListResponseEventSessionErrorPropertiesErrorJSON `json:"-"` + union EventListResponseEventSessionErrorPropertiesErrorUnion +} + +// eventListResponseEventSessionErrorPropertiesErrorJSON contains the JSON metadata +// for the struct [EventListResponseEventSessionErrorPropertiesError] +type eventListResponseEventSessionErrorPropertiesErrorJSON struct { + Data apijson.Field + Name apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r eventListResponseEventSessionErrorPropertiesErrorJSON) RawJSON() string { + return r.raw +} + +func (r *EventListResponseEventSessionErrorPropertiesError) UnmarshalJSON(data []byte) (err error) { + *r = EventListResponseEventSessionErrorPropertiesError{} + err = apijson.UnmarshalRoot(data, &r.union) + if err != nil { + return err + } + return apijson.Port(r.union, &r) +} + +// AsUnion returns a [EventListResponseEventSessionErrorPropertiesErrorUnion] +// interface which you can cast to the specific types for more type safety. +// +// Possible runtime types of the union are [shared.ProviderAuthError], +// [shared.UnknownError], +// [EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError]. +func (r EventListResponseEventSessionErrorPropertiesError) AsUnion() EventListResponseEventSessionErrorPropertiesErrorUnion { + return r.union +} + +// Union satisfied by [shared.ProviderAuthError], [shared.UnknownError] or +// [EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError]. +type EventListResponseEventSessionErrorPropertiesErrorUnion interface { + ImplementsEventListResponseEventSessionErrorPropertiesError() +} + +func init() { + apijson.RegisterUnion( + reflect.TypeOf((*EventListResponseEventSessionErrorPropertiesErrorUnion)(nil)).Elem(), + "name", + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(shared.ProviderAuthError{}), + DiscriminatorValue: "ProviderAuthError", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(shared.UnknownError{}), + DiscriminatorValue: "UnknownError", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError{}), + DiscriminatorValue: "MessageOutputLengthError", + }, + ) +} + +type EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError struct { + Data interface{} `json:"data,required"` + Name EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthErrorName `json:"name,required"` + JSON eventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthErrorJSON `json:"-"` +} + +// eventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthErrorJSON +// contains the JSON metadata for the struct +// [EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError] +type eventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthErrorJSON struct { + Data apijson.Field + Name apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthErrorJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError) ImplementsEventListResponseEventSessionErrorPropertiesError() { +} + +type EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthErrorName string + +const ( + EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthErrorNameMessageOutputLengthError EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthErrorName = "MessageOutputLengthError" +) + +func (r EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthErrorName) IsKnown() bool { + switch r { + case EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthErrorNameMessageOutputLengthError: + return true + } + return false +} + +type EventListResponseEventSessionErrorPropertiesErrorName string + +const ( + EventListResponseEventSessionErrorPropertiesErrorNameProviderAuthError EventListResponseEventSessionErrorPropertiesErrorName = "ProviderAuthError" + EventListResponseEventSessionErrorPropertiesErrorNameUnknownError EventListResponseEventSessionErrorPropertiesErrorName = "UnknownError" + EventListResponseEventSessionErrorPropertiesErrorNameMessageOutputLengthError EventListResponseEventSessionErrorPropertiesErrorName = "MessageOutputLengthError" +) + +func (r EventListResponseEventSessionErrorPropertiesErrorName) IsKnown() bool { + switch r { + case EventListResponseEventSessionErrorPropertiesErrorNameProviderAuthError, EventListResponseEventSessionErrorPropertiesErrorNameUnknownError, EventListResponseEventSessionErrorPropertiesErrorNameMessageOutputLengthError: + return true + } + return false +} + +type EventListResponseEventSessionErrorType string + +const ( + EventListResponseEventSessionErrorTypeSessionError EventListResponseEventSessionErrorType = "session.error" +) + +func (r EventListResponseEventSessionErrorType) IsKnown() bool { + switch r { + case EventListResponseEventSessionErrorTypeSessionError: + return true + } + return false +} + +type EventListResponseEventFileWatcherUpdated struct { + Properties EventListResponseEventFileWatcherUpdatedProperties `json:"properties,required"` + Type EventListResponseEventFileWatcherUpdatedType `json:"type,required"` + JSON eventListResponseEventFileWatcherUpdatedJSON `json:"-"` +} + +// eventListResponseEventFileWatcherUpdatedJSON contains the JSON metadata for the +// struct [EventListResponseEventFileWatcherUpdated] +type eventListResponseEventFileWatcherUpdatedJSON struct { + Properties apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventFileWatcherUpdated) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventFileWatcherUpdatedJSON) RawJSON() string { + return r.raw +} + +func (r EventListResponseEventFileWatcherUpdated) implementsEventListResponse() {} + +type EventListResponseEventFileWatcherUpdatedProperties struct { + Event EventListResponseEventFileWatcherUpdatedPropertiesEvent `json:"event,required"` + File string `json:"file,required"` + JSON eventListResponseEventFileWatcherUpdatedPropertiesJSON `json:"-"` +} + +// eventListResponseEventFileWatcherUpdatedPropertiesJSON contains the JSON +// metadata for the struct [EventListResponseEventFileWatcherUpdatedProperties] +type eventListResponseEventFileWatcherUpdatedPropertiesJSON struct { + Event apijson.Field + File apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *EventListResponseEventFileWatcherUpdatedProperties) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r eventListResponseEventFileWatcherUpdatedPropertiesJSON) RawJSON() string { + return r.raw +} + +type EventListResponseEventFileWatcherUpdatedPropertiesEvent string + +const ( + EventListResponseEventFileWatcherUpdatedPropertiesEventRename EventListResponseEventFileWatcherUpdatedPropertiesEvent = "rename" + EventListResponseEventFileWatcherUpdatedPropertiesEventChange EventListResponseEventFileWatcherUpdatedPropertiesEvent = "change" +) + +func (r EventListResponseEventFileWatcherUpdatedPropertiesEvent) IsKnown() bool { + switch r { + case EventListResponseEventFileWatcherUpdatedPropertiesEventRename, EventListResponseEventFileWatcherUpdatedPropertiesEventChange: + return true + } + return false +} + +type EventListResponseEventFileWatcherUpdatedType string + +const ( + EventListResponseEventFileWatcherUpdatedTypeFileWatcherUpdated EventListResponseEventFileWatcherUpdatedType = "file.watcher.updated" +) + +func (r EventListResponseEventFileWatcherUpdatedType) IsKnown() bool { + switch r { + case EventListResponseEventFileWatcherUpdatedTypeFileWatcherUpdated: + return true + } + return false +} + +type EventListResponseType string + +const ( + EventListResponseTypeLspClientDiagnostics EventListResponseType = "lsp.client.diagnostics" + EventListResponseTypePermissionUpdated EventListResponseType = "permission.updated" + EventListResponseTypeFileEdited EventListResponseType = "file.edited" + EventListResponseTypeStorageWrite EventListResponseType = "storage.write" + EventListResponseTypeInstallationUpdated EventListResponseType = "installation.updated" + EventListResponseTypeMessageUpdated EventListResponseType = "message.updated" + EventListResponseTypeMessageRemoved EventListResponseType = "message.removed" + EventListResponseTypeMessagePartUpdated EventListResponseType = "message.part.updated" + EventListResponseTypeSessionUpdated EventListResponseType = "session.updated" + EventListResponseTypeSessionDeleted EventListResponseType = "session.deleted" + EventListResponseTypeSessionIdle EventListResponseType = "session.idle" + EventListResponseTypeSessionError EventListResponseType = "session.error" + EventListResponseTypeFileWatcherUpdated EventListResponseType = "file.watcher.updated" +) + +func (r EventListResponseType) IsKnown() bool { + switch r { + case EventListResponseTypeLspClientDiagnostics, EventListResponseTypePermissionUpdated, EventListResponseTypeFileEdited, EventListResponseTypeStorageWrite, EventListResponseTypeInstallationUpdated, EventListResponseTypeMessageUpdated, EventListResponseTypeMessageRemoved, EventListResponseTypeMessagePartUpdated, EventListResponseTypeSessionUpdated, EventListResponseTypeSessionDeleted, EventListResponseTypeSessionIdle, EventListResponseTypeSessionError, EventListResponseTypeFileWatcherUpdated: + return true + } + return false +} diff --git a/packages/tui/sdk/examples/.keep b/packages/tui/sdk/examples/.keep new file mode 100644 index 00000000..d8c73e93 --- /dev/null +++ b/packages/tui/sdk/examples/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store example files demonstrating usage of this SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/packages/tui/sdk/field.go b/packages/tui/sdk/field.go new file mode 100644 index 00000000..56d2f890 --- /dev/null +++ b/packages/tui/sdk/field.go @@ -0,0 +1,50 @@ +package opencode + +import ( + "github.com/sst/opencode-sdk-go/internal/param" + "io" +) + +// F is a param field helper used to initialize a [param.Field] generic struct. +// This helps specify null, zero values, and overrides, as well as normal values. +// You can read more about this in our [README]. +// +// [README]: https://pkg.go.dev/github.com/sst/opencode-sdk-go#readme-request-fields +func F[T any](value T) param.Field[T] { return param.Field[T]{Value: value, Present: true} } + +// Null is a param field helper which explicitly sends null to the API. +func Null[T any]() param.Field[T] { return param.Field[T]{Null: true, Present: true} } + +// Raw is a param field helper for specifying values for fields when the +// type you are looking to send is different from the type that is specified in +// the SDK. For example, if the type of the field is an integer, but you want +// to send a float, you could do that by setting the corresponding field with +// Raw[int](0.5). +func Raw[T any](value any) param.Field[T] { return param.Field[T]{Raw: value, Present: true} } + +// Int is a param field helper which helps specify integers. This is +// particularly helpful when specifying integer constants for fields. +func Int(value int64) param.Field[int64] { return F(value) } + +// String is a param field helper which helps specify strings. +func String(value string) param.Field[string] { return F(value) } + +// Float is a param field helper which helps specify floats. +func Float(value float64) param.Field[float64] { return F(value) } + +// Bool is a param field helper which helps specify bools. +func Bool(value bool) param.Field[bool] { return F(value) } + +// FileParam is a param field helper which helps files with a mime content-type. +func FileParam(reader io.Reader, filename string, contentType string) param.Field[io.Reader] { + return F[io.Reader](&file{reader, filename, contentType}) +} + +type file struct { + io.Reader + name string + contentType string +} + +func (f *file) ContentType() string { return f.contentType } +func (f *file) Filename() string { return f.name } diff --git a/packages/tui/sdk/file.go b/packages/tui/sdk/file.go new file mode 100644 index 00000000..a9d6f018 --- /dev/null +++ b/packages/tui/sdk/file.go @@ -0,0 +1,143 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode + +import ( + "context" + "net/http" + "net/url" + + "github.com/sst/opencode-sdk-go/internal/apijson" + "github.com/sst/opencode-sdk-go/internal/apiquery" + "github.com/sst/opencode-sdk-go/internal/param" + "github.com/sst/opencode-sdk-go/internal/requestconfig" + "github.com/sst/opencode-sdk-go/option" +) + +// FileService contains methods and other services that help with interacting with +// the opencode API. +// +// Note, unlike clients, this service does not read variables from the environment +// automatically. You should not instantiate this service directly, and instead use +// the [NewFileService] method instead. +type FileService struct { + Options []option.RequestOption +} + +// NewFileService generates a new service that applies the given options to each +// request. These options are applied after the parent client's options (if there +// is one), and before any request-specific options. +func NewFileService(opts ...option.RequestOption) (r *FileService) { + r = &FileService{} + r.Options = opts + return +} + +// Read a file +func (r *FileService) Read(ctx context.Context, query FileReadParams, opts ...option.RequestOption) (res *FileReadResponse, err error) { + opts = append(r.Options[:], opts...) + path := "file" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) + return +} + +// Get file status +func (r *FileService) Status(ctx context.Context, opts ...option.RequestOption) (res *[]FileStatusResponse, err error) { + opts = append(r.Options[:], opts...) + path := "file/status" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + +type FileReadResponse struct { + Content string `json:"content,required"` + Type FileReadResponseType `json:"type,required"` + JSON fileReadResponseJSON `json:"-"` +} + +// fileReadResponseJSON contains the JSON metadata for the struct +// [FileReadResponse] +type fileReadResponseJSON struct { + Content apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *FileReadResponse) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r fileReadResponseJSON) RawJSON() string { + return r.raw +} + +type FileReadResponseType string + +const ( + FileReadResponseTypeRaw FileReadResponseType = "raw" + FileReadResponseTypePatch FileReadResponseType = "patch" +) + +func (r FileReadResponseType) IsKnown() bool { + switch r { + case FileReadResponseTypeRaw, FileReadResponseTypePatch: + return true + } + return false +} + +type FileStatusResponse struct { + Added int64 `json:"added,required"` + File string `json:"file,required"` + Removed int64 `json:"removed,required"` + Status FileStatusResponseStatus `json:"status,required"` + JSON fileStatusResponseJSON `json:"-"` +} + +// fileStatusResponseJSON contains the JSON metadata for the struct +// [FileStatusResponse] +type fileStatusResponseJSON struct { + Added apijson.Field + File apijson.Field + Removed apijson.Field + Status apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *FileStatusResponse) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r fileStatusResponseJSON) RawJSON() string { + return r.raw +} + +type FileStatusResponseStatus string + +const ( + FileStatusResponseStatusAdded FileStatusResponseStatus = "added" + FileStatusResponseStatusDeleted FileStatusResponseStatus = "deleted" + FileStatusResponseStatusModified FileStatusResponseStatus = "modified" +) + +func (r FileStatusResponseStatus) IsKnown() bool { + switch r { + case FileStatusResponseStatusAdded, FileStatusResponseStatusDeleted, FileStatusResponseStatusModified: + return true + } + return false +} + +type FileReadParams struct { + Path param.Field[string] `query:"path,required"` +} + +// URLQuery serializes [FileReadParams]'s query parameters as `url.Values`. +func (r FileReadParams) URLQuery() (v url.Values) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ + ArrayFormat: apiquery.ArrayQueryFormatComma, + NestedFormat: apiquery.NestedQueryFormatBrackets, + }) +} diff --git a/packages/tui/sdk/file_test.go b/packages/tui/sdk/file_test.go new file mode 100644 index 00000000..60212ea2 --- /dev/null +++ b/packages/tui/sdk/file_test.go @@ -0,0 +1,60 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/sst/opencode-sdk-go" + "github.com/sst/opencode-sdk-go/internal/testutil" + "github.com/sst/opencode-sdk-go/option" +) + +func TestFileRead(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.File.Read(context.TODO(), opencode.FileReadParams{ + Path: opencode.F("path"), + }) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestFileStatus(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.File.Status(context.TODO()) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} diff --git a/packages/tui/sdk/find.go b/packages/tui/sdk/find.go new file mode 100644 index 00000000..bbd6b680 --- /dev/null +++ b/packages/tui/sdk/find.go @@ -0,0 +1,213 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode + +import ( + "context" + "net/http" + "net/url" + + "github.com/sst/opencode-sdk-go/internal/apijson" + "github.com/sst/opencode-sdk-go/internal/apiquery" + "github.com/sst/opencode-sdk-go/internal/param" + "github.com/sst/opencode-sdk-go/internal/requestconfig" + "github.com/sst/opencode-sdk-go/option" +) + +// FindService contains methods and other services that help with interacting with +// the opencode API. +// +// Note, unlike clients, this service does not read variables from the environment +// automatically. You should not instantiate this service directly, and instead use +// the [NewFindService] method instead. +type FindService struct { + Options []option.RequestOption +} + +// NewFindService generates a new service that applies the given options to each +// request. These options are applied after the parent client's options (if there +// is one), and before any request-specific options. +func NewFindService(opts ...option.RequestOption) (r *FindService) { + r = &FindService{} + r.Options = opts + return +} + +// Find files +func (r *FindService) Files(ctx context.Context, query FindFilesParams, opts ...option.RequestOption) (res *[]string, err error) { + opts = append(r.Options[:], opts...) + path := "find/file" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) + return +} + +// Find workspace symbols +func (r *FindService) Symbols(ctx context.Context, query FindSymbolsParams, opts ...option.RequestOption) (res *[]FindSymbolsResponse, err error) { + opts = append(r.Options[:], opts...) + path := "find/symbol" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) + return +} + +// Find text in files +func (r *FindService) Text(ctx context.Context, query FindTextParams, opts ...option.RequestOption) (res *[]FindTextResponse, err error) { + opts = append(r.Options[:], opts...) + path := "find" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) + return +} + +type FindSymbolsResponse = interface{} + +type FindTextResponse struct { + AbsoluteOffset float64 `json:"absolute_offset,required"` + LineNumber float64 `json:"line_number,required"` + Lines FindTextResponseLines `json:"lines,required"` + Path FindTextResponsePath `json:"path,required"` + Submatches []FindTextResponseSubmatch `json:"submatches,required"` + JSON findTextResponseJSON `json:"-"` +} + +// findTextResponseJSON contains the JSON metadata for the struct +// [FindTextResponse] +type findTextResponseJSON struct { + AbsoluteOffset apijson.Field + LineNumber apijson.Field + Lines apijson.Field + Path apijson.Field + Submatches apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *FindTextResponse) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r findTextResponseJSON) RawJSON() string { + return r.raw +} + +type FindTextResponseLines struct { + Text string `json:"text,required"` + JSON findTextResponseLinesJSON `json:"-"` +} + +// findTextResponseLinesJSON contains the JSON metadata for the struct +// [FindTextResponseLines] +type findTextResponseLinesJSON struct { + Text apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *FindTextResponseLines) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r findTextResponseLinesJSON) RawJSON() string { + return r.raw +} + +type FindTextResponsePath struct { + Text string `json:"text,required"` + JSON findTextResponsePathJSON `json:"-"` +} + +// findTextResponsePathJSON contains the JSON metadata for the struct +// [FindTextResponsePath] +type findTextResponsePathJSON struct { + Text apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *FindTextResponsePath) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r findTextResponsePathJSON) RawJSON() string { + return r.raw +} + +type FindTextResponseSubmatch struct { + End float64 `json:"end,required"` + Match FindTextResponseSubmatchesMatch `json:"match,required"` + Start float64 `json:"start,required"` + JSON findTextResponseSubmatchJSON `json:"-"` +} + +// findTextResponseSubmatchJSON contains the JSON metadata for the struct +// [FindTextResponseSubmatch] +type findTextResponseSubmatchJSON struct { + End apijson.Field + Match apijson.Field + Start apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *FindTextResponseSubmatch) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r findTextResponseSubmatchJSON) RawJSON() string { + return r.raw +} + +type FindTextResponseSubmatchesMatch struct { + Text string `json:"text,required"` + JSON findTextResponseSubmatchesMatchJSON `json:"-"` +} + +// findTextResponseSubmatchesMatchJSON contains the JSON metadata for the struct +// [FindTextResponseSubmatchesMatch] +type findTextResponseSubmatchesMatchJSON struct { + Text apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *FindTextResponseSubmatchesMatch) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r findTextResponseSubmatchesMatchJSON) RawJSON() string { + return r.raw +} + +type FindFilesParams struct { + Query param.Field[string] `query:"query,required"` +} + +// URLQuery serializes [FindFilesParams]'s query parameters as `url.Values`. +func (r FindFilesParams) URLQuery() (v url.Values) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ + ArrayFormat: apiquery.ArrayQueryFormatComma, + NestedFormat: apiquery.NestedQueryFormatBrackets, + }) +} + +type FindSymbolsParams struct { + Query param.Field[string] `query:"query,required"` +} + +// URLQuery serializes [FindSymbolsParams]'s query parameters as `url.Values`. +func (r FindSymbolsParams) URLQuery() (v url.Values) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ + ArrayFormat: apiquery.ArrayQueryFormatComma, + NestedFormat: apiquery.NestedQueryFormatBrackets, + }) +} + +type FindTextParams struct { + Pattern param.Field[string] `query:"pattern,required"` +} + +// URLQuery serializes [FindTextParams]'s query parameters as `url.Values`. +func (r FindTextParams) URLQuery() (v url.Values) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ + ArrayFormat: apiquery.ArrayQueryFormatComma, + NestedFormat: apiquery.NestedQueryFormatBrackets, + }) +} diff --git a/packages/tui/sdk/find_test.go b/packages/tui/sdk/find_test.go new file mode 100644 index 00000000..e2f1caa1 --- /dev/null +++ b/packages/tui/sdk/find_test.go @@ -0,0 +1,86 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/sst/opencode-sdk-go" + "github.com/sst/opencode-sdk-go/internal/testutil" + "github.com/sst/opencode-sdk-go/option" +) + +func TestFindFiles(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Find.Files(context.TODO(), opencode.FindFilesParams{ + Query: opencode.F("query"), + }) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestFindSymbols(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Find.Symbols(context.TODO(), opencode.FindSymbolsParams{ + Query: opencode.F("query"), + }) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestFindText(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Find.Text(context.TODO(), opencode.FindTextParams{ + Pattern: opencode.F("pattern"), + }) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} diff --git a/packages/tui/sdk/go.mod b/packages/tui/sdk/go.mod new file mode 100644 index 00000000..2817d301 --- /dev/null +++ b/packages/tui/sdk/go.mod @@ -0,0 +1,13 @@ +module github.com/sst/opencode-sdk-go + +go 1.21 + +require ( + github.com/tidwall/gjson v1.14.4 + github.com/tidwall/sjson v1.2.5 +) + +require ( + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect +) diff --git a/packages/tui/sdk/go.sum b/packages/tui/sdk/go.sum new file mode 100644 index 00000000..a70a5e0a --- /dev/null +++ b/packages/tui/sdk/go.sum @@ -0,0 +1,10 @@ +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= diff --git a/packages/tui/sdk/internal/apierror/apierror.go b/packages/tui/sdk/internal/apierror/apierror.go new file mode 100644 index 00000000..24307fc3 --- /dev/null +++ b/packages/tui/sdk/internal/apierror/apierror.go @@ -0,0 +1,53 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package apierror + +import ( + "fmt" + "net/http" + "net/http/httputil" + + "github.com/sst/opencode-sdk-go/internal/apijson" +) + +// Error represents an error that originates from the API, i.e. when a request is +// made and the API returns a response with a HTTP status code. Other errors are +// not wrapped by this SDK. +type Error struct { + JSON errorJSON `json:"-"` + StatusCode int + Request *http.Request + Response *http.Response +} + +// errorJSON contains the JSON metadata for the struct [Error] +type errorJSON struct { + raw string + ExtraFields map[string]apijson.Field +} + +func (r *Error) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r errorJSON) RawJSON() string { + return r.raw +} + +func (r *Error) Error() string { + // Attempt to re-populate the response body + return fmt.Sprintf("%s \"%s\": %d %s %s", r.Request.Method, r.Request.URL, r.Response.StatusCode, http.StatusText(r.Response.StatusCode), r.JSON.RawJSON()) +} + +func (r *Error) DumpRequest(body bool) []byte { + if r.Request.GetBody != nil { + r.Request.Body, _ = r.Request.GetBody() + } + out, _ := httputil.DumpRequestOut(r.Request, body) + return out +} + +func (r *Error) DumpResponse(body bool) []byte { + out, _ := httputil.DumpResponse(r.Response, body) + return out +} diff --git a/packages/tui/sdk/internal/apiform/encoder.go b/packages/tui/sdk/internal/apiform/encoder.go new file mode 100644 index 00000000..243a1a12 --- /dev/null +++ b/packages/tui/sdk/internal/apiform/encoder.go @@ -0,0 +1,383 @@ +package apiform + +import ( + "fmt" + "io" + "mime/multipart" + "net/textproto" + "path" + "reflect" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/sst/opencode-sdk-go/internal/param" +) + +var encoders sync.Map // map[encoderEntry]encoderFunc + +func Marshal(value interface{}, writer *multipart.Writer) error { + e := &encoder{dateFormat: time.RFC3339} + return e.marshal(value, writer) +} + +func MarshalRoot(value interface{}, writer *multipart.Writer) error { + e := &encoder{root: true, dateFormat: time.RFC3339} + return e.marshal(value, writer) +} + +type encoder struct { + dateFormat string + root bool +} + +type encoderFunc func(key string, value reflect.Value, writer *multipart.Writer) error + +type encoderField struct { + tag parsedStructTag + fn encoderFunc + idx []int +} + +type encoderEntry struct { + reflect.Type + dateFormat string + root bool +} + +func (e *encoder) marshal(value interface{}, writer *multipart.Writer) error { + val := reflect.ValueOf(value) + if !val.IsValid() { + return nil + } + typ := val.Type() + enc := e.typeEncoder(typ) + return enc("", val, writer) +} + +func (e *encoder) typeEncoder(t reflect.Type) encoderFunc { + entry := encoderEntry{ + Type: t, + dateFormat: e.dateFormat, + root: e.root, + } + + if fi, ok := encoders.Load(entry); ok { + return fi.(encoderFunc) + } + + // To deal with recursive types, populate the map with an + // indirect func before we build it. This type waits on the + // real func (f) to be ready and then calls it. This indirect + // func is only used for recursive types. + var ( + wg sync.WaitGroup + f encoderFunc + ) + wg.Add(1) + fi, loaded := encoders.LoadOrStore(entry, encoderFunc(func(key string, v reflect.Value, writer *multipart.Writer) error { + wg.Wait() + return f(key, v, writer) + })) + if loaded { + return fi.(encoderFunc) + } + + // Compute the real encoder and replace the indirect func with it. + f = e.newTypeEncoder(t) + wg.Done() + encoders.Store(entry, f) + return f +} + +func (e *encoder) newTypeEncoder(t reflect.Type) encoderFunc { + if t.ConvertibleTo(reflect.TypeOf(time.Time{})) { + return e.newTimeTypeEncoder() + } + if t.ConvertibleTo(reflect.TypeOf((*io.Reader)(nil)).Elem()) { + return e.newReaderTypeEncoder() + } + e.root = false + switch t.Kind() { + case reflect.Pointer: + inner := t.Elem() + + innerEncoder := e.typeEncoder(inner) + return func(key string, v reflect.Value, writer *multipart.Writer) error { + if !v.IsValid() || v.IsNil() { + return nil + } + return innerEncoder(key, v.Elem(), writer) + } + case reflect.Struct: + return e.newStructTypeEncoder(t) + case reflect.Slice, reflect.Array: + return e.newArrayTypeEncoder(t) + case reflect.Map: + return e.newMapEncoder(t) + case reflect.Interface: + return e.newInterfaceEncoder() + default: + return e.newPrimitiveTypeEncoder(t) + } +} + +func (e *encoder) newPrimitiveTypeEncoder(t reflect.Type) encoderFunc { + switch t.Kind() { + // Note that we could use `gjson` to encode these types but it would complicate our + // code more and this current code shouldn't cause any issues + case reflect.String: + return func(key string, v reflect.Value, writer *multipart.Writer) error { + return writer.WriteField(key, v.String()) + } + case reflect.Bool: + return func(key string, v reflect.Value, writer *multipart.Writer) error { + if v.Bool() { + return writer.WriteField(key, "true") + } + return writer.WriteField(key, "false") + } + case reflect.Int, reflect.Int16, reflect.Int32, reflect.Int64: + return func(key string, v reflect.Value, writer *multipart.Writer) error { + return writer.WriteField(key, strconv.FormatInt(v.Int(), 10)) + } + case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return func(key string, v reflect.Value, writer *multipart.Writer) error { + return writer.WriteField(key, strconv.FormatUint(v.Uint(), 10)) + } + case reflect.Float32: + return func(key string, v reflect.Value, writer *multipart.Writer) error { + return writer.WriteField(key, strconv.FormatFloat(v.Float(), 'f', -1, 32)) + } + case reflect.Float64: + return func(key string, v reflect.Value, writer *multipart.Writer) error { + return writer.WriteField(key, strconv.FormatFloat(v.Float(), 'f', -1, 64)) + } + default: + return func(key string, v reflect.Value, writer *multipart.Writer) error { + return fmt.Errorf("unknown type received at primitive encoder: %s", t.String()) + } + } +} + +func (e *encoder) newArrayTypeEncoder(t reflect.Type) encoderFunc { + itemEncoder := e.typeEncoder(t.Elem()) + + return func(key string, v reflect.Value, writer *multipart.Writer) error { + if key != "" { + key = key + "." + } + for i := 0; i < v.Len(); i++ { + err := itemEncoder(key+strconv.Itoa(i), v.Index(i), writer) + if err != nil { + return err + } + } + return nil + } +} + +func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc { + if t.Implements(reflect.TypeOf((*param.FieldLike)(nil)).Elem()) { + return e.newFieldTypeEncoder(t) + } + + encoderFields := []encoderField{} + extraEncoder := (*encoderField)(nil) + + // This helper allows us to recursively collect field encoders into a flat + // array. The parameter `index` keeps track of the access patterns necessary + // to get to some field. + var collectEncoderFields func(r reflect.Type, index []int) + collectEncoderFields = func(r reflect.Type, index []int) { + for i := 0; i < r.NumField(); i++ { + idx := append(index, i) + field := t.FieldByIndex(idx) + if !field.IsExported() { + continue + } + // If this is an embedded struct, traverse one level deeper to extract + // the field and get their encoders as well. + if field.Anonymous { + collectEncoderFields(field.Type, idx) + continue + } + // If json tag is not present, then we skip, which is intentionally + // different behavior from the stdlib. + ptag, ok := parseFormStructTag(field) + if !ok { + continue + } + // We only want to support unexported field if they're tagged with + // `extras` because that field shouldn't be part of the public API. We + // also want to only keep the top level extras + if ptag.extras && len(index) == 0 { + extraEncoder = &encoderField{ptag, e.typeEncoder(field.Type.Elem()), idx} + continue + } + if ptag.name == "-" { + continue + } + + dateFormat, ok := parseFormatStructTag(field) + oldFormat := e.dateFormat + if ok { + switch dateFormat { + case "date-time": + e.dateFormat = time.RFC3339 + case "date": + e.dateFormat = "2006-01-02" + } + } + encoderFields = append(encoderFields, encoderField{ptag, e.typeEncoder(field.Type), idx}) + e.dateFormat = oldFormat + } + } + collectEncoderFields(t, []int{}) + + // Ensure deterministic output by sorting by lexicographic order + sort.Slice(encoderFields, func(i, j int) bool { + return encoderFields[i].tag.name < encoderFields[j].tag.name + }) + + return func(key string, value reflect.Value, writer *multipart.Writer) error { + if key != "" { + key = key + "." + } + + for _, ef := range encoderFields { + field := value.FieldByIndex(ef.idx) + err := ef.fn(key+ef.tag.name, field, writer) + if err != nil { + return err + } + } + + if extraEncoder != nil { + err := e.encodeMapEntries(key, value.FieldByIndex(extraEncoder.idx), writer) + if err != nil { + return err + } + } + + return nil + } +} + +func (e *encoder) newFieldTypeEncoder(t reflect.Type) encoderFunc { + f, _ := t.FieldByName("Value") + enc := e.typeEncoder(f.Type) + + return func(key string, value reflect.Value, writer *multipart.Writer) error { + present := value.FieldByName("Present") + if !present.Bool() { + return nil + } + null := value.FieldByName("Null") + if null.Bool() { + return nil + } + raw := value.FieldByName("Raw") + if !raw.IsNil() { + return e.typeEncoder(raw.Type())(key, raw, writer) + } + return enc(key, value.FieldByName("Value"), writer) + } +} + +func (e *encoder) newTimeTypeEncoder() encoderFunc { + format := e.dateFormat + return func(key string, value reflect.Value, writer *multipart.Writer) error { + return writer.WriteField(key, value.Convert(reflect.TypeOf(time.Time{})).Interface().(time.Time).Format(format)) + } +} + +func (e encoder) newInterfaceEncoder() encoderFunc { + return func(key string, value reflect.Value, writer *multipart.Writer) error { + value = value.Elem() + if !value.IsValid() { + return nil + } + return e.typeEncoder(value.Type())(key, value, writer) + } +} + +var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + +func escapeQuotes(s string) string { + return quoteEscaper.Replace(s) +} + +func (e *encoder) newReaderTypeEncoder() encoderFunc { + return func(key string, value reflect.Value, writer *multipart.Writer) error { + reader := value.Convert(reflect.TypeOf((*io.Reader)(nil)).Elem()).Interface().(io.Reader) + filename := "anonymous_file" + contentType := "application/octet-stream" + if named, ok := reader.(interface{ Filename() string }); ok { + filename = named.Filename() + } else if named, ok := reader.(interface{ Name() string }); ok { + filename = path.Base(named.Name()) + } + if typed, ok := reader.(interface{ ContentType() string }); ok { + contentType = typed.ContentType() + } + + // Below is taken almost 1-for-1 from [multipart.CreateFormFile] + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, escapeQuotes(key), escapeQuotes(filename))) + h.Set("Content-Type", contentType) + filewriter, err := writer.CreatePart(h) + if err != nil { + return err + } + _, err = io.Copy(filewriter, reader) + return err + } +} + +// Given a []byte of json (may either be an empty object or an object that already contains entries) +// encode all of the entries in the map to the json byte array. +func (e *encoder) encodeMapEntries(key string, v reflect.Value, writer *multipart.Writer) error { + type mapPair struct { + key string + value reflect.Value + } + + if key != "" { + key = key + "." + } + + pairs := []mapPair{} + + iter := v.MapRange() + for iter.Next() { + if iter.Key().Type().Kind() == reflect.String { + pairs = append(pairs, mapPair{key: iter.Key().String(), value: iter.Value()}) + } else { + return fmt.Errorf("cannot encode a map with a non string key") + } + } + + // Ensure deterministic output + sort.Slice(pairs, func(i, j int) bool { + return pairs[i].key < pairs[j].key + }) + + elementEncoder := e.typeEncoder(v.Type().Elem()) + for _, p := range pairs { + err := elementEncoder(key+string(p.key), p.value, writer) + if err != nil { + return err + } + } + + return nil +} + +func (e *encoder) newMapEncoder(t reflect.Type) encoderFunc { + return func(key string, value reflect.Value, writer *multipart.Writer) error { + return e.encodeMapEntries(key, value, writer) + } +} diff --git a/packages/tui/sdk/internal/apiform/form.go b/packages/tui/sdk/internal/apiform/form.go new file mode 100644 index 00000000..5445116e --- /dev/null +++ b/packages/tui/sdk/internal/apiform/form.go @@ -0,0 +1,5 @@ +package apiform + +type Marshaler interface { + MarshalMultipart() ([]byte, string, error) +} diff --git a/packages/tui/sdk/internal/apiform/form_test.go b/packages/tui/sdk/internal/apiform/form_test.go new file mode 100644 index 00000000..39d1460c --- /dev/null +++ b/packages/tui/sdk/internal/apiform/form_test.go @@ -0,0 +1,440 @@ +package apiform + +import ( + "bytes" + "mime/multipart" + "strings" + "testing" + "time" +) + +func P[T any](v T) *T { return &v } + +type Primitives struct { + A bool `form:"a"` + B int `form:"b"` + C uint `form:"c"` + D float64 `form:"d"` + E float32 `form:"e"` + F []int `form:"f"` +} + +type PrimitivePointers struct { + A *bool `form:"a"` + B *int `form:"b"` + C *uint `form:"c"` + D *float64 `form:"d"` + E *float32 `form:"e"` + F *[]int `form:"f"` +} + +type Slices struct { + Slice []Primitives `form:"slices"` +} + +type DateTime struct { + Date time.Time `form:"date" format:"date"` + DateTime time.Time `form:"date-time" format:"date-time"` +} + +type AdditionalProperties struct { + A bool `form:"a"` + Extras map[string]interface{} `form:"-,extras"` +} + +type TypedAdditionalProperties struct { + A bool `form:"a"` + Extras map[string]int `form:"-,extras"` +} + +type EmbeddedStructs struct { + AdditionalProperties + A *int `form:"number2"` + Extras map[string]interface{} `form:"-,extras"` +} + +type Recursive struct { + Name string `form:"name"` + Child *Recursive `form:"child"` +} + +type UnknownStruct struct { + Unknown interface{} `form:"unknown"` +} + +type UnionStruct struct { + Union Union `form:"union" format:"date"` +} + +type Union interface { + union() +} + +type UnionInteger int64 + +func (UnionInteger) union() {} + +type UnionStructA struct { + Type string `form:"type"` + A string `form:"a"` + B string `form:"b"` +} + +func (UnionStructA) union() {} + +type UnionStructB struct { + Type string `form:"type"` + A string `form:"a"` +} + +func (UnionStructB) union() {} + +type UnionTime time.Time + +func (UnionTime) union() {} + +type ReaderStruct struct { +} + +var tests = map[string]struct { + buf string + val interface{} +}{ + "map_string": { + `--xxx +Content-Disposition: form-data; name="foo" + +bar +--xxx-- +`, + map[string]string{"foo": "bar"}, + }, + + "map_interface": { + `--xxx +Content-Disposition: form-data; name="a" + +1 +--xxx +Content-Disposition: form-data; name="b" + +str +--xxx +Content-Disposition: form-data; name="c" + +false +--xxx-- +`, + map[string]interface{}{"a": float64(1), "b": "str", "c": false}, + }, + + "primitive_struct": { + `--xxx +Content-Disposition: form-data; name="a" + +false +--xxx +Content-Disposition: form-data; name="b" + +237628372683 +--xxx +Content-Disposition: form-data; name="c" + +654 +--xxx +Content-Disposition: form-data; name="d" + +9999.43 +--xxx +Content-Disposition: form-data; name="e" + +43.76 +--xxx +Content-Disposition: form-data; name="f.0" + +1 +--xxx +Content-Disposition: form-data; name="f.1" + +2 +--xxx +Content-Disposition: form-data; name="f.2" + +3 +--xxx +Content-Disposition: form-data; name="f.3" + +4 +--xxx-- +`, + Primitives{A: false, B: 237628372683, C: uint(654), D: 9999.43, E: 43.76, F: []int{1, 2, 3, 4}}, + }, + + "slices": { + `--xxx +Content-Disposition: form-data; name="slices.0.a" + +false +--xxx +Content-Disposition: form-data; name="slices.0.b" + +237628372683 +--xxx +Content-Disposition: form-data; name="slices.0.c" + +654 +--xxx +Content-Disposition: form-data; name="slices.0.d" + +9999.43 +--xxx +Content-Disposition: form-data; name="slices.0.e" + +43.76 +--xxx +Content-Disposition: form-data; name="slices.0.f.0" + +1 +--xxx +Content-Disposition: form-data; name="slices.0.f.1" + +2 +--xxx +Content-Disposition: form-data; name="slices.0.f.2" + +3 +--xxx +Content-Disposition: form-data; name="slices.0.f.3" + +4 +--xxx-- +`, + Slices{ + Slice: []Primitives{{A: false, B: 237628372683, C: uint(654), D: 9999.43, E: 43.76, F: []int{1, 2, 3, 4}}}, + }, + }, + + "primitive_pointer_struct": { + `--xxx +Content-Disposition: form-data; name="a" + +false +--xxx +Content-Disposition: form-data; name="b" + +237628372683 +--xxx +Content-Disposition: form-data; name="c" + +654 +--xxx +Content-Disposition: form-data; name="d" + +9999.43 +--xxx +Content-Disposition: form-data; name="e" + +43.76 +--xxx +Content-Disposition: form-data; name="f.0" + +1 +--xxx +Content-Disposition: form-data; name="f.1" + +2 +--xxx +Content-Disposition: form-data; name="f.2" + +3 +--xxx +Content-Disposition: form-data; name="f.3" + +4 +--xxx +Content-Disposition: form-data; name="f.4" + +5 +--xxx-- +`, + PrimitivePointers{ + A: P(false), + B: P(237628372683), + C: P(uint(654)), + D: P(9999.43), + E: P(float32(43.76)), + F: &[]int{1, 2, 3, 4, 5}, + }, + }, + + "datetime_struct": { + `--xxx +Content-Disposition: form-data; name="date" + +2006-01-02 +--xxx +Content-Disposition: form-data; name="date-time" + +2006-01-02T15:04:05Z +--xxx-- +`, + DateTime{ + Date: time.Date(2006, time.January, 2, 0, 0, 0, 0, time.UTC), + DateTime: time.Date(2006, time.January, 2, 15, 4, 5, 0, time.UTC), + }, + }, + + "additional_properties": { + `--xxx +Content-Disposition: form-data; name="a" + +true +--xxx +Content-Disposition: form-data; name="bar" + +value +--xxx +Content-Disposition: form-data; name="foo" + +true +--xxx-- +`, + AdditionalProperties{ + A: true, + Extras: map[string]interface{}{ + "bar": "value", + "foo": true, + }, + }, + }, + + "recursive_struct": { + `--xxx +Content-Disposition: form-data; name="child.name" + +Alex +--xxx +Content-Disposition: form-data; name="name" + +Robert +--xxx-- +`, + Recursive{Name: "Robert", Child: &Recursive{Name: "Alex"}}, + }, + + "unknown_struct_number": { + `--xxx +Content-Disposition: form-data; name="unknown" + +12 +--xxx-- +`, + UnknownStruct{ + Unknown: 12., + }, + }, + + "unknown_struct_map": { + `--xxx +Content-Disposition: form-data; name="unknown.foo" + +bar +--xxx-- +`, + UnknownStruct{ + Unknown: map[string]interface{}{ + "foo": "bar", + }, + }, + }, + + "union_integer": { + `--xxx +Content-Disposition: form-data; name="union" + +12 +--xxx-- +`, + UnionStruct{ + Union: UnionInteger(12), + }, + }, + + "union_struct_discriminated_a": { + `--xxx +Content-Disposition: form-data; name="union.a" + +foo +--xxx +Content-Disposition: form-data; name="union.b" + +bar +--xxx +Content-Disposition: form-data; name="union.type" + +typeA +--xxx-- +`, + + UnionStruct{ + Union: UnionStructA{ + Type: "typeA", + A: "foo", + B: "bar", + }, + }, + }, + + "union_struct_discriminated_b": { + `--xxx +Content-Disposition: form-data; name="union.a" + +foo +--xxx +Content-Disposition: form-data; name="union.type" + +typeB +--xxx-- +`, + UnionStruct{ + Union: UnionStructB{ + Type: "typeB", + A: "foo", + }, + }, + }, + + "union_struct_time": { + `--xxx +Content-Disposition: form-data; name="union" + +2010-05-23 +--xxx-- +`, + UnionStruct{ + Union: UnionTime(time.Date(2010, 05, 23, 0, 0, 0, 0, time.UTC)), + }, + }, +} + +func TestEncode(t *testing.T) { + for name, test := range tests { + t.Run(name, func(t *testing.T) { + buf := bytes.NewBuffer(nil) + writer := multipart.NewWriter(buf) + writer.SetBoundary("xxx") + err := Marshal(test.val, writer) + if err != nil { + t.Errorf("serialization of %v failed with error %v", test.val, err) + } + err = writer.Close() + if err != nil { + t.Errorf("serialization of %v failed with error %v", test.val, err) + } + raw := buf.Bytes() + if string(raw) != strings.ReplaceAll(test.buf, "\n", "\r\n") { + t.Errorf("expected %+#v to serialize to '%s' but got '%s'", test.val, test.buf, string(raw)) + } + }) + } +} diff --git a/packages/tui/sdk/internal/apiform/tag.go b/packages/tui/sdk/internal/apiform/tag.go new file mode 100644 index 00000000..b22e054f --- /dev/null +++ b/packages/tui/sdk/internal/apiform/tag.go @@ -0,0 +1,48 @@ +package apiform + +import ( + "reflect" + "strings" +) + +const jsonStructTag = "json" +const formStructTag = "form" +const formatStructTag = "format" + +type parsedStructTag struct { + name string + required bool + extras bool + metadata bool +} + +func parseFormStructTag(field reflect.StructField) (tag parsedStructTag, ok bool) { + raw, ok := field.Tag.Lookup(formStructTag) + if !ok { + raw, ok = field.Tag.Lookup(jsonStructTag) + } + if !ok { + return + } + parts := strings.Split(raw, ",") + if len(parts) == 0 { + return tag, false + } + tag.name = parts[0] + for _, part := range parts[1:] { + switch part { + case "required": + tag.required = true + case "extras": + tag.extras = true + case "metadata": + tag.metadata = true + } + } + return +} + +func parseFormatStructTag(field reflect.StructField) (format string, ok bool) { + format, ok = field.Tag.Lookup(formatStructTag) + return +} diff --git a/packages/tui/sdk/internal/apijson/decoder.go b/packages/tui/sdk/internal/apijson/decoder.go new file mode 100644 index 00000000..68b7ed6b --- /dev/null +++ b/packages/tui/sdk/internal/apijson/decoder.go @@ -0,0 +1,670 @@ +package apijson + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strconv" + "sync" + "time" + "unsafe" + + "github.com/tidwall/gjson" +) + +// decoders is a synchronized map with roughly the following type: +// map[reflect.Type]decoderFunc +var decoders sync.Map + +// Unmarshal is similar to [encoding/json.Unmarshal] and parses the JSON-encoded +// data and stores it in the given pointer. +func Unmarshal(raw []byte, to any) error { + d := &decoderBuilder{dateFormat: time.RFC3339} + return d.unmarshal(raw, to) +} + +// UnmarshalRoot is like Unmarshal, but doesn't try to call MarshalJSON on the +// root element. Useful if a struct's UnmarshalJSON is overrode to use the +// behavior of this encoder versus the standard library. +func UnmarshalRoot(raw []byte, to any) error { + d := &decoderBuilder{dateFormat: time.RFC3339, root: true} + return d.unmarshal(raw, to) +} + +// decoderBuilder contains the 'compile-time' state of the decoder. +type decoderBuilder struct { + // Whether or not this is the first element and called by [UnmarshalRoot], see + // the documentation there to see why this is necessary. + root bool + // The dateFormat (a format string for [time.Format]) which is chosen by the + // last struct tag that was seen. + dateFormat string +} + +// decoderState contains the 'run-time' state of the decoder. +type decoderState struct { + strict bool + exactness exactness +} + +// Exactness refers to how close to the type the result was if deserialization +// was successful. This is useful in deserializing unions, where you want to try +// each entry, first with strict, then with looser validation, without actually +// having to do a lot of redundant work by marshalling twice (or maybe even more +// times). +type exactness int8 + +const ( + // Some values had to fudged a bit, for example by converting a string to an + // int, or an enum with extra values. + loose exactness = iota + // There are some extra arguments, but other wise it matches the union. + extras + // Exactly right. + exact +) + +type decoderFunc func(node gjson.Result, value reflect.Value, state *decoderState) error + +type decoderField struct { + tag parsedStructTag + fn decoderFunc + idx []int + goname string +} + +type decoderEntry struct { + reflect.Type + dateFormat string + root bool +} + +func (d *decoderBuilder) unmarshal(raw []byte, to any) error { + value := reflect.ValueOf(to).Elem() + result := gjson.ParseBytes(raw) + if !value.IsValid() { + return fmt.Errorf("apijson: cannot marshal into invalid value") + } + return d.typeDecoder(value.Type())(result, value, &decoderState{strict: false, exactness: exact}) +} + +func (d *decoderBuilder) typeDecoder(t reflect.Type) decoderFunc { + entry := decoderEntry{ + Type: t, + dateFormat: d.dateFormat, + root: d.root, + } + + if fi, ok := decoders.Load(entry); ok { + return fi.(decoderFunc) + } + + // To deal with recursive types, populate the map with an + // indirect func before we build it. This type waits on the + // real func (f) to be ready and then calls it. This indirect + // func is only used for recursive types. + var ( + wg sync.WaitGroup + f decoderFunc + ) + wg.Add(1) + fi, loaded := decoders.LoadOrStore(entry, decoderFunc(func(node gjson.Result, v reflect.Value, state *decoderState) error { + wg.Wait() + return f(node, v, state) + })) + if loaded { + return fi.(decoderFunc) + } + + // Compute the real decoder and replace the indirect func with it. + f = d.newTypeDecoder(t) + wg.Done() + decoders.Store(entry, f) + return f +} + +func indirectUnmarshalerDecoder(n gjson.Result, v reflect.Value, state *decoderState) error { + return v.Addr().Interface().(json.Unmarshaler).UnmarshalJSON([]byte(n.Raw)) +} + +func unmarshalerDecoder(n gjson.Result, v reflect.Value, state *decoderState) error { + if v.Kind() == reflect.Pointer && v.CanSet() { + v.Set(reflect.New(v.Type().Elem())) + } + return v.Interface().(json.Unmarshaler).UnmarshalJSON([]byte(n.Raw)) +} + +func (d *decoderBuilder) newTypeDecoder(t reflect.Type) decoderFunc { + if t.ConvertibleTo(reflect.TypeOf(time.Time{})) { + return d.newTimeTypeDecoder(t) + } + if !d.root && t.Implements(reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()) { + return unmarshalerDecoder + } + if !d.root && reflect.PointerTo(t).Implements(reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()) { + if _, ok := unionVariants[t]; !ok { + return indirectUnmarshalerDecoder + } + } + d.root = false + + if _, ok := unionRegistry[t]; ok { + return d.newUnionDecoder(t) + } + + switch t.Kind() { + case reflect.Pointer: + inner := t.Elem() + innerDecoder := d.typeDecoder(inner) + + return func(n gjson.Result, v reflect.Value, state *decoderState) error { + if !v.IsValid() { + return fmt.Errorf("apijson: unexpected invalid reflection value %+#v", v) + } + + newValue := reflect.New(inner).Elem() + err := innerDecoder(n, newValue, state) + if err != nil { + return err + } + + v.Set(newValue.Addr()) + return nil + } + case reflect.Struct: + return d.newStructTypeDecoder(t) + case reflect.Array: + fallthrough + case reflect.Slice: + return d.newArrayTypeDecoder(t) + case reflect.Map: + return d.newMapDecoder(t) + case reflect.Interface: + return func(node gjson.Result, value reflect.Value, state *decoderState) error { + if !value.IsValid() { + return fmt.Errorf("apijson: unexpected invalid value %+#v", value) + } + if node.Value() != nil && value.CanSet() { + value.Set(reflect.ValueOf(node.Value())) + } + return nil + } + default: + return d.newPrimitiveTypeDecoder(t) + } +} + +// newUnionDecoder returns a decoderFunc that deserializes into a union using an +// algorithm roughly similar to Pydantic's [smart algorithm]. +// +// Conceptually this is equivalent to choosing the best schema based on how 'exact' +// the deserialization is for each of the schemas. +// +// If there is a tie in the level of exactness, then the tie is broken +// left-to-right. +// +// [smart algorithm]: https://docs.pydantic.dev/latest/concepts/unions/#smart-mode +func (d *decoderBuilder) newUnionDecoder(t reflect.Type) decoderFunc { + unionEntry, ok := unionRegistry[t] + if !ok { + panic("apijson: couldn't find union of type " + t.String() + " in union registry") + } + decoders := []decoderFunc{} + for _, variant := range unionEntry.variants { + decoder := d.typeDecoder(variant.Type) + decoders = append(decoders, decoder) + } + return func(n gjson.Result, v reflect.Value, state *decoderState) error { + // If there is a discriminator match, circumvent the exactness logic entirely + for idx, variant := range unionEntry.variants { + decoder := decoders[idx] + if variant.TypeFilter != n.Type { + continue + } + + if len(unionEntry.discriminatorKey) != 0 { + discriminatorValue := n.Get(unionEntry.discriminatorKey).Value() + if discriminatorValue == variant.DiscriminatorValue { + inner := reflect.New(variant.Type).Elem() + err := decoder(n, inner, state) + v.Set(inner) + return err + } + } + } + + // Set bestExactness to worse than loose + bestExactness := loose - 1 + for idx, variant := range unionEntry.variants { + decoder := decoders[idx] + if variant.TypeFilter != n.Type { + continue + } + sub := decoderState{strict: state.strict, exactness: exact} + inner := reflect.New(variant.Type).Elem() + err := decoder(n, inner, &sub) + if err != nil { + continue + } + if sub.exactness == exact { + v.Set(inner) + return nil + } + if sub.exactness > bestExactness { + v.Set(inner) + bestExactness = sub.exactness + } + } + + if bestExactness < loose { + return errors.New("apijson: was not able to coerce type as union") + } + + if guardStrict(state, bestExactness != exact) { + return errors.New("apijson: was not able to coerce type as union strictly") + } + + return nil + } +} + +func (d *decoderBuilder) newMapDecoder(t reflect.Type) decoderFunc { + keyType := t.Key() + itemType := t.Elem() + itemDecoder := d.typeDecoder(itemType) + + return func(node gjson.Result, value reflect.Value, state *decoderState) (err error) { + mapValue := reflect.MakeMapWithSize(t, len(node.Map())) + + node.ForEach(func(key, value gjson.Result) bool { + // It's fine for us to just use `ValueOf` here because the key types will + // always be primitive types so we don't need to decode it using the standard pattern + keyValue := reflect.ValueOf(key.Value()) + if !keyValue.IsValid() { + if err == nil { + err = fmt.Errorf("apijson: received invalid key type %v", keyValue.String()) + } + return false + } + if keyValue.Type() != keyType { + if err == nil { + err = fmt.Errorf("apijson: expected key type %v but got %v", keyType, keyValue.Type()) + } + return false + } + + itemValue := reflect.New(itemType).Elem() + itemerr := itemDecoder(value, itemValue, state) + if itemerr != nil { + if err == nil { + err = itemerr + } + return false + } + + mapValue.SetMapIndex(keyValue, itemValue) + return true + }) + + if err != nil { + return err + } + value.Set(mapValue) + return nil + } +} + +func (d *decoderBuilder) newArrayTypeDecoder(t reflect.Type) decoderFunc { + itemDecoder := d.typeDecoder(t.Elem()) + + return func(node gjson.Result, value reflect.Value, state *decoderState) (err error) { + if !node.IsArray() { + return fmt.Errorf("apijson: could not deserialize to an array") + } + + arrayNode := node.Array() + + arrayValue := reflect.MakeSlice(reflect.SliceOf(t.Elem()), len(arrayNode), len(arrayNode)) + for i, itemNode := range arrayNode { + err = itemDecoder(itemNode, arrayValue.Index(i), state) + if err != nil { + return err + } + } + + value.Set(arrayValue) + return nil + } +} + +func (d *decoderBuilder) newStructTypeDecoder(t reflect.Type) decoderFunc { + // map of json field name to struct field decoders + decoderFields := map[string]decoderField{} + anonymousDecoders := []decoderField{} + extraDecoder := (*decoderField)(nil) + inlineDecoder := (*decoderField)(nil) + + for i := 0; i < t.NumField(); i++ { + idx := []int{i} + field := t.FieldByIndex(idx) + if !field.IsExported() { + continue + } + // If this is an embedded struct, traverse one level deeper to extract + // the fields and get their encoders as well. + if field.Anonymous { + anonymousDecoders = append(anonymousDecoders, decoderField{ + fn: d.typeDecoder(field.Type), + idx: idx[:], + }) + continue + } + // If json tag is not present, then we skip, which is intentionally + // different behavior from the stdlib. + ptag, ok := parseJSONStructTag(field) + if !ok { + continue + } + // We only want to support unexported fields if they're tagged with + // `extras` because that field shouldn't be part of the public API. + if ptag.extras { + extraDecoder = &decoderField{ptag, d.typeDecoder(field.Type.Elem()), idx, field.Name} + continue + } + if ptag.inline { + inlineDecoder = &decoderField{ptag, d.typeDecoder(field.Type), idx, field.Name} + continue + } + if ptag.metadata { + continue + } + + oldFormat := d.dateFormat + dateFormat, ok := parseFormatStructTag(field) + if ok { + switch dateFormat { + case "date-time": + d.dateFormat = time.RFC3339 + case "date": + d.dateFormat = "2006-01-02" + } + } + decoderFields[ptag.name] = decoderField{ptag, d.typeDecoder(field.Type), idx, field.Name} + d.dateFormat = oldFormat + } + + return func(node gjson.Result, value reflect.Value, state *decoderState) (err error) { + if field := value.FieldByName("JSON"); field.IsValid() { + if raw := field.FieldByName("raw"); raw.IsValid() { + setUnexportedField(raw, node.Raw) + } + } + + for _, decoder := range anonymousDecoders { + // ignore errors + decoder.fn(node, value.FieldByIndex(decoder.idx), state) + } + + if inlineDecoder != nil { + var meta Field + dest := value.FieldByIndex(inlineDecoder.idx) + isValid := false + if dest.IsValid() && node.Type != gjson.Null { + err = inlineDecoder.fn(node, dest, state) + if err == nil { + isValid = true + } + } + + if node.Type == gjson.Null { + meta = Field{ + raw: node.Raw, + status: null, + } + } else if !isValid { + meta = Field{ + raw: node.Raw, + status: invalid, + } + } else if isValid { + meta = Field{ + raw: node.Raw, + status: valid, + } + } + if metadata := getSubField(value, inlineDecoder.idx, inlineDecoder.goname); metadata.IsValid() { + metadata.Set(reflect.ValueOf(meta)) + } + return err + } + + typedExtraType := reflect.Type(nil) + typedExtraFields := reflect.Value{} + if extraDecoder != nil { + typedExtraType = value.FieldByIndex(extraDecoder.idx).Type() + typedExtraFields = reflect.MakeMap(typedExtraType) + } + untypedExtraFields := map[string]Field{} + + for fieldName, itemNode := range node.Map() { + df, explicit := decoderFields[fieldName] + var ( + dest reflect.Value + fn decoderFunc + meta Field + ) + if explicit { + fn = df.fn + dest = value.FieldByIndex(df.idx) + } + if !explicit && extraDecoder != nil { + dest = reflect.New(typedExtraType.Elem()).Elem() + fn = extraDecoder.fn + } + + isValid := false + if dest.IsValid() && itemNode.Type != gjson.Null { + err = fn(itemNode, dest, state) + if err == nil { + isValid = true + } + } + + if itemNode.Type == gjson.Null { + meta = Field{ + raw: itemNode.Raw, + status: null, + } + } else if !isValid { + meta = Field{ + raw: itemNode.Raw, + status: invalid, + } + } else if isValid { + meta = Field{ + raw: itemNode.Raw, + status: valid, + } + } + + if explicit { + if metadata := getSubField(value, df.idx, df.goname); metadata.IsValid() { + metadata.Set(reflect.ValueOf(meta)) + } + } + if !explicit { + untypedExtraFields[fieldName] = meta + } + if !explicit && extraDecoder != nil { + typedExtraFields.SetMapIndex(reflect.ValueOf(fieldName), dest) + } + } + + if extraDecoder != nil && typedExtraFields.Len() > 0 { + value.FieldByIndex(extraDecoder.idx).Set(typedExtraFields) + } + + // Set exactness to 'extras' if there are untyped, extra fields. + if len(untypedExtraFields) > 0 && state.exactness > extras { + state.exactness = extras + } + + if metadata := getSubField(value, []int{-1}, "ExtraFields"); metadata.IsValid() && len(untypedExtraFields) > 0 { + metadata.Set(reflect.ValueOf(untypedExtraFields)) + } + return nil + } +} + +func (d *decoderBuilder) newPrimitiveTypeDecoder(t reflect.Type) decoderFunc { + switch t.Kind() { + case reflect.String: + return func(n gjson.Result, v reflect.Value, state *decoderState) error { + v.SetString(n.String()) + if guardStrict(state, n.Type != gjson.String) { + return fmt.Errorf("apijson: failed to parse string strictly") + } + // Everything that is not an object can be loosely stringified. + if n.Type == gjson.JSON { + return fmt.Errorf("apijson: failed to parse string") + } + if guardUnknown(state, v) { + return fmt.Errorf("apijson: failed string enum validation") + } + return nil + } + case reflect.Bool: + return func(n gjson.Result, v reflect.Value, state *decoderState) error { + v.SetBool(n.Bool()) + if guardStrict(state, n.Type != gjson.True && n.Type != gjson.False) { + return fmt.Errorf("apijson: failed to parse bool strictly") + } + // Numbers and strings that are either 'true' or 'false' can be loosely + // deserialized as bool. + if n.Type == gjson.String && (n.Raw != "true" && n.Raw != "false") || n.Type == gjson.JSON { + return fmt.Errorf("apijson: failed to parse bool") + } + if guardUnknown(state, v) { + return fmt.Errorf("apijson: failed bool enum validation") + } + return nil + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return func(n gjson.Result, v reflect.Value, state *decoderState) error { + v.SetInt(n.Int()) + if guardStrict(state, n.Type != gjson.Number || n.Num != float64(int(n.Num))) { + return fmt.Errorf("apijson: failed to parse int strictly") + } + // Numbers, booleans, and strings that maybe look like numbers can be + // loosely deserialized as numbers. + if n.Type == gjson.JSON || (n.Type == gjson.String && !canParseAsNumber(n.Str)) { + return fmt.Errorf("apijson: failed to parse int") + } + if guardUnknown(state, v) { + return fmt.Errorf("apijson: failed int enum validation") + } + return nil + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return func(n gjson.Result, v reflect.Value, state *decoderState) error { + v.SetUint(n.Uint()) + if guardStrict(state, n.Type != gjson.Number || n.Num != float64(int(n.Num)) || n.Num < 0) { + return fmt.Errorf("apijson: failed to parse uint strictly") + } + // Numbers, booleans, and strings that maybe look like numbers can be + // loosely deserialized as uint. + if n.Type == gjson.JSON || (n.Type == gjson.String && !canParseAsNumber(n.Str)) { + return fmt.Errorf("apijson: failed to parse uint") + } + if guardUnknown(state, v) { + return fmt.Errorf("apijson: failed uint enum validation") + } + return nil + } + case reflect.Float32, reflect.Float64: + return func(n gjson.Result, v reflect.Value, state *decoderState) error { + v.SetFloat(n.Float()) + if guardStrict(state, n.Type != gjson.Number) { + return fmt.Errorf("apijson: failed to parse float strictly") + } + // Numbers, booleans, and strings that maybe look like numbers can be + // loosely deserialized as floats. + if n.Type == gjson.JSON || (n.Type == gjson.String && !canParseAsNumber(n.Str)) { + return fmt.Errorf("apijson: failed to parse float") + } + if guardUnknown(state, v) { + return fmt.Errorf("apijson: failed float enum validation") + } + return nil + } + default: + return func(node gjson.Result, v reflect.Value, state *decoderState) error { + return fmt.Errorf("unknown type received at primitive decoder: %s", t.String()) + } + } +} + +func (d *decoderBuilder) newTimeTypeDecoder(t reflect.Type) decoderFunc { + format := d.dateFormat + return func(n gjson.Result, v reflect.Value, state *decoderState) error { + parsed, err := time.Parse(format, n.Str) + if err == nil { + v.Set(reflect.ValueOf(parsed).Convert(t)) + return nil + } + + if guardStrict(state, true) { + return err + } + + layouts := []string{ + "2006-01-02", + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05Z0700", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05Z07:00", + "2006-01-02 15:04:05Z0700", + "2006-01-02 15:04:05", + } + + for _, layout := range layouts { + parsed, err := time.Parse(layout, n.Str) + if err == nil { + v.Set(reflect.ValueOf(parsed).Convert(t)) + return nil + } + } + + return fmt.Errorf("unable to leniently parse date-time string: %s", n.Str) + } +} + +func setUnexportedField(field reflect.Value, value interface{}) { + reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value)) +} + +func guardStrict(state *decoderState, cond bool) bool { + if !cond { + return false + } + + if state.strict { + return true + } + + state.exactness = loose + return false +} + +func canParseAsNumber(str string) bool { + _, err := strconv.ParseFloat(str, 64) + return err == nil +} + +func guardUnknown(state *decoderState, v reflect.Value) bool { + if have, ok := v.Interface().(interface{ IsKnown() bool }); guardStrict(state, ok && !have.IsKnown()) { + return true + } + return false +} diff --git a/packages/tui/sdk/internal/apijson/encoder.go b/packages/tui/sdk/internal/apijson/encoder.go new file mode 100644 index 00000000..0e5f89e1 --- /dev/null +++ b/packages/tui/sdk/internal/apijson/encoder.go @@ -0,0 +1,398 @@ +package apijson + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/tidwall/sjson" + + "github.com/sst/opencode-sdk-go/internal/param" +) + +var encoders sync.Map // map[encoderEntry]encoderFunc + +func Marshal(value interface{}) ([]byte, error) { + e := &encoder{dateFormat: time.RFC3339} + return e.marshal(value) +} + +func MarshalRoot(value interface{}) ([]byte, error) { + e := &encoder{root: true, dateFormat: time.RFC3339} + return e.marshal(value) +} + +type encoder struct { + dateFormat string + root bool +} + +type encoderFunc func(value reflect.Value) ([]byte, error) + +type encoderField struct { + tag parsedStructTag + fn encoderFunc + idx []int +} + +type encoderEntry struct { + reflect.Type + dateFormat string + root bool +} + +func (e *encoder) marshal(value interface{}) ([]byte, error) { + val := reflect.ValueOf(value) + if !val.IsValid() { + return nil, nil + } + typ := val.Type() + enc := e.typeEncoder(typ) + return enc(val) +} + +func (e *encoder) typeEncoder(t reflect.Type) encoderFunc { + entry := encoderEntry{ + Type: t, + dateFormat: e.dateFormat, + root: e.root, + } + + if fi, ok := encoders.Load(entry); ok { + return fi.(encoderFunc) + } + + // To deal with recursive types, populate the map with an + // indirect func before we build it. This type waits on the + // real func (f) to be ready and then calls it. This indirect + // func is only used for recursive types. + var ( + wg sync.WaitGroup + f encoderFunc + ) + wg.Add(1) + fi, loaded := encoders.LoadOrStore(entry, encoderFunc(func(v reflect.Value) ([]byte, error) { + wg.Wait() + return f(v) + })) + if loaded { + return fi.(encoderFunc) + } + + // Compute the real encoder and replace the indirect func with it. + f = e.newTypeEncoder(t) + wg.Done() + encoders.Store(entry, f) + return f +} + +func marshalerEncoder(v reflect.Value) ([]byte, error) { + return v.Interface().(json.Marshaler).MarshalJSON() +} + +func indirectMarshalerEncoder(v reflect.Value) ([]byte, error) { + return v.Addr().Interface().(json.Marshaler).MarshalJSON() +} + +func (e *encoder) newTypeEncoder(t reflect.Type) encoderFunc { + if t.ConvertibleTo(reflect.TypeOf(time.Time{})) { + return e.newTimeTypeEncoder() + } + if !e.root && t.Implements(reflect.TypeOf((*json.Marshaler)(nil)).Elem()) { + return marshalerEncoder + } + if !e.root && reflect.PointerTo(t).Implements(reflect.TypeOf((*json.Marshaler)(nil)).Elem()) { + return indirectMarshalerEncoder + } + e.root = false + switch t.Kind() { + case reflect.Pointer: + inner := t.Elem() + + innerEncoder := e.typeEncoder(inner) + return func(v reflect.Value) ([]byte, error) { + if !v.IsValid() || v.IsNil() { + return nil, nil + } + return innerEncoder(v.Elem()) + } + case reflect.Struct: + return e.newStructTypeEncoder(t) + case reflect.Array: + fallthrough + case reflect.Slice: + return e.newArrayTypeEncoder(t) + case reflect.Map: + return e.newMapEncoder(t) + case reflect.Interface: + return e.newInterfaceEncoder() + default: + return e.newPrimitiveTypeEncoder(t) + } +} + +func (e *encoder) newPrimitiveTypeEncoder(t reflect.Type) encoderFunc { + switch t.Kind() { + // Note that we could use `gjson` to encode these types but it would complicate our + // code more and this current code shouldn't cause any issues + case reflect.String: + return func(v reflect.Value) ([]byte, error) { + return json.Marshal(v.Interface()) + } + case reflect.Bool: + return func(v reflect.Value) ([]byte, error) { + if v.Bool() { + return []byte("true"), nil + } + return []byte("false"), nil + } + case reflect.Int, reflect.Int16, reflect.Int32, reflect.Int64: + return func(v reflect.Value) ([]byte, error) { + return []byte(strconv.FormatInt(v.Int(), 10)), nil + } + case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return func(v reflect.Value) ([]byte, error) { + return []byte(strconv.FormatUint(v.Uint(), 10)), nil + } + case reflect.Float32: + return func(v reflect.Value) ([]byte, error) { + return []byte(strconv.FormatFloat(v.Float(), 'f', -1, 32)), nil + } + case reflect.Float64: + return func(v reflect.Value) ([]byte, error) { + return []byte(strconv.FormatFloat(v.Float(), 'f', -1, 64)), nil + } + default: + return func(v reflect.Value) ([]byte, error) { + return nil, fmt.Errorf("unknown type received at primitive encoder: %s", t.String()) + } + } +} + +func (e *encoder) newArrayTypeEncoder(t reflect.Type) encoderFunc { + itemEncoder := e.typeEncoder(t.Elem()) + + return func(value reflect.Value) ([]byte, error) { + json := []byte("[]") + for i := 0; i < value.Len(); i++ { + var value, err = itemEncoder(value.Index(i)) + if err != nil { + return nil, err + } + if value == nil { + // Assume that empty items should be inserted as `null` so that the output array + // will be the same length as the input array + value = []byte("null") + } + + json, err = sjson.SetRawBytes(json, "-1", value) + if err != nil { + return nil, err + } + } + + return json, nil + } +} + +func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc { + if t.Implements(reflect.TypeOf((*param.FieldLike)(nil)).Elem()) { + return e.newFieldTypeEncoder(t) + } + + encoderFields := []encoderField{} + extraEncoder := (*encoderField)(nil) + + // This helper allows us to recursively collect field encoders into a flat + // array. The parameter `index` keeps track of the access patterns necessary + // to get to some field. + var collectEncoderFields func(r reflect.Type, index []int) + collectEncoderFields = func(r reflect.Type, index []int) { + for i := 0; i < r.NumField(); i++ { + idx := append(index, i) + field := t.FieldByIndex(idx) + if !field.IsExported() { + continue + } + // If this is an embedded struct, traverse one level deeper to extract + // the field and get their encoders as well. + if field.Anonymous { + collectEncoderFields(field.Type, idx) + continue + } + // If json tag is not present, then we skip, which is intentionally + // different behavior from the stdlib. + ptag, ok := parseJSONStructTag(field) + if !ok { + continue + } + // We only want to support unexported field if they're tagged with + // `extras` because that field shouldn't be part of the public API. We + // also want to only keep the top level extras + if ptag.extras && len(index) == 0 { + extraEncoder = &encoderField{ptag, e.typeEncoder(field.Type.Elem()), idx} + continue + } + if ptag.name == "-" { + continue + } + + dateFormat, ok := parseFormatStructTag(field) + oldFormat := e.dateFormat + if ok { + switch dateFormat { + case "date-time": + e.dateFormat = time.RFC3339 + case "date": + e.dateFormat = "2006-01-02" + } + } + encoderFields = append(encoderFields, encoderField{ptag, e.typeEncoder(field.Type), idx}) + e.dateFormat = oldFormat + } + } + collectEncoderFields(t, []int{}) + + // Ensure deterministic output by sorting by lexicographic order + sort.Slice(encoderFields, func(i, j int) bool { + return encoderFields[i].tag.name < encoderFields[j].tag.name + }) + + return func(value reflect.Value) (json []byte, err error) { + json = []byte("{}") + + for _, ef := range encoderFields { + field := value.FieldByIndex(ef.idx) + encoded, err := ef.fn(field) + if err != nil { + return nil, err + } + if encoded == nil { + continue + } + json, err = sjson.SetRawBytes(json, ef.tag.name, encoded) + if err != nil { + return nil, err + } + } + + if extraEncoder != nil { + json, err = e.encodeMapEntries(json, value.FieldByIndex(extraEncoder.idx)) + if err != nil { + return nil, err + } + } + return + } +} + +func (e *encoder) newFieldTypeEncoder(t reflect.Type) encoderFunc { + f, _ := t.FieldByName("Value") + enc := e.typeEncoder(f.Type) + + return func(value reflect.Value) (json []byte, err error) { + present := value.FieldByName("Present") + if !present.Bool() { + return nil, nil + } + null := value.FieldByName("Null") + if null.Bool() { + return []byte("null"), nil + } + raw := value.FieldByName("Raw") + if !raw.IsNil() { + return e.typeEncoder(raw.Type())(raw) + } + return enc(value.FieldByName("Value")) + } +} + +func (e *encoder) newTimeTypeEncoder() encoderFunc { + format := e.dateFormat + return func(value reflect.Value) (json []byte, err error) { + return []byte(`"` + value.Convert(reflect.TypeOf(time.Time{})).Interface().(time.Time).Format(format) + `"`), nil + } +} + +func (e encoder) newInterfaceEncoder() encoderFunc { + return func(value reflect.Value) ([]byte, error) { + value = value.Elem() + if !value.IsValid() { + return nil, nil + } + return e.typeEncoder(value.Type())(value) + } +} + +// Given a []byte of json (may either be an empty object or an object that already contains entries) +// encode all of the entries in the map to the json byte array. +func (e *encoder) encodeMapEntries(json []byte, v reflect.Value) ([]byte, error) { + type mapPair struct { + key []byte + value reflect.Value + } + + pairs := []mapPair{} + keyEncoder := e.typeEncoder(v.Type().Key()) + + iter := v.MapRange() + for iter.Next() { + var encodedKeyString string + if iter.Key().Type().Kind() == reflect.String { + encodedKeyString = iter.Key().String() + } else { + var err error + encodedKeyBytes, err := keyEncoder(iter.Key()) + if err != nil { + return nil, err + } + encodedKeyString = string(encodedKeyBytes) + } + encodedKey := []byte(sjsonReplacer.Replace(encodedKeyString)) + pairs = append(pairs, mapPair{key: encodedKey, value: iter.Value()}) + } + + // Ensure deterministic output + sort.Slice(pairs, func(i, j int) bool { + return bytes.Compare(pairs[i].key, pairs[j].key) < 0 + }) + + elementEncoder := e.typeEncoder(v.Type().Elem()) + for _, p := range pairs { + encodedValue, err := elementEncoder(p.value) + if err != nil { + return nil, err + } + if len(encodedValue) == 0 { + continue + } + json, err = sjson.SetRawBytes(json, string(p.key), encodedValue) + if err != nil { + return nil, err + } + } + + return json, nil +} + +func (e *encoder) newMapEncoder(t reflect.Type) encoderFunc { + return func(value reflect.Value) ([]byte, error) { + json := []byte("{}") + var err error + json, err = e.encodeMapEntries(json, value) + if err != nil { + return nil, err + } + return json, nil + } +} + +// If we want to set a literal key value into JSON using sjson, we need to make sure it doesn't have +// special characters that sjson interprets as a path. +var sjsonReplacer *strings.Replacer = strings.NewReplacer(".", "\\.", ":", "\\:", "*", "\\*") diff --git a/packages/tui/sdk/internal/apijson/field.go b/packages/tui/sdk/internal/apijson/field.go new file mode 100644 index 00000000..3ef207c5 --- /dev/null +++ b/packages/tui/sdk/internal/apijson/field.go @@ -0,0 +1,41 @@ +package apijson + +import "reflect" + +type status uint8 + +const ( + missing status = iota + null + invalid + valid +) + +type Field struct { + raw string + status status +} + +// Returns true if the field is explicitly `null` _or_ if it is not present at all (ie, missing). +// To check if the field's key is present in the JSON with an explicit null value, +// you must check `f.IsNull() && !f.IsMissing()`. +func (j Field) IsNull() bool { return j.status <= null } +func (j Field) IsMissing() bool { return j.status == missing } +func (j Field) IsInvalid() bool { return j.status == invalid } +func (j Field) Raw() string { return j.raw } + +func getSubField(root reflect.Value, index []int, name string) reflect.Value { + strct := root.FieldByIndex(index[:len(index)-1]) + if !strct.IsValid() { + panic("couldn't find encapsulating struct for field " + name) + } + meta := strct.FieldByName("JSON") + if !meta.IsValid() { + return reflect.Value{} + } + field := meta.FieldByName(name) + if !field.IsValid() { + return reflect.Value{} + } + return field +} diff --git a/packages/tui/sdk/internal/apijson/field_test.go b/packages/tui/sdk/internal/apijson/field_test.go new file mode 100644 index 00000000..2e170c76 --- /dev/null +++ b/packages/tui/sdk/internal/apijson/field_test.go @@ -0,0 +1,66 @@ +package apijson + +import ( + "testing" + "time" + + "github.com/sst/opencode-sdk-go/internal/param" +) + +type Struct struct { + A string `json:"a"` + B int64 `json:"b"` +} + +type FieldStruct struct { + A param.Field[string] `json:"a"` + B param.Field[int64] `json:"b"` + C param.Field[Struct] `json:"c"` + D param.Field[time.Time] `json:"d" format:"date"` + E param.Field[time.Time] `json:"e" format:"date-time"` + F param.Field[int64] `json:"f"` +} + +func TestFieldMarshal(t *testing.T) { + tests := map[string]struct { + value interface{} + expected string + }{ + "null_string": {param.Field[string]{Present: true, Null: true}, "null"}, + "null_int": {param.Field[int]{Present: true, Null: true}, "null"}, + "null_int64": {param.Field[int64]{Present: true, Null: true}, "null"}, + "null_struct": {param.Field[Struct]{Present: true, Null: true}, "null"}, + + "string": {param.Field[string]{Present: true, Value: "string"}, `"string"`}, + "int": {param.Field[int]{Present: true, Value: 123}, "123"}, + "int64": {param.Field[int64]{Present: true, Value: int64(123456789123456789)}, "123456789123456789"}, + "struct": {param.Field[Struct]{Present: true, Value: Struct{A: "yo", B: 123}}, `{"a":"yo","b":123}`}, + + "string_raw": {param.Field[int]{Present: true, Raw: "string"}, `"string"`}, + "int_raw": {param.Field[int]{Present: true, Raw: 123}, "123"}, + "int64_raw": {param.Field[int]{Present: true, Raw: int64(123456789123456789)}, "123456789123456789"}, + "struct_raw": {param.Field[int]{Present: true, Raw: Struct{A: "yo", B: 123}}, `{"a":"yo","b":123}`}, + + "param_struct": { + FieldStruct{ + A: param.Field[string]{Present: true, Value: "hello"}, + B: param.Field[int64]{Present: true, Value: int64(12)}, + D: param.Field[time.Time]{Present: true, Value: time.Date(2023, time.March, 18, 14, 47, 38, 0, time.UTC)}, + E: param.Field[time.Time]{Present: true, Value: time.Date(2023, time.March, 18, 14, 47, 38, 0, time.UTC)}, + }, + `{"a":"hello","b":12,"d":"2023-03-18","e":"2023-03-18T14:47:38Z"}`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + b, err := Marshal(test.value) + if err != nil { + t.Fatalf("didn't expect error %v", err) + } + if string(b) != test.expected { + t.Fatalf("expected %s, received %s", test.expected, string(b)) + } + }) + } +} diff --git a/packages/tui/sdk/internal/apijson/json_test.go b/packages/tui/sdk/internal/apijson/json_test.go new file mode 100644 index 00000000..e6563448 --- /dev/null +++ b/packages/tui/sdk/internal/apijson/json_test.go @@ -0,0 +1,617 @@ +package apijson + +import ( + "reflect" + "strings" + "testing" + "time" + + "github.com/tidwall/gjson" +) + +func P[T any](v T) *T { return &v } + +type Primitives struct { + A bool `json:"a"` + B int `json:"b"` + C uint `json:"c"` + D float64 `json:"d"` + E float32 `json:"e"` + F []int `json:"f"` +} + +type PrimitivePointers struct { + A *bool `json:"a"` + B *int `json:"b"` + C *uint `json:"c"` + D *float64 `json:"d"` + E *float32 `json:"e"` + F *[]int `json:"f"` +} + +type Slices struct { + Slice []Primitives `json:"slices"` +} + +type DateTime struct { + Date time.Time `json:"date" format:"date"` + DateTime time.Time `json:"date-time" format:"date-time"` +} + +type AdditionalProperties struct { + A bool `json:"a"` + ExtraFields map[string]interface{} `json:"-,extras"` +} + +type TypedAdditionalProperties struct { + A bool `json:"a"` + ExtraFields map[string]int `json:"-,extras"` +} + +type EmbeddedStruct struct { + A bool `json:"a"` + B string `json:"b"` + + JSON EmbeddedStructJSON +} + +type EmbeddedStructJSON struct { + A Field + B Field + ExtraFields map[string]Field + raw string +} + +type EmbeddedStructs struct { + EmbeddedStruct + A *int `json:"a"` + ExtraFields map[string]interface{} `json:"-,extras"` + + JSON EmbeddedStructsJSON +} + +type EmbeddedStructsJSON struct { + A Field + ExtraFields map[string]Field + raw string +} + +type Recursive struct { + Name string `json:"name"` + Child *Recursive `json:"child"` +} + +type JSONFieldStruct struct { + A bool `json:"a"` + B int64 `json:"b"` + C string `json:"c"` + D string `json:"d"` + ExtraFields map[string]int64 `json:"-,extras"` + JSON JSONFieldStructJSON `json:"-,metadata"` +} + +type JSONFieldStructJSON struct { + A Field + B Field + C Field + D Field + ExtraFields map[string]Field + raw string +} + +type UnknownStruct struct { + Unknown interface{} `json:"unknown"` +} + +type UnionStruct struct { + Union Union `json:"union" format:"date"` +} + +type Union interface { + union() +} + +type Inline struct { + InlineField Primitives `json:"-,inline"` + JSON InlineJSON `json:"-,metadata"` +} + +type InlineArray struct { + InlineField []string `json:"-,inline"` + JSON InlineJSON `json:"-,metadata"` +} + +type InlineJSON struct { + InlineField Field + raw string +} + +type UnionInteger int64 + +func (UnionInteger) union() {} + +type UnionStructA struct { + Type string `json:"type"` + A string `json:"a"` + B string `json:"b"` +} + +func (UnionStructA) union() {} + +type UnionStructB struct { + Type string `json:"type"` + A string `json:"a"` +} + +func (UnionStructB) union() {} + +type UnionTime time.Time + +func (UnionTime) union() {} + +func init() { + RegisterUnion(reflect.TypeOf((*Union)(nil)).Elem(), "type", + UnionVariant{ + TypeFilter: gjson.String, + Type: reflect.TypeOf(UnionTime{}), + }, + UnionVariant{ + TypeFilter: gjson.Number, + Type: reflect.TypeOf(UnionInteger(0)), + }, + UnionVariant{ + TypeFilter: gjson.JSON, + DiscriminatorValue: "typeA", + Type: reflect.TypeOf(UnionStructA{}), + }, + UnionVariant{ + TypeFilter: gjson.JSON, + DiscriminatorValue: "typeB", + Type: reflect.TypeOf(UnionStructB{}), + }, + ) +} + +type ComplexUnionStruct struct { + Union ComplexUnion `json:"union"` +} + +type ComplexUnion interface { + complexUnion() +} + +type ComplexUnionA struct { + Boo string `json:"boo"` + Foo bool `json:"foo"` +} + +func (ComplexUnionA) complexUnion() {} + +type ComplexUnionB struct { + Boo bool `json:"boo"` + Foo string `json:"foo"` +} + +func (ComplexUnionB) complexUnion() {} + +type ComplexUnionC struct { + Boo int64 `json:"boo"` +} + +func (ComplexUnionC) complexUnion() {} + +type ComplexUnionTypeA struct { + Baz int64 `json:"baz"` + Type TypeA `json:"type"` +} + +func (ComplexUnionTypeA) complexUnion() {} + +type TypeA string + +func (t TypeA) IsKnown() bool { + return t == "a" +} + +type ComplexUnionTypeB struct { + Baz int64 `json:"baz"` + Type TypeB `json:"type"` +} + +type TypeB string + +func (t TypeB) IsKnown() bool { + return t == "b" +} + +type UnmarshalStruct struct { + Foo string `json:"foo"` + prop bool `json:"-"` +} + +func (r *UnmarshalStruct) UnmarshalJSON(json []byte) error { + r.prop = true + return UnmarshalRoot(json, r) +} + +func (ComplexUnionTypeB) complexUnion() {} + +func init() { + RegisterUnion(reflect.TypeOf((*ComplexUnion)(nil)).Elem(), "", + UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(ComplexUnionA{}), + }, + UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(ComplexUnionB{}), + }, + UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(ComplexUnionC{}), + }, + UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(ComplexUnionTypeA{}), + }, + UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(ComplexUnionTypeB{}), + }, + ) +} + +type MarshallingUnionStruct struct { + Union MarshallingUnion +} + +func (r *MarshallingUnionStruct) UnmarshalJSON(data []byte) (err error) { + *r = MarshallingUnionStruct{} + err = UnmarshalRoot(data, &r.Union) + return +} + +func (r MarshallingUnionStruct) MarshalJSON() (data []byte, err error) { + return MarshalRoot(r.Union) +} + +type MarshallingUnion interface { + marshallingUnion() +} + +type MarshallingUnionA struct { + Boo string `json:"boo"` +} + +func (MarshallingUnionA) marshallingUnion() {} + +func (r *MarshallingUnionA) UnmarshalJSON(data []byte) (err error) { + return UnmarshalRoot(data, r) +} + +type MarshallingUnionB struct { + Foo string `json:"foo"` +} + +func (MarshallingUnionB) marshallingUnion() {} + +func (r *MarshallingUnionB) UnmarshalJSON(data []byte) (err error) { + return UnmarshalRoot(data, r) +} + +func init() { + RegisterUnion( + reflect.TypeOf((*MarshallingUnion)(nil)).Elem(), + "", + UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(MarshallingUnionA{}), + }, + UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(MarshallingUnionB{}), + }, + ) +} + +var tests = map[string]struct { + buf string + val interface{} +}{ + "true": {"true", true}, + "false": {"false", false}, + "int": {"1", 1}, + "int_bigger": {"12324", 12324}, + "int_string_coerce": {`"65"`, 65}, + "int_boolean_coerce": {"true", 1}, + "int64": {"1", int64(1)}, + "int64_huge": {"123456789123456789", int64(123456789123456789)}, + "uint": {"1", uint(1)}, + "uint_bigger": {"12324", uint(12324)}, + "uint_coerce": {`"65"`, uint(65)}, + "float_1.54": {"1.54", float32(1.54)}, + "float_1.89": {"1.89", float64(1.89)}, + "string": {`"str"`, "str"}, + "string_int_coerce": {`12`, "12"}, + "array_string": {`["foo","bar"]`, []string{"foo", "bar"}}, + "array_int": {`[1,2]`, []int{1, 2}}, + "array_int_coerce": {`["1",2]`, []int{1, 2}}, + + "ptr_true": {"true", P(true)}, + "ptr_false": {"false", P(false)}, + "ptr_int": {"1", P(1)}, + "ptr_int_bigger": {"12324", P(12324)}, + "ptr_int_string_coerce": {`"65"`, P(65)}, + "ptr_int_boolean_coerce": {"true", P(1)}, + "ptr_int64": {"1", P(int64(1))}, + "ptr_int64_huge": {"123456789123456789", P(int64(123456789123456789))}, + "ptr_uint": {"1", P(uint(1))}, + "ptr_uint_bigger": {"12324", P(uint(12324))}, + "ptr_uint_coerce": {`"65"`, P(uint(65))}, + "ptr_float_1.54": {"1.54", P(float32(1.54))}, + "ptr_float_1.89": {"1.89", P(float64(1.89))}, + + "date_time": {`"2007-03-01T13:00:00Z"`, time.Date(2007, time.March, 1, 13, 0, 0, 0, time.UTC)}, + "date_time_nano_coerce": {`"2007-03-01T13:03:05.123456789Z"`, time.Date(2007, time.March, 1, 13, 3, 5, 123456789, time.UTC)}, + + "date_time_missing_t_coerce": {`"2007-03-01 13:03:05Z"`, time.Date(2007, time.March, 1, 13, 3, 5, 0, time.UTC)}, + "date_time_missing_timezone_coerce": {`"2007-03-01T13:03:05"`, time.Date(2007, time.March, 1, 13, 3, 5, 0, time.UTC)}, + // note: using -1200 to minimize probability of conflicting with the local timezone of the test runner + // see https://en.wikipedia.org/wiki/UTC%E2%88%9212:00 + "date_time_missing_timezone_colon_coerce": {`"2007-03-01T13:03:05-1200"`, time.Date(2007, time.March, 1, 13, 3, 5, 0, time.FixedZone("", -12*60*60))}, + "date_time_nano_missing_t_coerce": {`"2007-03-01 13:03:05.123456789Z"`, time.Date(2007, time.March, 1, 13, 3, 5, 123456789, time.UTC)}, + + "map_string": {`{"foo":"bar"}`, map[string]string{"foo": "bar"}}, + "map_string_with_sjson_path_chars": {`{":a.b.c*:d*-1e.f":"bar"}`, map[string]string{":a.b.c*:d*-1e.f": "bar"}}, + "map_interface": {`{"a":1,"b":"str","c":false}`, map[string]interface{}{"a": float64(1), "b": "str", "c": false}}, + + "primitive_struct": { + `{"a":false,"b":237628372683,"c":654,"d":9999.43,"e":43.76,"f":[1,2,3,4]}`, + Primitives{A: false, B: 237628372683, C: uint(654), D: 9999.43, E: 43.76, F: []int{1, 2, 3, 4}}, + }, + + "slices": { + `{"slices":[{"a":false,"b":237628372683,"c":654,"d":9999.43,"e":43.76,"f":[1,2,3,4]}]}`, + Slices{ + Slice: []Primitives{{A: false, B: 237628372683, C: uint(654), D: 9999.43, E: 43.76, F: []int{1, 2, 3, 4}}}, + }, + }, + + "primitive_pointer_struct": { + `{"a":false,"b":237628372683,"c":654,"d":9999.43,"e":43.76,"f":[1,2,3,4,5]}`, + PrimitivePointers{ + A: P(false), + B: P(237628372683), + C: P(uint(654)), + D: P(9999.43), + E: P(float32(43.76)), + F: &[]int{1, 2, 3, 4, 5}, + }, + }, + + "datetime_struct": { + `{"date":"2006-01-02","date-time":"2006-01-02T15:04:05Z"}`, + DateTime{ + Date: time.Date(2006, time.January, 2, 0, 0, 0, 0, time.UTC), + DateTime: time.Date(2006, time.January, 2, 15, 4, 5, 0, time.UTC), + }, + }, + + "additional_properties": { + `{"a":true,"bar":"value","foo":true}`, + AdditionalProperties{ + A: true, + ExtraFields: map[string]interface{}{ + "bar": "value", + "foo": true, + }, + }, + }, + + "embedded_struct": { + `{"a":1,"b":"bar"}`, + EmbeddedStructs{ + EmbeddedStruct: EmbeddedStruct{ + A: true, + B: "bar", + JSON: EmbeddedStructJSON{ + A: Field{raw: `1`, status: valid}, + B: Field{raw: `"bar"`, status: valid}, + raw: `{"a":1,"b":"bar"}`, + }, + }, + A: P(1), + ExtraFields: map[string]interface{}{"b": "bar"}, + JSON: EmbeddedStructsJSON{ + A: Field{raw: `1`, status: valid}, + ExtraFields: map[string]Field{ + "b": {raw: `"bar"`, status: valid}, + }, + raw: `{"a":1,"b":"bar"}`, + }, + }, + }, + + "recursive_struct": { + `{"child":{"name":"Alex"},"name":"Robert"}`, + Recursive{Name: "Robert", Child: &Recursive{Name: "Alex"}}, + }, + + "metadata_coerce": { + `{"a":"12","b":"12","c":null,"extra_typed":12,"extra_untyped":{"foo":"bar"}}`, + JSONFieldStruct{ + A: false, + B: 12, + C: "", + JSON: JSONFieldStructJSON{ + raw: `{"a":"12","b":"12","c":null,"extra_typed":12,"extra_untyped":{"foo":"bar"}}`, + A: Field{raw: `"12"`, status: invalid}, + B: Field{raw: `"12"`, status: valid}, + C: Field{raw: "null", status: null}, + D: Field{raw: "", status: missing}, + ExtraFields: map[string]Field{ + "extra_typed": { + raw: "12", + status: valid, + }, + "extra_untyped": { + raw: `{"foo":"bar"}`, + status: invalid, + }, + }, + }, + ExtraFields: map[string]int64{ + "extra_typed": 12, + "extra_untyped": 0, + }, + }, + }, + + "unknown_struct_number": { + `{"unknown":12}`, + UnknownStruct{ + Unknown: 12., + }, + }, + + "unknown_struct_map": { + `{"unknown":{"foo":"bar"}}`, + UnknownStruct{ + Unknown: map[string]interface{}{ + "foo": "bar", + }, + }, + }, + + "union_integer": { + `{"union":12}`, + UnionStruct{ + Union: UnionInteger(12), + }, + }, + + "union_struct_discriminated_a": { + `{"union":{"a":"foo","b":"bar","type":"typeA"}}`, + UnionStruct{ + Union: UnionStructA{ + Type: "typeA", + A: "foo", + B: "bar", + }, + }, + }, + + "union_struct_discriminated_b": { + `{"union":{"a":"foo","type":"typeB"}}`, + UnionStruct{ + Union: UnionStructB{ + Type: "typeB", + A: "foo", + }, + }, + }, + + "union_struct_time": { + `{"union":"2010-05-23"}`, + UnionStruct{ + Union: UnionTime(time.Date(2010, 05, 23, 0, 0, 0, 0, time.UTC)), + }, + }, + + "complex_union_a": { + `{"union":{"boo":"12","foo":true}}`, + ComplexUnionStruct{Union: ComplexUnionA{Boo: "12", Foo: true}}, + }, + + "complex_union_b": { + `{"union":{"boo":true,"foo":"12"}}`, + ComplexUnionStruct{Union: ComplexUnionB{Boo: true, Foo: "12"}}, + }, + + "complex_union_c": { + `{"union":{"boo":12}}`, + ComplexUnionStruct{Union: ComplexUnionC{Boo: 12}}, + }, + + "complex_union_type_a": { + `{"union":{"baz":12,"type":"a"}}`, + ComplexUnionStruct{Union: ComplexUnionTypeA{Baz: 12, Type: TypeA("a")}}, + }, + + "complex_union_type_b": { + `{"union":{"baz":12,"type":"b"}}`, + ComplexUnionStruct{Union: ComplexUnionTypeB{Baz: 12, Type: TypeB("b")}}, + }, + + "marshalling_union_a": { + `{"boo":"hello"}`, + MarshallingUnionStruct{Union: MarshallingUnionA{Boo: "hello"}}, + }, + "marshalling_union_b": { + `{"foo":"hi"}`, + MarshallingUnionStruct{Union: MarshallingUnionB{Foo: "hi"}}, + }, + + "unmarshal": { + `{"foo":"hello"}`, + &UnmarshalStruct{Foo: "hello", prop: true}, + }, + + "array_of_unmarshal": { + `[{"foo":"hello"}]`, + []UnmarshalStruct{{Foo: "hello", prop: true}}, + }, + + "inline_coerce": { + `{"a":false,"b":237628372683,"c":654,"d":9999.43,"e":43.76,"f":[1,2,3,4]}`, + Inline{ + InlineField: Primitives{A: false, B: 237628372683, C: 0x28e, D: 9999.43, E: 43.76, F: []int{1, 2, 3, 4}}, + JSON: InlineJSON{ + InlineField: Field{raw: "{\"a\":false,\"b\":237628372683,\"c\":654,\"d\":9999.43,\"e\":43.76,\"f\":[1,2,3,4]}", status: 3}, + raw: "{\"a\":false,\"b\":237628372683,\"c\":654,\"d\":9999.43,\"e\":43.76,\"f\":[1,2,3,4]}", + }, + }, + }, + + "inline_array_coerce": { + `["Hello","foo","bar"]`, + InlineArray{ + InlineField: []string{"Hello", "foo", "bar"}, + JSON: InlineJSON{ + InlineField: Field{raw: `["Hello","foo","bar"]`, status: 3}, + raw: `["Hello","foo","bar"]`, + }, + }, + }, +} + +func TestDecode(t *testing.T) { + for name, test := range tests { + t.Run(name, func(t *testing.T) { + result := reflect.New(reflect.TypeOf(test.val)) + if err := Unmarshal([]byte(test.buf), result.Interface()); err != nil { + t.Fatalf("deserialization of %v failed with error %v", result, err) + } + if !reflect.DeepEqual(result.Elem().Interface(), test.val) { + t.Fatalf("expected '%s' to deserialize to \n%#v\nbut got\n%#v", test.buf, test.val, result.Elem().Interface()) + } + }) + } +} + +func TestEncode(t *testing.T) { + for name, test := range tests { + if strings.HasSuffix(name, "_coerce") { + continue + } + t.Run(name, func(t *testing.T) { + raw, err := Marshal(test.val) + if err != nil { + t.Fatalf("serialization of %v failed with error %v", test.val, err) + } + if string(raw) != test.buf { + t.Fatalf("expected %+#v to serialize to %s but got %s", test.val, test.buf, string(raw)) + } + }) + } +} diff --git a/packages/tui/sdk/internal/apijson/port.go b/packages/tui/sdk/internal/apijson/port.go new file mode 100644 index 00000000..502ab778 --- /dev/null +++ b/packages/tui/sdk/internal/apijson/port.go @@ -0,0 +1,120 @@ +package apijson + +import ( + "fmt" + "reflect" +) + +// Port copies over values from one struct to another struct. +func Port(from any, to any) error { + toVal := reflect.ValueOf(to) + fromVal := reflect.ValueOf(from) + + if toVal.Kind() != reflect.Ptr || toVal.IsNil() { + return fmt.Errorf("destination must be a non-nil pointer") + } + + for toVal.Kind() == reflect.Ptr { + toVal = toVal.Elem() + } + toType := toVal.Type() + + for fromVal.Kind() == reflect.Ptr { + fromVal = fromVal.Elem() + } + fromType := fromVal.Type() + + if toType.Kind() != reflect.Struct { + return fmt.Errorf("destination must be a non-nil pointer to a struct (%v %v)", toType, toType.Kind()) + } + + values := map[string]reflect.Value{} + fields := map[string]reflect.Value{} + + fromJSON := fromVal.FieldByName("JSON") + toJSON := toVal.FieldByName("JSON") + + // Iterate through the fields of v and load all the "normal" fields in the struct to the map of + // string to reflect.Value, as well as their raw .JSON.Foo counterpart indicated by j. + var getFields func(t reflect.Type, v reflect.Value) + getFields = func(t reflect.Type, v reflect.Value) { + j := v.FieldByName("JSON") + + // Recurse into anonymous fields first, since the fields on the object should win over the fields in the + // embedded object. + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.Anonymous { + getFields(field.Type, v.Field(i)) + continue + } + } + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + ptag, ok := parseJSONStructTag(field) + if !ok || ptag.name == "-" { + continue + } + values[ptag.name] = v.Field(i) + if j.IsValid() { + fields[ptag.name] = j.FieldByName(field.Name) + } + } + } + getFields(fromType, fromVal) + + // Use the values from the previous step to populate the 'to' struct. + for i := 0; i < toType.NumField(); i++ { + field := toType.Field(i) + ptag, ok := parseJSONStructTag(field) + if !ok { + continue + } + if ptag.name == "-" { + continue + } + if value, ok := values[ptag.name]; ok { + delete(values, ptag.name) + if field.Type.Kind() == reflect.Interface { + toVal.Field(i).Set(value) + } else { + switch value.Kind() { + case reflect.String: + toVal.Field(i).SetString(value.String()) + case reflect.Bool: + toVal.Field(i).SetBool(value.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + toVal.Field(i).SetInt(value.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + toVal.Field(i).SetUint(value.Uint()) + case reflect.Float32, reflect.Float64: + toVal.Field(i).SetFloat(value.Float()) + default: + toVal.Field(i).Set(value) + } + } + } + + if fromJSONField, ok := fields[ptag.name]; ok { + if toJSONField := toJSON.FieldByName(field.Name); toJSONField.IsValid() { + toJSONField.Set(fromJSONField) + } + } + } + + // Finally, copy over the .JSON.raw and .JSON.ExtraFields + if toJSON.IsValid() { + if raw := toJSON.FieldByName("raw"); raw.IsValid() { + setUnexportedField(raw, fromJSON.Interface().(interface{ RawJSON() string }).RawJSON()) + } + + if toExtraFields := toJSON.FieldByName("ExtraFields"); toExtraFields.IsValid() { + if fromExtraFields := fromJSON.FieldByName("ExtraFields"); fromExtraFields.IsValid() { + setUnexportedField(toExtraFields, fromExtraFields.Interface()) + } + } + } + + return nil +} diff --git a/packages/tui/sdk/internal/apijson/port_test.go b/packages/tui/sdk/internal/apijson/port_test.go new file mode 100644 index 00000000..11540533 --- /dev/null +++ b/packages/tui/sdk/internal/apijson/port_test.go @@ -0,0 +1,257 @@ +package apijson + +import ( + "reflect" + "testing" +) + +type Metadata struct { + CreatedAt string `json:"created_at"` +} + +// Card is the "combined" type of CardVisa and CardMastercard +type Card struct { + Processor CardProcessor `json:"processor"` + Data any `json:"data"` + IsFoo bool `json:"is_foo"` + IsBar bool `json:"is_bar"` + Metadata Metadata `json:"metadata"` + Value interface{} `json:"value"` + + JSON cardJSON +} + +type cardJSON struct { + Processor Field + Data Field + IsFoo Field + IsBar Field + Metadata Field + Value Field + ExtraFields map[string]Field + raw string +} + +func (r cardJSON) RawJSON() string { return r.raw } + +type CardProcessor string + +// CardVisa +type CardVisa struct { + Processor CardVisaProcessor `json:"processor"` + Data CardVisaData `json:"data"` + IsFoo bool `json:"is_foo"` + Metadata Metadata `json:"metadata"` + Value string `json:"value"` + + JSON cardVisaJSON +} + +type cardVisaJSON struct { + Processor Field + Data Field + IsFoo Field + Metadata Field + Value Field + ExtraFields map[string]Field + raw string +} + +func (r cardVisaJSON) RawJSON() string { return r.raw } + +type CardVisaProcessor string + +type CardVisaData struct { + Foo string `json:"foo"` +} + +// CardMastercard +type CardMastercard struct { + Processor CardMastercardProcessor `json:"processor"` + Data CardMastercardData `json:"data"` + IsBar bool `json:"is_bar"` + Metadata Metadata `json:"metadata"` + Value bool `json:"value"` + + JSON cardMastercardJSON +} + +type cardMastercardJSON struct { + Processor Field + Data Field + IsBar Field + Metadata Field + Value Field + ExtraFields map[string]Field + raw string +} + +func (r cardMastercardJSON) RawJSON() string { return r.raw } + +type CardMastercardProcessor string + +type CardMastercardData struct { + Bar int64 `json:"bar"` +} + +type CommonFields struct { + Metadata Metadata `json:"metadata"` + Value string `json:"value"` + + JSON commonFieldsJSON +} + +type commonFieldsJSON struct { + Metadata Field + Value Field + ExtraFields map[string]Field + raw string +} + +type CardEmbedded struct { + CommonFields + Processor CardVisaProcessor `json:"processor"` + Data CardVisaData `json:"data"` + IsFoo bool `json:"is_foo"` + + JSON cardEmbeddedJSON +} + +type cardEmbeddedJSON struct { + Processor Field + Data Field + IsFoo Field + ExtraFields map[string]Field + raw string +} + +func (r cardEmbeddedJSON) RawJSON() string { return r.raw } + +var portTests = map[string]struct { + from any + to any +}{ + "visa to card": { + CardVisa{ + Processor: "visa", + IsFoo: true, + Data: CardVisaData{ + Foo: "foo", + }, + Metadata: Metadata{ + CreatedAt: "Mar 29 2024", + }, + Value: "value", + JSON: cardVisaJSON{ + raw: `{"processor":"visa","is_foo":true,"data":{"foo":"foo"}}`, + Processor: Field{raw: `"visa"`, status: valid}, + IsFoo: Field{raw: `true`, status: valid}, + Data: Field{raw: `{"foo":"foo"}`, status: valid}, + Value: Field{raw: `"value"`, status: valid}, + ExtraFields: map[string]Field{"extra": {raw: `"yo"`, status: valid}}, + }, + }, + Card{ + Processor: "visa", + IsFoo: true, + IsBar: false, + Data: CardVisaData{ + Foo: "foo", + }, + Metadata: Metadata{ + CreatedAt: "Mar 29 2024", + }, + Value: "value", + JSON: cardJSON{ + raw: `{"processor":"visa","is_foo":true,"data":{"foo":"foo"}}`, + Processor: Field{raw: `"visa"`, status: valid}, + IsFoo: Field{raw: `true`, status: valid}, + Data: Field{raw: `{"foo":"foo"}`, status: valid}, + Value: Field{raw: `"value"`, status: valid}, + ExtraFields: map[string]Field{"extra": {raw: `"yo"`, status: valid}}, + }, + }, + }, + "mastercard to card": { + CardMastercard{ + Processor: "mastercard", + IsBar: true, + Data: CardMastercardData{ + Bar: 13, + }, + Value: false, + }, + Card{ + Processor: "mastercard", + IsFoo: false, + IsBar: true, + Data: CardMastercardData{ + Bar: 13, + }, + Value: false, + }, + }, + "embedded to card": { + CardEmbedded{ + CommonFields: CommonFields{ + Metadata: Metadata{ + CreatedAt: "Mar 29 2024", + }, + Value: "embedded_value", + JSON: commonFieldsJSON{ + Metadata: Field{raw: `{"created_at":"Mar 29 2024"}`, status: valid}, + Value: Field{raw: `"embedded_value"`, status: valid}, + raw: `should not matter`, + }, + }, + Processor: "visa", + IsFoo: true, + Data: CardVisaData{ + Foo: "embedded_foo", + }, + JSON: cardEmbeddedJSON{ + raw: `{"processor":"visa","is_foo":true,"data":{"foo":"embedded_foo"},"metadata":{"created_at":"Mar 29 2024"},"value":"embedded_value"}`, + Processor: Field{raw: `"visa"`, status: valid}, + IsFoo: Field{raw: `true`, status: valid}, + Data: Field{raw: `{"foo":"embedded_foo"}`, status: valid}, + }, + }, + Card{ + Processor: "visa", + IsFoo: true, + IsBar: false, + Data: CardVisaData{ + Foo: "embedded_foo", + }, + Metadata: Metadata{ + CreatedAt: "Mar 29 2024", + }, + Value: "embedded_value", + JSON: cardJSON{ + raw: `{"processor":"visa","is_foo":true,"data":{"foo":"embedded_foo"},"metadata":{"created_at":"Mar 29 2024"},"value":"embedded_value"}`, + Processor: Field{raw: `"visa"`, status: 0x3}, + IsFoo: Field{raw: "true", status: 0x3}, + Data: Field{raw: `{"foo":"embedded_foo"}`, status: 0x3}, + Metadata: Field{raw: `{"created_at":"Mar 29 2024"}`, status: 0x3}, + Value: Field{raw: `"embedded_value"`, status: 0x3}, + }, + }, + }, +} + +func TestPort(t *testing.T) { + for name, test := range portTests { + t.Run(name, func(t *testing.T) { + toVal := reflect.New(reflect.TypeOf(test.to)) + + err := Port(test.from, toVal.Interface()) + if err != nil { + t.Fatalf("port of %v failed with error %v", test.from, err) + } + + if !reflect.DeepEqual(toVal.Elem().Interface(), test.to) { + t.Fatalf("expected:\n%+#v\n\nto port to:\n%+#v\n\nbut got:\n%+#v", test.from, test.to, toVal.Elem().Interface()) + } + }) + } +} diff --git a/packages/tui/sdk/internal/apijson/registry.go b/packages/tui/sdk/internal/apijson/registry.go new file mode 100644 index 00000000..119cc5ff --- /dev/null +++ b/packages/tui/sdk/internal/apijson/registry.go @@ -0,0 +1,41 @@ +package apijson + +import ( + "reflect" + + "github.com/tidwall/gjson" +) + +type UnionVariant struct { + TypeFilter gjson.Type + DiscriminatorValue interface{} + Type reflect.Type +} + +var unionRegistry = map[reflect.Type]unionEntry{} +var unionVariants = map[reflect.Type]interface{}{} + +type unionEntry struct { + discriminatorKey string + variants []UnionVariant +} + +func RegisterUnion(typ reflect.Type, discriminator string, variants ...UnionVariant) { + unionRegistry[typ] = unionEntry{ + discriminatorKey: discriminator, + variants: variants, + } + for _, variant := range variants { + unionVariants[variant.Type] = typ + } +} + +// Useful to wrap a union type to force it to use [apijson.UnmarshalJSON] since you cannot define an +// UnmarshalJSON function on the interface itself. +type UnionUnmarshaler[T any] struct { + Value T +} + +func (c *UnionUnmarshaler[T]) UnmarshalJSON(buf []byte) error { + return UnmarshalRoot(buf, &c.Value) +} diff --git a/packages/tui/sdk/internal/apijson/tag.go b/packages/tui/sdk/internal/apijson/tag.go new file mode 100644 index 00000000..812fb3ca --- /dev/null +++ b/packages/tui/sdk/internal/apijson/tag.go @@ -0,0 +1,47 @@ +package apijson + +import ( + "reflect" + "strings" +) + +const jsonStructTag = "json" +const formatStructTag = "format" + +type parsedStructTag struct { + name string + required bool + extras bool + metadata bool + inline bool +} + +func parseJSONStructTag(field reflect.StructField) (tag parsedStructTag, ok bool) { + raw, ok := field.Tag.Lookup(jsonStructTag) + if !ok { + return + } + parts := strings.Split(raw, ",") + if len(parts) == 0 { + return tag, false + } + tag.name = parts[0] + for _, part := range parts[1:] { + switch part { + case "required": + tag.required = true + case "extras": + tag.extras = true + case "metadata": + tag.metadata = true + case "inline": + tag.inline = true + } + } + return +} + +func parseFormatStructTag(field reflect.StructField) (format string, ok bool) { + format, ok = field.Tag.Lookup(formatStructTag) + return +} diff --git a/packages/tui/sdk/internal/apiquery/encoder.go b/packages/tui/sdk/internal/apiquery/encoder.go new file mode 100644 index 00000000..0922c231 --- /dev/null +++ b/packages/tui/sdk/internal/apiquery/encoder.go @@ -0,0 +1,341 @@ +package apiquery + +import ( + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/sst/opencode-sdk-go/internal/param" +) + +var encoders sync.Map // map[reflect.Type]encoderFunc + +type encoder struct { + dateFormat string + root bool + settings QuerySettings +} + +type encoderFunc func(key string, value reflect.Value) []Pair + +type encoderField struct { + tag parsedStructTag + fn encoderFunc + idx []int +} + +type encoderEntry struct { + reflect.Type + dateFormat string + root bool + settings QuerySettings +} + +type Pair struct { + key string + value string +} + +func (e *encoder) typeEncoder(t reflect.Type) encoderFunc { + entry := encoderEntry{ + Type: t, + dateFormat: e.dateFormat, + root: e.root, + settings: e.settings, + } + + if fi, ok := encoders.Load(entry); ok { + return fi.(encoderFunc) + } + + // To deal with recursive types, populate the map with an + // indirect func before we build it. This type waits on the + // real func (f) to be ready and then calls it. This indirect + // func is only used for recursive types. + var ( + wg sync.WaitGroup + f encoderFunc + ) + wg.Add(1) + fi, loaded := encoders.LoadOrStore(entry, encoderFunc(func(key string, v reflect.Value) []Pair { + wg.Wait() + return f(key, v) + })) + if loaded { + return fi.(encoderFunc) + } + + // Compute the real encoder and replace the indirect func with it. + f = e.newTypeEncoder(t) + wg.Done() + encoders.Store(entry, f) + return f +} + +func marshalerEncoder(key string, value reflect.Value) []Pair { + s, _ := value.Interface().(json.Marshaler).MarshalJSON() + return []Pair{{key, string(s)}} +} + +func (e *encoder) newTypeEncoder(t reflect.Type) encoderFunc { + if t.ConvertibleTo(reflect.TypeOf(time.Time{})) { + return e.newTimeTypeEncoder(t) + } + if !e.root && t.Implements(reflect.TypeOf((*json.Marshaler)(nil)).Elem()) { + return marshalerEncoder + } + e.root = false + switch t.Kind() { + case reflect.Pointer: + encoder := e.typeEncoder(t.Elem()) + return func(key string, value reflect.Value) (pairs []Pair) { + if !value.IsValid() || value.IsNil() { + return + } + pairs = encoder(key, value.Elem()) + return + } + case reflect.Struct: + return e.newStructTypeEncoder(t) + case reflect.Array: + fallthrough + case reflect.Slice: + return e.newArrayTypeEncoder(t) + case reflect.Map: + return e.newMapEncoder(t) + case reflect.Interface: + return e.newInterfaceEncoder() + default: + return e.newPrimitiveTypeEncoder(t) + } +} + +func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc { + if t.Implements(reflect.TypeOf((*param.FieldLike)(nil)).Elem()) { + return e.newFieldTypeEncoder(t) + } + + encoderFields := []encoderField{} + + // This helper allows us to recursively collect field encoders into a flat + // array. The parameter `index` keeps track of the access patterns necessary + // to get to some field. + var collectEncoderFields func(r reflect.Type, index []int) + collectEncoderFields = func(r reflect.Type, index []int) { + for i := 0; i < r.NumField(); i++ { + idx := append(index, i) + field := t.FieldByIndex(idx) + if !field.IsExported() { + continue + } + // If this is an embedded struct, traverse one level deeper to extract + // the field and get their encoders as well. + if field.Anonymous { + collectEncoderFields(field.Type, idx) + continue + } + // If query tag is not present, then we skip, which is intentionally + // different behavior from the stdlib. + ptag, ok := parseQueryStructTag(field) + if !ok { + continue + } + + if ptag.name == "-" && !ptag.inline { + continue + } + + dateFormat, ok := parseFormatStructTag(field) + oldFormat := e.dateFormat + if ok { + switch dateFormat { + case "date-time": + e.dateFormat = time.RFC3339 + case "date": + e.dateFormat = "2006-01-02" + } + } + encoderFields = append(encoderFields, encoderField{ptag, e.typeEncoder(field.Type), idx}) + e.dateFormat = oldFormat + } + } + collectEncoderFields(t, []int{}) + + return func(key string, value reflect.Value) (pairs []Pair) { + for _, ef := range encoderFields { + var subkey string = e.renderKeyPath(key, ef.tag.name) + if ef.tag.inline { + subkey = key + } + + field := value.FieldByIndex(ef.idx) + pairs = append(pairs, ef.fn(subkey, field)...) + } + return + } +} + +func (e *encoder) newMapEncoder(t reflect.Type) encoderFunc { + keyEncoder := e.typeEncoder(t.Key()) + elementEncoder := e.typeEncoder(t.Elem()) + return func(key string, value reflect.Value) (pairs []Pair) { + iter := value.MapRange() + for iter.Next() { + encodedKey := keyEncoder("", iter.Key()) + if len(encodedKey) != 1 { + panic("Unexpected number of parts for encoded map key. Are you using a non-primitive for this map?") + } + subkey := encodedKey[0].value + keyPath := e.renderKeyPath(key, subkey) + pairs = append(pairs, elementEncoder(keyPath, iter.Value())...) + } + return + } +} + +func (e *encoder) renderKeyPath(key string, subkey string) string { + if len(key) == 0 { + return subkey + } + if e.settings.NestedFormat == NestedQueryFormatDots { + return fmt.Sprintf("%s.%s", key, subkey) + } + return fmt.Sprintf("%s[%s]", key, subkey) +} + +func (e *encoder) newArrayTypeEncoder(t reflect.Type) encoderFunc { + switch e.settings.ArrayFormat { + case ArrayQueryFormatComma: + innerEncoder := e.typeEncoder(t.Elem()) + return func(key string, v reflect.Value) []Pair { + elements := []string{} + for i := 0; i < v.Len(); i++ { + for _, pair := range innerEncoder("", v.Index(i)) { + elements = append(elements, pair.value) + } + } + if len(elements) == 0 { + return []Pair{} + } + return []Pair{{key, strings.Join(elements, ",")}} + } + case ArrayQueryFormatRepeat: + innerEncoder := e.typeEncoder(t.Elem()) + return func(key string, value reflect.Value) (pairs []Pair) { + for i := 0; i < value.Len(); i++ { + pairs = append(pairs, innerEncoder(key, value.Index(i))...) + } + return pairs + } + case ArrayQueryFormatIndices: + panic("The array indices format is not supported yet") + case ArrayQueryFormatBrackets: + innerEncoder := e.typeEncoder(t.Elem()) + return func(key string, value reflect.Value) []Pair { + pairs := []Pair{} + for i := 0; i < value.Len(); i++ { + pairs = append(pairs, innerEncoder(key+"[]", value.Index(i))...) + } + return pairs + } + default: + panic(fmt.Sprintf("Unknown ArrayFormat value: %d", e.settings.ArrayFormat)) + } +} + +func (e *encoder) newPrimitiveTypeEncoder(t reflect.Type) encoderFunc { + switch t.Kind() { + case reflect.Pointer: + inner := t.Elem() + + innerEncoder := e.newPrimitiveTypeEncoder(inner) + return func(key string, v reflect.Value) []Pair { + if !v.IsValid() || v.IsNil() { + return nil + } + return innerEncoder(key, v.Elem()) + } + case reflect.String: + return func(key string, v reflect.Value) []Pair { + return []Pair{{key, v.String()}} + } + case reflect.Bool: + return func(key string, v reflect.Value) []Pair { + if v.Bool() { + return []Pair{{key, "true"}} + } + return []Pair{{key, "false"}} + } + case reflect.Int, reflect.Int16, reflect.Int32, reflect.Int64: + return func(key string, v reflect.Value) []Pair { + return []Pair{{key, strconv.FormatInt(v.Int(), 10)}} + } + case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return func(key string, v reflect.Value) []Pair { + return []Pair{{key, strconv.FormatUint(v.Uint(), 10)}} + } + case reflect.Float32, reflect.Float64: + return func(key string, v reflect.Value) []Pair { + return []Pair{{key, strconv.FormatFloat(v.Float(), 'f', -1, 64)}} + } + case reflect.Complex64, reflect.Complex128: + bitSize := 64 + if t.Kind() == reflect.Complex128 { + bitSize = 128 + } + return func(key string, v reflect.Value) []Pair { + return []Pair{{key, strconv.FormatComplex(v.Complex(), 'f', -1, bitSize)}} + } + default: + return func(key string, v reflect.Value) []Pair { + return nil + } + } +} + +func (e *encoder) newFieldTypeEncoder(t reflect.Type) encoderFunc { + f, _ := t.FieldByName("Value") + enc := e.typeEncoder(f.Type) + + return func(key string, value reflect.Value) []Pair { + present := value.FieldByName("Present") + if !present.Bool() { + return nil + } + null := value.FieldByName("Null") + if null.Bool() { + // TODO: Error? + return nil + } + raw := value.FieldByName("Raw") + if !raw.IsNil() { + return e.typeEncoder(raw.Type())(key, raw) + } + return enc(key, value.FieldByName("Value")) + } +} + +func (e *encoder) newTimeTypeEncoder(t reflect.Type) encoderFunc { + format := e.dateFormat + return func(key string, value reflect.Value) []Pair { + return []Pair{{ + key, + value.Convert(reflect.TypeOf(time.Time{})).Interface().(time.Time).Format(format), + }} + } +} + +func (e encoder) newInterfaceEncoder() encoderFunc { + return func(key string, value reflect.Value) []Pair { + value = value.Elem() + if !value.IsValid() { + return nil + } + return e.typeEncoder(value.Type())(key, value) + } + +} diff --git a/packages/tui/sdk/internal/apiquery/query.go b/packages/tui/sdk/internal/apiquery/query.go new file mode 100644 index 00000000..6f90e993 --- /dev/null +++ b/packages/tui/sdk/internal/apiquery/query.go @@ -0,0 +1,50 @@ +package apiquery + +import ( + "net/url" + "reflect" + "time" +) + +func MarshalWithSettings(value interface{}, settings QuerySettings) url.Values { + e := encoder{time.RFC3339, true, settings} + kv := url.Values{} + val := reflect.ValueOf(value) + if !val.IsValid() { + return nil + } + typ := val.Type() + for _, pair := range e.typeEncoder(typ)("", val) { + kv.Add(pair.key, pair.value) + } + return kv +} + +func Marshal(value interface{}) url.Values { + return MarshalWithSettings(value, QuerySettings{}) +} + +type Queryer interface { + URLQuery() url.Values +} + +type QuerySettings struct { + NestedFormat NestedQueryFormat + ArrayFormat ArrayQueryFormat +} + +type NestedQueryFormat int + +const ( + NestedQueryFormatBrackets NestedQueryFormat = iota + NestedQueryFormatDots +) + +type ArrayQueryFormat int + +const ( + ArrayQueryFormatComma ArrayQueryFormat = iota + ArrayQueryFormatRepeat + ArrayQueryFormatIndices + ArrayQueryFormatBrackets +) diff --git a/packages/tui/sdk/internal/apiquery/query_test.go b/packages/tui/sdk/internal/apiquery/query_test.go new file mode 100644 index 00000000..1e740d6a --- /dev/null +++ b/packages/tui/sdk/internal/apiquery/query_test.go @@ -0,0 +1,335 @@ +package apiquery + +import ( + "net/url" + "testing" + "time" +) + +func P[T any](v T) *T { return &v } + +type Primitives struct { + A bool `query:"a"` + B int `query:"b"` + C uint `query:"c"` + D float64 `query:"d"` + E float32 `query:"e"` + F []int `query:"f"` +} + +type PrimitivePointers struct { + A *bool `query:"a"` + B *int `query:"b"` + C *uint `query:"c"` + D *float64 `query:"d"` + E *float32 `query:"e"` + F *[]int `query:"f"` +} + +type Slices struct { + Slice []Primitives `query:"slices"` + Mixed []interface{} `query:"mixed"` +} + +type DateTime struct { + Date time.Time `query:"date" format:"date"` + DateTime time.Time `query:"date-time" format:"date-time"` +} + +type AdditionalProperties struct { + A bool `query:"a"` + Extras map[string]interface{} `query:"-,inline"` +} + +type Recursive struct { + Name string `query:"name"` + Child *Recursive `query:"child"` +} + +type UnknownStruct struct { + Unknown interface{} `query:"unknown"` +} + +type UnionStruct struct { + Union Union `query:"union" format:"date"` +} + +type Union interface { + union() +} + +type UnionInteger int64 + +func (UnionInteger) union() {} + +type UnionString string + +func (UnionString) union() {} + +type UnionStructA struct { + Type string `query:"type"` + A string `query:"a"` + B string `query:"b"` +} + +func (UnionStructA) union() {} + +type UnionStructB struct { + Type string `query:"type"` + A string `query:"a"` +} + +func (UnionStructB) union() {} + +type UnionTime time.Time + +func (UnionTime) union() {} + +type DeeplyNested struct { + A DeeplyNested1 `query:"a"` +} + +type DeeplyNested1 struct { + B DeeplyNested2 `query:"b"` +} + +type DeeplyNested2 struct { + C DeeplyNested3 `query:"c"` +} + +type DeeplyNested3 struct { + D *string `query:"d"` +} + +var tests = map[string]struct { + enc string + val interface{} + settings QuerySettings +}{ + "primitives": { + "a=false&b=237628372683&c=654&d=9999.43&e=43.7599983215332&f=1,2,3,4", + Primitives{A: false, B: 237628372683, C: uint(654), D: 9999.43, E: 43.76, F: []int{1, 2, 3, 4}}, + QuerySettings{}, + }, + + "slices_brackets": { + `mixed[]=1&mixed[]=2.3&mixed[]=hello&slices[][a]=false&slices[][a]=false&slices[][b]=237628372683&slices[][b]=237628372683&slices[][c]=654&slices[][c]=654&slices[][d]=9999.43&slices[][d]=9999.43&slices[][e]=43.7599983215332&slices[][e]=43.7599983215332&slices[][f][]=1&slices[][f][]=2&slices[][f][]=3&slices[][f][]=4&slices[][f][]=1&slices[][f][]=2&slices[][f][]=3&slices[][f][]=4`, + Slices{ + Slice: []Primitives{ + {A: false, B: 237628372683, C: uint(654), D: 9999.43, E: 43.76, F: []int{1, 2, 3, 4}}, + {A: false, B: 237628372683, C: uint(654), D: 9999.43, E: 43.76, F: []int{1, 2, 3, 4}}, + }, + Mixed: []interface{}{1, 2.3, "hello"}, + }, + QuerySettings{ArrayFormat: ArrayQueryFormatBrackets}, + }, + + "slices_comma": { + `mixed=1,2.3,hello`, + Slices{ + Mixed: []interface{}{1, 2.3, "hello"}, + }, + QuerySettings{ArrayFormat: ArrayQueryFormatComma}, + }, + + "slices_repeat": { + `mixed=1&mixed=2.3&mixed=hello&slices[a]=false&slices[a]=false&slices[b]=237628372683&slices[b]=237628372683&slices[c]=654&slices[c]=654&slices[d]=9999.43&slices[d]=9999.43&slices[e]=43.7599983215332&slices[e]=43.7599983215332&slices[f]=1&slices[f]=2&slices[f]=3&slices[f]=4&slices[f]=1&slices[f]=2&slices[f]=3&slices[f]=4`, + Slices{ + Slice: []Primitives{ + {A: false, B: 237628372683, C: uint(654), D: 9999.43, E: 43.76, F: []int{1, 2, 3, 4}}, + {A: false, B: 237628372683, C: uint(654), D: 9999.43, E: 43.76, F: []int{1, 2, 3, 4}}, + }, + Mixed: []interface{}{1, 2.3, "hello"}, + }, + QuerySettings{ArrayFormat: ArrayQueryFormatRepeat}, + }, + + "primitive_pointer_struct": { + "a=false&b=237628372683&c=654&d=9999.43&e=43.7599983215332&f=1,2,3,4,5", + PrimitivePointers{ + A: P(false), + B: P(237628372683), + C: P(uint(654)), + D: P(9999.43), + E: P(float32(43.76)), + F: &[]int{1, 2, 3, 4, 5}, + }, + QuerySettings{}, + }, + + "datetime_struct": { + `date=2006-01-02&date-time=2006-01-02T15:04:05Z`, + DateTime{ + Date: time.Date(2006, time.January, 2, 0, 0, 0, 0, time.UTC), + DateTime: time.Date(2006, time.January, 2, 15, 4, 5, 0, time.UTC), + }, + QuerySettings{}, + }, + + "additional_properties": { + `a=true&bar=value&foo=true`, + AdditionalProperties{ + A: true, + Extras: map[string]interface{}{ + "bar": "value", + "foo": true, + }, + }, + QuerySettings{}, + }, + + "recursive_struct_brackets": { + `child[name]=Alex&name=Robert`, + Recursive{Name: "Robert", Child: &Recursive{Name: "Alex"}}, + QuerySettings{NestedFormat: NestedQueryFormatBrackets}, + }, + + "recursive_struct_dots": { + `child.name=Alex&name=Robert`, + Recursive{Name: "Robert", Child: &Recursive{Name: "Alex"}}, + QuerySettings{NestedFormat: NestedQueryFormatDots}, + }, + + "unknown_struct_number": { + `unknown=12`, + UnknownStruct{ + Unknown: 12., + }, + QuerySettings{}, + }, + + "unknown_struct_map_brackets": { + `unknown[foo]=bar`, + UnknownStruct{ + Unknown: map[string]interface{}{ + "foo": "bar", + }, + }, + QuerySettings{NestedFormat: NestedQueryFormatBrackets}, + }, + + "unknown_struct_map_dots": { + `unknown.foo=bar`, + UnknownStruct{ + Unknown: map[string]interface{}{ + "foo": "bar", + }, + }, + QuerySettings{NestedFormat: NestedQueryFormatDots}, + }, + + "union_string": { + `union=hello`, + UnionStruct{ + Union: UnionString("hello"), + }, + QuerySettings{}, + }, + + "union_integer": { + `union=12`, + UnionStruct{ + Union: UnionInteger(12), + }, + QuerySettings{}, + }, + + "union_struct_discriminated_a": { + `union[a]=foo&union[b]=bar&union[type]=typeA`, + UnionStruct{ + Union: UnionStructA{ + Type: "typeA", + A: "foo", + B: "bar", + }, + }, + QuerySettings{}, + }, + + "union_struct_discriminated_b": { + `union[a]=foo&union[type]=typeB`, + UnionStruct{ + Union: UnionStructB{ + Type: "typeB", + A: "foo", + }, + }, + QuerySettings{}, + }, + + "union_struct_time": { + `union=2010-05-23`, + UnionStruct{ + Union: UnionTime(time.Date(2010, 05, 23, 0, 0, 0, 0, time.UTC)), + }, + QuerySettings{}, + }, + + "deeply_nested_brackets": { + `a[b][c][d]=hello`, + DeeplyNested{ + A: DeeplyNested1{ + B: DeeplyNested2{ + C: DeeplyNested3{ + D: P("hello"), + }, + }, + }, + }, + QuerySettings{NestedFormat: NestedQueryFormatBrackets}, + }, + + "deeply_nested_dots": { + `a.b.c.d=hello`, + DeeplyNested{ + A: DeeplyNested1{ + B: DeeplyNested2{ + C: DeeplyNested3{ + D: P("hello"), + }, + }, + }, + }, + QuerySettings{NestedFormat: NestedQueryFormatDots}, + }, + + "deeply_nested_brackets_empty": { + ``, + DeeplyNested{ + A: DeeplyNested1{ + B: DeeplyNested2{ + C: DeeplyNested3{ + D: nil, + }, + }, + }, + }, + QuerySettings{NestedFormat: NestedQueryFormatBrackets}, + }, + + "deeply_nested_dots_empty": { + ``, + DeeplyNested{ + A: DeeplyNested1{ + B: DeeplyNested2{ + C: DeeplyNested3{ + D: nil, + }, + }, + }, + }, + QuerySettings{NestedFormat: NestedQueryFormatDots}, + }, +} + +func TestEncode(t *testing.T) { + for name, test := range tests { + t.Run(name, func(t *testing.T) { + values := MarshalWithSettings(test.val, test.settings) + str, _ := url.QueryUnescape(values.Encode()) + if str != test.enc { + t.Fatalf("expected %+#v to serialize to %s but got %s", test.val, test.enc, str) + } + }) + } +} diff --git a/packages/tui/sdk/internal/apiquery/tag.go b/packages/tui/sdk/internal/apiquery/tag.go new file mode 100644 index 00000000..7ccd739c --- /dev/null +++ b/packages/tui/sdk/internal/apiquery/tag.go @@ -0,0 +1,41 @@ +package apiquery + +import ( + "reflect" + "strings" +) + +const queryStructTag = "query" +const formatStructTag = "format" + +type parsedStructTag struct { + name string + omitempty bool + inline bool +} + +func parseQueryStructTag(field reflect.StructField) (tag parsedStructTag, ok bool) { + raw, ok := field.Tag.Lookup(queryStructTag) + if !ok { + return + } + parts := strings.Split(raw, ",") + if len(parts) == 0 { + return tag, false + } + tag.name = parts[0] + for _, part := range parts[1:] { + switch part { + case "omitempty": + tag.omitempty = true + case "inline": + tag.inline = true + } + } + return +} + +func parseFormatStructTag(field reflect.StructField) (format string, ok bool) { + format, ok = field.Tag.Lookup(formatStructTag) + return +} diff --git a/packages/tui/sdk/internal/param/field.go b/packages/tui/sdk/internal/param/field.go new file mode 100644 index 00000000..4d0fd9c6 --- /dev/null +++ b/packages/tui/sdk/internal/param/field.go @@ -0,0 +1,29 @@ +package param + +import ( + "fmt" +) + +type FieldLike interface{ field() } + +// Field is a wrapper used for all values sent to the API, +// to distinguish zero values from null or omitted fields. +// +// It also allows sending arbitrary deserializable values. +// +// To instantiate a Field, use the helpers exported from +// the package root: `F()`, `Null()`, `Raw()`, etc. +type Field[T any] struct { + FieldLike + Value T + Null bool + Present bool + Raw any +} + +func (f Field[T]) String() string { + if s, ok := any(f.Value).(fmt.Stringer); ok { + return s.String() + } + return fmt.Sprintf("%v", f.Value) +} diff --git a/packages/tui/sdk/internal/requestconfig/requestconfig.go b/packages/tui/sdk/internal/requestconfig/requestconfig.go new file mode 100644 index 00000000..91b70cca --- /dev/null +++ b/packages/tui/sdk/internal/requestconfig/requestconfig.go @@ -0,0 +1,629 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package requestconfig + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math" + "math/rand" + "mime" + "net/http" + "net/url" + "runtime" + "strconv" + "strings" + "time" + + "github.com/sst/opencode-sdk-go/internal" + "github.com/sst/opencode-sdk-go/internal/apierror" + "github.com/sst/opencode-sdk-go/internal/apiform" + "github.com/sst/opencode-sdk-go/internal/apiquery" + "github.com/sst/opencode-sdk-go/internal/param" +) + +func getDefaultHeaders() map[string]string { + return map[string]string{ + "User-Agent": fmt.Sprintf("Opencode/Go %s", internal.PackageVersion), + } +} + +func getNormalizedOS() string { + switch runtime.GOOS { + case "ios": + return "iOS" + case "android": + return "Android" + case "darwin": + return "MacOS" + case "window": + return "Windows" + case "freebsd": + return "FreeBSD" + case "openbsd": + return "OpenBSD" + case "linux": + return "Linux" + default: + return fmt.Sprintf("Other:%s", runtime.GOOS) + } +} + +func getNormalizedArchitecture() string { + switch runtime.GOARCH { + case "386": + return "x32" + case "amd64": + return "x64" + case "arm": + return "arm" + case "arm64": + return "arm64" + default: + return fmt.Sprintf("other:%s", runtime.GOARCH) + } +} + +func getPlatformProperties() map[string]string { + return map[string]string{ + "X-Stainless-Lang": "go", + "X-Stainless-Package-Version": internal.PackageVersion, + "X-Stainless-OS": getNormalizedOS(), + "X-Stainless-Arch": getNormalizedArchitecture(), + "X-Stainless-Runtime": "go", + "X-Stainless-Runtime-Version": runtime.Version(), + } +} + +type RequestOption interface { + Apply(*RequestConfig) error +} + +type RequestOptionFunc func(*RequestConfig) error +type PreRequestOptionFunc func(*RequestConfig) error + +func (s RequestOptionFunc) Apply(r *RequestConfig) error { return s(r) } +func (s PreRequestOptionFunc) Apply(r *RequestConfig) error { return s(r) } + +func NewRequestConfig(ctx context.Context, method string, u string, body interface{}, dst interface{}, opts ...RequestOption) (*RequestConfig, error) { + var reader io.Reader + + contentType := "application/json" + hasSerializationFunc := false + + if body, ok := body.(json.Marshaler); ok { + content, err := body.MarshalJSON() + if err != nil { + return nil, err + } + reader = bytes.NewBuffer(content) + hasSerializationFunc = true + } + if body, ok := body.(apiform.Marshaler); ok { + var ( + content []byte + err error + ) + content, contentType, err = body.MarshalMultipart() + if err != nil { + return nil, err + } + reader = bytes.NewBuffer(content) + hasSerializationFunc = true + } + if body, ok := body.(apiquery.Queryer); ok { + hasSerializationFunc = true + params := body.URLQuery().Encode() + if params != "" { + u = u + "?" + params + } + } + if body, ok := body.([]byte); ok { + reader = bytes.NewBuffer(body) + hasSerializationFunc = true + } + if body, ok := body.(io.Reader); ok { + reader = body + hasSerializationFunc = true + } + + // Fallback to json serialization if none of the serialization functions that we expect + // to see is present. + if body != nil && !hasSerializationFunc { + content, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewBuffer(content) + } + + req, err := http.NewRequestWithContext(ctx, method, u, nil) + if err != nil { + return nil, err + } + if reader != nil { + req.Header.Set("Content-Type", contentType) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Stainless-Retry-Count", "0") + req.Header.Set("X-Stainless-Timeout", "0") + for k, v := range getDefaultHeaders() { + req.Header.Add(k, v) + } + + for k, v := range getPlatformProperties() { + req.Header.Add(k, v) + } + cfg := RequestConfig{ + MaxRetries: 2, + Context: ctx, + Request: req, + HTTPClient: http.DefaultClient, + Body: reader, + } + cfg.ResponseBodyInto = dst + err = cfg.Apply(opts...) + if err != nil { + return nil, err + } + + // This must run after `cfg.Apply(...)` above in case the request timeout gets modified. We also only + // apply our own logic for it if it's still "0" from above. If it's not, then it was deleted or modified + // by the user and we should respect that. + if req.Header.Get("X-Stainless-Timeout") == "0" { + if cfg.RequestTimeout == time.Duration(0) { + req.Header.Del("X-Stainless-Timeout") + } else { + req.Header.Set("X-Stainless-Timeout", strconv.Itoa(int(cfg.RequestTimeout.Seconds()))) + } + } + + return &cfg, nil +} + +func UseDefaultParam[T any](dst *param.Field[T], src *T) { + if !dst.Present && src != nil { + dst.Value = *src + dst.Present = true + } +} + +// This interface is primarily used to describe an [*http.Client], but also +// supports custom HTTP implementations. +type HTTPDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// RequestConfig represents all the state related to one request. +// +// Editing the variables inside RequestConfig directly is unstable api. Prefer +// composing the RequestOption instead if possible. +type RequestConfig struct { + MaxRetries int + RequestTimeout time.Duration + Context context.Context + Request *http.Request + BaseURL *url.URL + // DefaultBaseURL will be used if BaseURL is not explicitly overridden using + // WithBaseURL. + DefaultBaseURL *url.URL + CustomHTTPDoer HTTPDoer + HTTPClient *http.Client + Middlewares []middleware + // If ResponseBodyInto not nil, then we will attempt to deserialize into + // ResponseBodyInto. If Destination is a []byte, then it will return the body as + // is. + ResponseBodyInto interface{} + // ResponseInto copies the \*http.Response of the corresponding request into the + // given address + ResponseInto **http.Response + Body io.Reader +} + +// middleware is exactly the same type as the Middleware type found in the [option] package, +// but it is redeclared here for circular dependency issues. +type middleware = func(*http.Request, middlewareNext) (*http.Response, error) + +// middlewareNext is exactly the same type as the MiddlewareNext type found in the [option] package, +// but it is redeclared here for circular dependency issues. +type middlewareNext = func(*http.Request) (*http.Response, error) + +func applyMiddleware(middleware middleware, next middlewareNext) middlewareNext { + return func(req *http.Request) (res *http.Response, err error) { + return middleware(req, next) + } +} + +func shouldRetry(req *http.Request, res *http.Response) bool { + // If there is no way to recover the Body, then we shouldn't retry. + if req.Body != nil && req.GetBody == nil { + return false + } + + // If there is no response, that indicates that there is a connection error + // so we retry the request. + if res == nil { + return true + } + + // If the header explicitly wants a retry behavior, respect that over the + // http status code. + if res.Header.Get("x-should-retry") == "true" { + return true + } + if res.Header.Get("x-should-retry") == "false" { + return false + } + + return res.StatusCode == http.StatusRequestTimeout || + res.StatusCode == http.StatusConflict || + res.StatusCode == http.StatusTooManyRequests || + res.StatusCode >= http.StatusInternalServerError +} + +func parseRetryAfterHeader(resp *http.Response) (time.Duration, bool) { + if resp == nil { + return 0, false + } + + type retryData struct { + header string + units time.Duration + + // custom is used when the regular algorithm failed and is optional. + // the returned duration is used verbatim (units is not applied). + custom func(string) (time.Duration, bool) + } + + nop := func(string) (time.Duration, bool) { return 0, false } + + // the headers are listed in order of preference + retries := []retryData{ + { + header: "Retry-After-Ms", + units: time.Millisecond, + custom: nop, + }, + { + header: "Retry-After", + units: time.Second, + + // retry-after values are expressed in either number of + // seconds or an HTTP-date indicating when to try again + custom: func(ra string) (time.Duration, bool) { + t, err := time.Parse(time.RFC1123, ra) + if err != nil { + return 0, false + } + return time.Until(t), true + }, + }, + } + + for _, retry := range retries { + v := resp.Header.Get(retry.header) + if v == "" { + continue + } + if retryAfter, err := strconv.ParseFloat(v, 64); err == nil { + return time.Duration(retryAfter * float64(retry.units)), true + } + if d, ok := retry.custom(v); ok { + return d, true + } + } + + return 0, false +} + +// isBeforeContextDeadline reports whether the non-zero Time t is +// before ctx's deadline. If ctx does not have a deadline, it +// always reports true (the deadline is considered infinite). +func isBeforeContextDeadline(t time.Time, ctx context.Context) bool { + d, ok := ctx.Deadline() + if !ok { + return true + } + return t.Before(d) +} + +// bodyWithTimeout is an io.ReadCloser which can observe a context's cancel func +// to handle timeouts etc. It wraps an existing io.ReadCloser. +type bodyWithTimeout struct { + stop func() // stops the time.Timer waiting to cancel the request + rc io.ReadCloser +} + +func (b *bodyWithTimeout) Read(p []byte) (n int, err error) { + n, err = b.rc.Read(p) + if err == nil { + return n, nil + } + if err == io.EOF { + return n, err + } + return n, err +} + +func (b *bodyWithTimeout) Close() error { + err := b.rc.Close() + b.stop() + return err +} + +func retryDelay(res *http.Response, retryCount int) time.Duration { + // If the API asks us to wait a certain amount of time (and it's a reasonable amount), + // just do what it says. + + if retryAfterDelay, ok := parseRetryAfterHeader(res); ok && 0 <= retryAfterDelay && retryAfterDelay < time.Minute { + return retryAfterDelay + } + + maxDelay := 8 * time.Second + delay := time.Duration(0.5 * float64(time.Second) * math.Pow(2, float64(retryCount))) + if delay > maxDelay { + delay = maxDelay + } + + jitter := rand.Int63n(int64(delay / 4)) + delay -= time.Duration(jitter) + return delay +} + +func (cfg *RequestConfig) Execute() (err error) { + if cfg.BaseURL == nil { + if cfg.DefaultBaseURL != nil { + cfg.BaseURL = cfg.DefaultBaseURL + } else { + return fmt.Errorf("requestconfig: base url is not set") + } + } + + cfg.Request.URL, err = cfg.BaseURL.Parse(strings.TrimLeft(cfg.Request.URL.String(), "/")) + if err != nil { + return err + } + + if cfg.Body != nil && cfg.Request.Body == nil { + switch body := cfg.Body.(type) { + case *bytes.Buffer: + b := body.Bytes() + cfg.Request.ContentLength = int64(body.Len()) + cfg.Request.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(b)), nil } + cfg.Request.Body, _ = cfg.Request.GetBody() + case *bytes.Reader: + cfg.Request.ContentLength = int64(body.Len()) + cfg.Request.GetBody = func() (io.ReadCloser, error) { + _, err := body.Seek(0, 0) + return io.NopCloser(body), err + } + cfg.Request.Body, _ = cfg.Request.GetBody() + default: + if rc, ok := body.(io.ReadCloser); ok { + cfg.Request.Body = rc + } else { + cfg.Request.Body = io.NopCloser(body) + } + } + } + + handler := cfg.HTTPClient.Do + if cfg.CustomHTTPDoer != nil { + handler = cfg.CustomHTTPDoer.Do + } + for i := len(cfg.Middlewares) - 1; i >= 0; i -= 1 { + handler = applyMiddleware(cfg.Middlewares[i], handler) + } + + // Don't send the current retry count in the headers if the caller modified the header defaults. + shouldSendRetryCount := cfg.Request.Header.Get("X-Stainless-Retry-Count") == "0" + + var res *http.Response + var cancel context.CancelFunc + for retryCount := 0; retryCount <= cfg.MaxRetries; retryCount += 1 { + ctx := cfg.Request.Context() + if cfg.RequestTimeout != time.Duration(0) && isBeforeContextDeadline(time.Now().Add(cfg.RequestTimeout), ctx) { + ctx, cancel = context.WithTimeout(ctx, cfg.RequestTimeout) + defer func() { + // The cancel function is nil if it was handed off to be handled in a different scope. + if cancel != nil { + cancel() + } + }() + } + + req := cfg.Request.Clone(ctx) + if shouldSendRetryCount { + req.Header.Set("X-Stainless-Retry-Count", strconv.Itoa(retryCount)) + } + + res, err = handler(req) + if ctx != nil && ctx.Err() != nil { + return ctx.Err() + } + if !shouldRetry(cfg.Request, res) || retryCount >= cfg.MaxRetries { + break + } + + // Prepare next request and wait for the retry delay + if cfg.Request.GetBody != nil { + cfg.Request.Body, err = cfg.Request.GetBody() + if err != nil { + return err + } + } + + // Can't actually refresh the body, so we don't attempt to retry here + if cfg.Request.GetBody == nil && cfg.Request.Body != nil { + break + } + + time.Sleep(retryDelay(res, retryCount)) + } + + // Save *http.Response if it is requested to, even if there was an error making the request. This is + // useful in cases where you might want to debug by inspecting the response. Note that if err != nil, + // the response should be generally be empty, but there are edge cases. + if cfg.ResponseInto != nil { + *cfg.ResponseInto = res + } + if responseBodyInto, ok := cfg.ResponseBodyInto.(**http.Response); ok { + *responseBodyInto = res + } + + // If there was a connection error in the final request or any other transport error, + // return that early without trying to coerce into an APIError. + if err != nil { + return err + } + + if res.StatusCode >= 400 { + contents, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + return err + } + + // If there is an APIError, re-populate the response body so that debugging + // utilities can conveniently dump the response without issue. + res.Body = io.NopCloser(bytes.NewBuffer(contents)) + + // Load the contents into the error format if it is provided. + aerr := apierror.Error{Request: cfg.Request, Response: res, StatusCode: res.StatusCode} + err = aerr.UnmarshalJSON(contents) + if err != nil { + return err + } + return &aerr + } + + _, intoCustomResponseBody := cfg.ResponseBodyInto.(**http.Response) + if cfg.ResponseBodyInto == nil || intoCustomResponseBody { + // We aren't reading the response body in this scope, but whoever is will need the + // cancel func from the context to observe request timeouts. + // Put the cancel function in the response body so it can be handled elsewhere. + if cancel != nil { + res.Body = &bodyWithTimeout{rc: res.Body, stop: cancel} + cancel = nil + } + return nil + } + + contents, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + return fmt.Errorf("error reading response body: %w", err) + } + + // If we are not json, return plaintext + contentType := res.Header.Get("content-type") + mediaType, _, _ := mime.ParseMediaType(contentType) + isJSON := strings.Contains(mediaType, "application/json") || strings.HasSuffix(mediaType, "+json") + if !isJSON { + switch dst := cfg.ResponseBodyInto.(type) { + case *string: + *dst = string(contents) + case **string: + tmp := string(contents) + *dst = &tmp + case *[]byte: + *dst = contents + default: + return fmt.Errorf("expected destination type of 'string' or '[]byte' for responses with content-type '%s' that is not 'application/json'", contentType) + } + return nil + } + + switch dst := cfg.ResponseBodyInto.(type) { + // If the response happens to be a byte array, deserialize the body as-is. + case *[]byte: + *dst = contents + default: + err = json.NewDecoder(bytes.NewReader(contents)).Decode(cfg.ResponseBodyInto) + if err != nil { + return fmt.Errorf("error parsing response json: %w", err) + } + } + + return nil +} + +func ExecuteNewRequest(ctx context.Context, method string, u string, body interface{}, dst interface{}, opts ...RequestOption) error { + cfg, err := NewRequestConfig(ctx, method, u, body, dst, opts...) + if err != nil { + return err + } + return cfg.Execute() +} + +func (cfg *RequestConfig) Clone(ctx context.Context) *RequestConfig { + if cfg == nil { + return nil + } + req := cfg.Request.Clone(ctx) + var err error + if req.Body != nil { + req.Body, err = req.GetBody() + } + if err != nil { + return nil + } + new := &RequestConfig{ + MaxRetries: cfg.MaxRetries, + RequestTimeout: cfg.RequestTimeout, + Context: ctx, + Request: req, + BaseURL: cfg.BaseURL, + HTTPClient: cfg.HTTPClient, + Middlewares: cfg.Middlewares, + } + + return new +} + +func (cfg *RequestConfig) Apply(opts ...RequestOption) error { + for _, opt := range opts { + err := opt.Apply(cfg) + if err != nil { + return err + } + } + return nil +} + +// PreRequestOptions is used to collect all the options which need to be known before +// a call to [RequestConfig.ExecuteNewRequest], such as path parameters +// or global defaults. +// PreRequestOptions will return a [RequestConfig] with the options applied. +// +// Only request option functions of type [PreRequestOptionFunc] are applied. +func PreRequestOptions(opts ...RequestOption) (RequestConfig, error) { + cfg := RequestConfig{} + for _, opt := range opts { + if opt, ok := opt.(PreRequestOptionFunc); ok { + err := opt.Apply(&cfg) + if err != nil { + return cfg, err + } + } + } + return cfg, nil +} + +// WithDefaultBaseURL returns a RequestOption that sets the client's default Base URL. +// This is always overridden by setting a base URL with WithBaseURL. +// WithBaseURL should be used instead of WithDefaultBaseURL except in internal code. +func WithDefaultBaseURL(baseURL string) RequestOption { + u, err := url.Parse(baseURL) + return RequestOptionFunc(func(r *RequestConfig) error { + if err != nil { + return err + } + r.DefaultBaseURL = u + return nil + }) +} diff --git a/packages/tui/sdk/internal/testutil/testutil.go b/packages/tui/sdk/internal/testutil/testutil.go new file mode 100644 index 00000000..826d266f --- /dev/null +++ b/packages/tui/sdk/internal/testutil/testutil.go @@ -0,0 +1,27 @@ +package testutil + +import ( + "net/http" + "os" + "strconv" + "testing" +) + +func CheckTestServer(t *testing.T, url string) bool { + if _, err := http.Get(url); err != nil { + const SKIP_MOCK_TESTS = "SKIP_MOCK_TESTS" + if str, ok := os.LookupEnv(SKIP_MOCK_TESTS); ok { + skip, err := strconv.ParseBool(str) + if err != nil { + t.Fatalf("strconv.ParseBool(os.LookupEnv(%s)) failed: %s", SKIP_MOCK_TESTS, err) + } + if skip { + t.Skip("The test will not run without a mock Prism server running against your OpenAPI spec") + return false + } + t.Errorf("The test will not run without a mock Prism server running against your OpenAPI spec. You can set the environment variable %s to true to skip running any tests that require the mock server", SKIP_MOCK_TESTS) + return false + } + } + return true +} diff --git a/packages/tui/sdk/internal/version.go b/packages/tui/sdk/internal/version.go new file mode 100644 index 00000000..64dcebbb --- /dev/null +++ b/packages/tui/sdk/internal/version.go @@ -0,0 +1,5 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package internal + +const PackageVersion = "0.1.0-alpha.8" // x-release-please-version diff --git a/packages/tui/sdk/lib/.keep b/packages/tui/sdk/lib/.keep new file mode 100644 index 00000000..5e2c99fd --- /dev/null +++ b/packages/tui/sdk/lib/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store custom files to expand the SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/packages/tui/sdk/option/middleware.go b/packages/tui/sdk/option/middleware.go new file mode 100644 index 00000000..8ec9dd60 --- /dev/null +++ b/packages/tui/sdk/option/middleware.go @@ -0,0 +1,38 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package option + +import ( + "log" + "net/http" + "net/http/httputil" +) + +// WithDebugLog logs the HTTP request and response content. +// If the logger parameter is nil, it uses the default logger. +// +// WithDebugLog is for debugging and development purposes only. +// It should not be used in production code. The behavior and interface +// of WithDebugLog is not guaranteed to be stable. +func WithDebugLog(logger *log.Logger) RequestOption { + return WithMiddleware(func(req *http.Request, nxt MiddlewareNext) (*http.Response, error) { + if logger == nil { + logger = log.Default() + } + + if reqBytes, err := httputil.DumpRequest(req, true); err == nil { + logger.Printf("Request Content:\n%s\n", reqBytes) + } + + resp, err := nxt(req) + if err != nil { + return resp, err + } + + if respBytes, err := httputil.DumpResponse(resp, true); err == nil { + logger.Printf("Response Content:\n%s\n", respBytes) + } + + return resp, err + }) +} diff --git a/packages/tui/sdk/option/requestoption.go b/packages/tui/sdk/option/requestoption.go new file mode 100644 index 00000000..313552e9 --- /dev/null +++ b/packages/tui/sdk/option/requestoption.go @@ -0,0 +1,266 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package option + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sst/opencode-sdk-go/internal/requestconfig" + "github.com/tidwall/sjson" +) + +// RequestOption is an option for the requests made by the opencode API Client +// which can be supplied to clients, services, and methods. You can read more about this functional +// options pattern in our [README]. +// +// [README]: https://pkg.go.dev/github.com/sst/opencode-sdk-go#readme-requestoptions +type RequestOption = requestconfig.RequestOption + +// WithBaseURL returns a RequestOption that sets the BaseURL for the client. +// +// For security reasons, ensure that the base URL is trusted. +func WithBaseURL(base string) RequestOption { + u, err := url.Parse(base) + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + if err != nil { + return fmt.Errorf("requestoption: WithBaseURL failed to parse url %s\n", err) + } + + if u.Path != "" && !strings.HasSuffix(u.Path, "/") { + u.Path += "/" + } + r.BaseURL = u + return nil + }) +} + +// HTTPClient is primarily used to describe an [*http.Client], but also +// supports custom implementations. +// +// For bespoke implementations, prefer using an [*http.Client] with a +// custom transport. See [http.RoundTripper] for further information. +type HTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +// WithHTTPClient returns a RequestOption that changes the underlying http client used to make this +// request, which by default is [http.DefaultClient]. +// +// For custom uses cases, it is recommended to provide an [*http.Client] with a custom +// [http.RoundTripper] as its transport, rather than directly implementing [HTTPClient]. +func WithHTTPClient(client HTTPClient) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + if client == nil { + return fmt.Errorf("requestoption: custom http client cannot be nil") + } + + if c, ok := client.(*http.Client); ok { + // Prefer the native client if possible. + r.HTTPClient = c + r.CustomHTTPDoer = nil + } else { + r.CustomHTTPDoer = client + } + + return nil + }) +} + +// MiddlewareNext is a function which is called by a middleware to pass an HTTP request +// to the next stage in the middleware chain. +type MiddlewareNext = func(*http.Request) (*http.Response, error) + +// Middleware is a function which intercepts HTTP requests, processing or modifying +// them, and then passing the request to the next middleware or handler +// in the chain by calling the provided MiddlewareNext function. +type Middleware = func(*http.Request, MiddlewareNext) (*http.Response, error) + +// WithMiddleware returns a RequestOption that applies the given middleware +// to the requests made. Each middleware will execute in the order they were given. +func WithMiddleware(middlewares ...Middleware) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + r.Middlewares = append(r.Middlewares, middlewares...) + return nil + }) +} + +// WithMaxRetries returns a RequestOption that sets the maximum number of retries that the client +// attempts to make. When given 0, the client only makes one request. By +// default, the client retries two times. +// +// WithMaxRetries panics when retries is negative. +func WithMaxRetries(retries int) RequestOption { + if retries < 0 { + panic("option: cannot have fewer than 0 retries") + } + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + r.MaxRetries = retries + return nil + }) +} + +// WithHeader returns a RequestOption that sets the header value to the associated key. It overwrites +// any value if there was one already present. +func WithHeader(key, value string) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + r.Request.Header.Set(key, value) + return nil + }) +} + +// WithHeaderAdd returns a RequestOption that adds the header value to the associated key. It appends +// onto any existing values. +func WithHeaderAdd(key, value string) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + r.Request.Header.Add(key, value) + return nil + }) +} + +// WithHeaderDel returns a RequestOption that deletes the header value(s) associated with the given key. +func WithHeaderDel(key string) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + r.Request.Header.Del(key) + return nil + }) +} + +// WithQuery returns a RequestOption that sets the query value to the associated key. It overwrites +// any value if there was one already present. +func WithQuery(key, value string) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + query := r.Request.URL.Query() + query.Set(key, value) + r.Request.URL.RawQuery = query.Encode() + return nil + }) +} + +// WithQueryAdd returns a RequestOption that adds the query value to the associated key. It appends +// onto any existing values. +func WithQueryAdd(key, value string) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + query := r.Request.URL.Query() + query.Add(key, value) + r.Request.URL.RawQuery = query.Encode() + return nil + }) +} + +// WithQueryDel returns a RequestOption that deletes the query value(s) associated with the key. +func WithQueryDel(key string) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + query := r.Request.URL.Query() + query.Del(key) + r.Request.URL.RawQuery = query.Encode() + return nil + }) +} + +// WithJSONSet returns a RequestOption that sets the body's JSON value associated with the key. +// The key accepts a string as defined by the [sjson format]. +// +// [sjson format]: https://github.com/tidwall/sjson +func WithJSONSet(key string, value interface{}) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) (err error) { + var b []byte + + if r.Body == nil { + b, err = sjson.SetBytes(nil, key, value) + if err != nil { + return err + } + } else if buffer, ok := r.Body.(*bytes.Buffer); ok { + b = buffer.Bytes() + b, err = sjson.SetBytes(b, key, value) + if err != nil { + return err + } + } else { + return fmt.Errorf("cannot use WithJSONSet on a body that is not serialized as *bytes.Buffer") + } + + r.Body = bytes.NewBuffer(b) + return nil + }) +} + +// WithJSONDel returns a RequestOption that deletes the body's JSON value associated with the key. +// The key accepts a string as defined by the [sjson format]. +// +// [sjson format]: https://github.com/tidwall/sjson +func WithJSONDel(key string) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) (err error) { + if buffer, ok := r.Body.(*bytes.Buffer); ok { + b := buffer.Bytes() + b, err = sjson.DeleteBytes(b, key) + if err != nil { + return err + } + r.Body = bytes.NewBuffer(b) + return nil + } + + return fmt.Errorf("cannot use WithJSONDel on a body that is not serialized as *bytes.Buffer") + }) +} + +// WithResponseBodyInto returns a RequestOption that overwrites the deserialization target with +// the given destination. If provided, we don't deserialize into the default struct. +func WithResponseBodyInto(dst any) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + r.ResponseBodyInto = dst + return nil + }) +} + +// WithResponseInto returns a RequestOption that copies the [*http.Response] into the given address. +func WithResponseInto(dst **http.Response) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + r.ResponseInto = dst + return nil + }) +} + +// WithRequestBody returns a RequestOption that provides a custom serialized body with the given +// content type. +// +// body accepts an io.Reader or raw []bytes. +func WithRequestBody(contentType string, body any) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + if reader, ok := body.(io.Reader); ok { + r.Body = reader + return r.Apply(WithHeader("Content-Type", contentType)) + } + + if b, ok := body.([]byte); ok { + r.Body = bytes.NewBuffer(b) + return r.Apply(WithHeader("Content-Type", contentType)) + } + + return fmt.Errorf("body must be a byte slice or implement io.Reader") + }) +} + +// WithRequestTimeout returns a RequestOption that sets the timeout for +// each request attempt. This should be smaller than the timeout defined in +// the context, which spans all retries. +func WithRequestTimeout(dur time.Duration) RequestOption { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + r.RequestTimeout = dur + return nil + }) +} + +// WithEnvironmentProduction returns a RequestOption that sets the current +// environment to be the "production" environment. An environment specifies which base URL +// to use by default. +func WithEnvironmentProduction() RequestOption { + return requestconfig.WithDefaultBaseURL("http://localhost:54321/") +} diff --git a/packages/tui/sdk/packages/ssestream/ssestream.go b/packages/tui/sdk/packages/ssestream/ssestream.go new file mode 100644 index 00000000..81adbd69 --- /dev/null +++ b/packages/tui/sdk/packages/ssestream/ssestream.go @@ -0,0 +1,181 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package ssestream + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "net/http" + "strings" +) + +type Decoder interface { + Event() Event + Next() bool + Close() error + Err() error +} + +func NewDecoder(res *http.Response) Decoder { + if res == nil || res.Body == nil { + return nil + } + + var decoder Decoder + contentType := res.Header.Get("content-type") + if t, ok := decoderTypes[contentType]; ok { + decoder = t(res.Body) + } else { + scn := bufio.NewScanner(res.Body) + scn.Buffer(nil, bufio.MaxScanTokenSize<<4) + decoder = &eventStreamDecoder{rc: res.Body, scn: scn} + } + return decoder +} + +var decoderTypes = map[string](func(io.ReadCloser) Decoder){} + +func RegisterDecoder(contentType string, decoder func(io.ReadCloser) Decoder) { + decoderTypes[strings.ToLower(contentType)] = decoder +} + +type Event struct { + Type string + Data []byte +} + +// A base implementation of a Decoder for text/event-stream. +type eventStreamDecoder struct { + evt Event + rc io.ReadCloser + scn *bufio.Scanner + err error +} + +func (s *eventStreamDecoder) Next() bool { + if s.err != nil { + return false + } + + event := "" + data := bytes.NewBuffer(nil) + + for s.scn.Scan() { + txt := s.scn.Bytes() + + // Dispatch event on an empty line + if len(txt) == 0 { + s.evt = Event{ + Type: event, + Data: data.Bytes(), + } + return true + } + + // Split a string like "event: bar" into name="event" and value=" bar". + name, value, _ := bytes.Cut(txt, []byte(":")) + + // Consume an optional space after the colon if it exists. + if len(value) > 0 && value[0] == ' ' { + value = value[1:] + } + + switch string(name) { + case "": + // An empty line in the for ": something" is a comment and should be ignored. + continue + case "event": + event = string(value) + case "data": + _, s.err = data.Write(value) + if s.err != nil { + break + } + _, s.err = data.WriteRune('\n') + if s.err != nil { + break + } + } + } + + if s.scn.Err() != nil { + s.err = s.scn.Err() + } + + return false +} + +func (s *eventStreamDecoder) Event() Event { + return s.evt +} + +func (s *eventStreamDecoder) Close() error { + return s.rc.Close() +} + +func (s *eventStreamDecoder) Err() error { + return s.err +} + +type Stream[T any] struct { + decoder Decoder + cur T + err error +} + +func NewStream[T any](decoder Decoder, err error) *Stream[T] { + return &Stream[T]{ + decoder: decoder, + err: err, + } +} + +// Next returns false if the stream has ended or an error occurred. +// Call Stream.Current() to get the current value. +// Call Stream.Err() to get the error. +// +// for stream.Next() { +// data := stream.Current() +// } +// +// if stream.Err() != nil { +// ... +// } +func (s *Stream[T]) Next() bool { + if s.err != nil { + return false + } + + for s.decoder.Next() { + var nxt T + s.err = json.Unmarshal(s.decoder.Event().Data, &nxt) + if s.err != nil { + return false + } + s.cur = nxt + return true + } + + // decoder.Next() may be false because of an error + s.err = s.decoder.Err() + + return false +} + +func (s *Stream[T]) Current() T { + return s.cur +} + +func (s *Stream[T]) Err() error { + return s.err +} + +func (s *Stream[T]) Close() error { + if s.decoder == nil { + // already closed + return nil + } + return s.decoder.Close() +} diff --git a/packages/tui/sdk/release-please-config.json b/packages/tui/sdk/release-please-config.json new file mode 100644 index 00000000..a38198ec --- /dev/null +++ b/packages/tui/sdk/release-please-config.json @@ -0,0 +1,67 @@ +{ + "packages": { + ".": {} + }, + "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", + "include-v-in-tag": true, + "include-component-in-tag": false, + "versioning": "prerelease", + "prerelease": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "pull-request-header": "Automated Release PR", + "pull-request-title-pattern": "release: ${version}", + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "revert", + "section": "Reverts" + }, + { + "type": "chore", + "section": "Chores" + }, + { + "type": "docs", + "section": "Documentation" + }, + { + "type": "style", + "section": "Styles" + }, + { + "type": "refactor", + "section": "Refactors" + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "build", + "section": "Build System" + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + } + ], + "release-type": "go", + "extra-files": [ + "internal/version.go", + "README.md" + ] +} \ No newline at end of file diff --git a/packages/tui/sdk/scripts/bootstrap b/packages/tui/sdk/scripts/bootstrap new file mode 100755 index 00000000..d6ac1654 --- /dev/null +++ b/packages/tui/sdk/scripts/bootstrap @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ]; then + brew bundle check >/dev/null 2>&1 || { + echo "==> Installing Homebrew dependencies…" + brew bundle + } +fi + +echo "==> Installing Go dependencies…" + +go mod tidy -e diff --git a/packages/tui/sdk/scripts/format b/packages/tui/sdk/scripts/format new file mode 100755 index 00000000..db2a3fa2 --- /dev/null +++ b/packages/tui/sdk/scripts/format @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running gofmt -s -w" +gofmt -s -w . diff --git a/packages/tui/sdk/scripts/lint b/packages/tui/sdk/scripts/lint new file mode 100755 index 00000000..fa7ba1f6 --- /dev/null +++ b/packages/tui/sdk/scripts/lint @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running Go build" +go build ./... diff --git a/packages/tui/sdk/scripts/mock b/packages/tui/sdk/scripts/mock new file mode 100755 index 00000000..d2814ae6 --- /dev/null +++ b/packages/tui/sdk/scripts/mock @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [[ -n "$1" && "$1" != '--'* ]]; then + URL="$1" + shift +else + URL="$(grep 'openapi_spec_url' .stats.yml | cut -d' ' -f2)" +fi + +# Check if the URL is empty +if [ -z "$URL" ]; then + echo "Error: No OpenAPI spec path/url provided or found in .stats.yml" + exit 1 +fi + +echo "==> Starting mock server with URL ${URL}" + +# Run prism mock on the given spec +if [ "$1" == "--daemon" ]; then + npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" &> .prism.log & + + # Wait for server to come online + echo -n "Waiting for server" + while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + echo -n "." + sleep 0.1 + done + + if grep -q "✖ fatal" ".prism.log"; then + cat .prism.log + exit 1 + fi + + echo +else + npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" +fi diff --git a/packages/tui/sdk/scripts/test b/packages/tui/sdk/scripts/test new file mode 100755 index 00000000..efebceae --- /dev/null +++ b/packages/tui/sdk/scripts/test @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +function prism_is_running() { + curl --silent "http://localhost:4010" >/dev/null 2>&1 +} + +kill_server_on_port() { + pids=$(lsof -t -i tcp:"$1" || echo "") + if [ "$pids" != "" ]; then + kill "$pids" + echo "Stopped $pids." + fi +} + +function is_overriding_api_base_url() { + [ -n "$TEST_API_BASE_URL" ] +} + +if ! is_overriding_api_base_url && ! prism_is_running ; then + # When we exit this script, make sure to kill the background mock server process + trap 'kill_server_on_port 4010' EXIT + + # Start the dev server + ./scripts/mock --daemon +fi + +if is_overriding_api_base_url ; then + echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" + echo +elif ! prism_is_running ; then + echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" + echo -e "running against your OpenAPI spec." + echo + echo -e "To run the server, pass in the path or url of your OpenAPI" + echo -e "spec to the prism command:" + echo + echo -e " \$ ${YELLOW}npm exec --package=@stoplight/prism-cli@~5.3.2 -- prism mock path/to/your.openapi.yml${NC}" + echo + + exit 1 +else + echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" + echo +fi + +echo "==> Running tests" +go test ./... "$@" diff --git a/packages/tui/sdk/session.go b/packages/tui/sdk/session.go new file mode 100644 index 00000000..e8216a1f --- /dev/null +++ b/packages/tui/sdk/session.go @@ -0,0 +1,1385 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode + +import ( + "context" + "errors" + "fmt" + "net/http" + "reflect" + + "github.com/sst/opencode-sdk-go/internal/apijson" + "github.com/sst/opencode-sdk-go/internal/param" + "github.com/sst/opencode-sdk-go/internal/requestconfig" + "github.com/sst/opencode-sdk-go/option" + "github.com/sst/opencode-sdk-go/shared" + "github.com/tidwall/gjson" +) + +// SessionService contains methods and other services that help with interacting +// with the opencode API. +// +// Note, unlike clients, this service does not read variables from the environment +// automatically. You should not instantiate this service directly, and instead use +// the [NewSessionService] method instead. +type SessionService struct { + Options []option.RequestOption +} + +// NewSessionService generates a new service that applies the given options to each +// request. These options are applied after the parent client's options (if there +// is one), and before any request-specific options. +func NewSessionService(opts ...option.RequestOption) (r *SessionService) { + r = &SessionService{} + r.Options = opts + return +} + +// Create a new session +func (r *SessionService) New(ctx context.Context, opts ...option.RequestOption) (res *Session, err error) { + opts = append(r.Options[:], opts...) + path := "session" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...) + return +} + +// List all sessions +func (r *SessionService) List(ctx context.Context, opts ...option.RequestOption) (res *[]Session, err error) { + opts = append(r.Options[:], opts...) + path := "session" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + +// Delete a session and all its data +func (r *SessionService) Delete(ctx context.Context, id string, opts ...option.RequestOption) (res *bool, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("session/%s", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, &res, opts...) + return +} + +// Abort a session +func (r *SessionService) Abort(ctx context.Context, id string, opts ...option.RequestOption) (res *bool, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("session/%s/abort", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...) + return +} + +// Create and send a new message to a session +func (r *SessionService) Chat(ctx context.Context, id string, body SessionChatParams, opts ...option.RequestOption) (res *Message, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("session/%s/message", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return +} + +// Analyze the app and create an AGENTS.md file +func (r *SessionService) Init(ctx context.Context, id string, body SessionInitParams, opts ...option.RequestOption) (res *bool, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("session/%s/init", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return +} + +// List messages for a session +func (r *SessionService) Messages(ctx context.Context, id string, opts ...option.RequestOption) (res *[]Message, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("session/%s/message", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + +// Share a session +func (r *SessionService) Share(ctx context.Context, id string, opts ...option.RequestOption) (res *Session, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("session/%s/share", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...) + return +} + +// Summarize the session +func (r *SessionService) Summarize(ctx context.Context, id string, body SessionSummarizeParams, opts ...option.RequestOption) (res *bool, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("session/%s/summarize", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return +} + +// Unshare the session +func (r *SessionService) Unshare(ctx context.Context, id string, opts ...option.RequestOption) (res *Session, err error) { + opts = append(r.Options[:], opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("session/%s/share", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, &res, opts...) + return +} + +type FilePart struct { + MediaType string `json:"mediaType,required"` + Type FilePartType `json:"type,required"` + URL string `json:"url,required"` + Filename string `json:"filename"` + JSON filePartJSON `json:"-"` +} + +// filePartJSON contains the JSON metadata for the struct [FilePart] +type filePartJSON struct { + MediaType apijson.Field + Type apijson.Field + URL apijson.Field + Filename apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *FilePart) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r filePartJSON) RawJSON() string { + return r.raw +} + +func (r FilePart) implementsMessagePart() {} + +type FilePartType string + +const ( + FilePartTypeFile FilePartType = "file" +) + +func (r FilePartType) IsKnown() bool { + switch r { + case FilePartTypeFile: + return true + } + return false +} + +type FilePartParam struct { + MediaType param.Field[string] `json:"mediaType,required"` + Type param.Field[FilePartType] `json:"type,required"` + URL param.Field[string] `json:"url,required"` + Filename param.Field[string] `json:"filename"` +} + +func (r FilePartParam) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +func (r FilePartParam) implementsMessagePartUnionParam() {} + +type Message struct { + ID string `json:"id,required"` + Metadata MessageMetadata `json:"metadata,required"` + Parts []MessagePart `json:"parts,required"` + Role MessageRole `json:"role,required"` + JSON messageJSON `json:"-"` +} + +// messageJSON contains the JSON metadata for the struct [Message] +type messageJSON struct { + ID apijson.Field + Metadata apijson.Field + Parts apijson.Field + Role apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *Message) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r messageJSON) RawJSON() string { + return r.raw +} + +type MessageMetadata struct { + SessionID string `json:"sessionID,required"` + Time MessageMetadataTime `json:"time,required"` + Tool map[string]MessageMetadataTool `json:"tool,required"` + Assistant MessageMetadataAssistant `json:"assistant"` + Error MessageMetadataError `json:"error"` + Snapshot string `json:"snapshot"` + JSON messageMetadataJSON `json:"-"` +} + +// messageMetadataJSON contains the JSON metadata for the struct [MessageMetadata] +type messageMetadataJSON struct { + SessionID apijson.Field + Time apijson.Field + Tool apijson.Field + Assistant apijson.Field + Error apijson.Field + Snapshot apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *MessageMetadata) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r messageMetadataJSON) RawJSON() string { + return r.raw +} + +type MessageMetadataTime struct { + Created float64 `json:"created,required"` + Completed float64 `json:"completed"` + JSON messageMetadataTimeJSON `json:"-"` +} + +// messageMetadataTimeJSON contains the JSON metadata for the struct +// [MessageMetadataTime] +type messageMetadataTimeJSON struct { + Created apijson.Field + Completed apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *MessageMetadataTime) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r messageMetadataTimeJSON) RawJSON() string { + return r.raw +} + +type MessageMetadataTool struct { + Time MessageMetadataToolTime `json:"time,required"` + Title string `json:"title,required"` + Snapshot string `json:"snapshot"` + ExtraFields map[string]interface{} `json:"-,extras"` + JSON messageMetadataToolJSON `json:"-"` +} + +// messageMetadataToolJSON contains the JSON metadata for the struct +// [MessageMetadataTool] +type messageMetadataToolJSON struct { + Time apijson.Field + Title apijson.Field + Snapshot apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *MessageMetadataTool) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r messageMetadataToolJSON) RawJSON() string { + return r.raw +} + +type MessageMetadataToolTime struct { + End float64 `json:"end,required"` + Start float64 `json:"start,required"` + JSON messageMetadataToolTimeJSON `json:"-"` +} + +// messageMetadataToolTimeJSON contains the JSON metadata for the struct +// [MessageMetadataToolTime] +type messageMetadataToolTimeJSON struct { + End apijson.Field + Start apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *MessageMetadataToolTime) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r messageMetadataToolTimeJSON) RawJSON() string { + return r.raw +} + +type MessageMetadataAssistant struct { + Cost float64 `json:"cost,required"` + ModelID string `json:"modelID,required"` + Path MessageMetadataAssistantPath `json:"path,required"` + ProviderID string `json:"providerID,required"` + System []string `json:"system,required"` + Tokens MessageMetadataAssistantTokens `json:"tokens,required"` + Summary bool `json:"summary"` + JSON messageMetadataAssistantJSON `json:"-"` +} + +// messageMetadataAssistantJSON contains the JSON metadata for the struct +// [MessageMetadataAssistant] +type messageMetadataAssistantJSON struct { + Cost apijson.Field + ModelID apijson.Field + Path apijson.Field + ProviderID apijson.Field + System apijson.Field + Tokens apijson.Field + Summary apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *MessageMetadataAssistant) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r messageMetadataAssistantJSON) RawJSON() string { + return r.raw +} + +type MessageMetadataAssistantPath struct { + Cwd string `json:"cwd,required"` + Root string `json:"root,required"` + JSON messageMetadataAssistantPathJSON `json:"-"` +} + +// messageMetadataAssistantPathJSON contains the JSON metadata for the struct +// [MessageMetadataAssistantPath] +type messageMetadataAssistantPathJSON struct { + Cwd apijson.Field + Root apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *MessageMetadataAssistantPath) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r messageMetadataAssistantPathJSON) RawJSON() string { + return r.raw +} + +type MessageMetadataAssistantTokens struct { + Cache MessageMetadataAssistantTokensCache `json:"cache,required"` + Input float64 `json:"input,required"` + Output float64 `json:"output,required"` + Reasoning float64 `json:"reasoning,required"` + JSON messageMetadataAssistantTokensJSON `json:"-"` +} + +// messageMetadataAssistantTokensJSON contains the JSON metadata for the struct +// [MessageMetadataAssistantTokens] +type messageMetadataAssistantTokensJSON struct { + Cache apijson.Field + Input apijson.Field + Output apijson.Field + Reasoning apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *MessageMetadataAssistantTokens) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r messageMetadataAssistantTokensJSON) RawJSON() string { + return r.raw +} + +type MessageMetadataAssistantTokensCache struct { + Read float64 `json:"read,required"` + Write float64 `json:"write,required"` + JSON messageMetadataAssistantTokensCacheJSON `json:"-"` +} + +// messageMetadataAssistantTokensCacheJSON contains the JSON metadata for the +// struct [MessageMetadataAssistantTokensCache] +type messageMetadataAssistantTokensCacheJSON struct { + Read apijson.Field + Write apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *MessageMetadataAssistantTokensCache) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r messageMetadataAssistantTokensCacheJSON) RawJSON() string { + return r.raw +} + +type MessageMetadataError struct { + // This field can have the runtime type of [shared.ProviderAuthErrorData], + // [shared.UnknownErrorData], [interface{}]. + Data interface{} `json:"data,required"` + Name MessageMetadataErrorName `json:"name,required"` + JSON messageMetadataErrorJSON `json:"-"` + union MessageMetadataErrorUnion +} + +// messageMetadataErrorJSON contains the JSON metadata for the struct +// [MessageMetadataError] +type messageMetadataErrorJSON struct { + Data apijson.Field + Name apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r messageMetadataErrorJSON) RawJSON() string { + return r.raw +} + +func (r *MessageMetadataError) UnmarshalJSON(data []byte) (err error) { + *r = MessageMetadataError{} + err = apijson.UnmarshalRoot(data, &r.union) + if err != nil { + return err + } + return apijson.Port(r.union, &r) +} + +// AsUnion returns a [MessageMetadataErrorUnion] interface which you can cast to +// the specific types for more type safety. +// +// Possible runtime types of the union are [shared.ProviderAuthError], +// [shared.UnknownError], [MessageMetadataErrorMessageOutputLengthError]. +func (r MessageMetadataError) AsUnion() MessageMetadataErrorUnion { + return r.union +} + +// Union satisfied by [shared.ProviderAuthError], [shared.UnknownError] or +// [MessageMetadataErrorMessageOutputLengthError]. +type MessageMetadataErrorUnion interface { + ImplementsMessageMetadataError() +} + +func init() { + apijson.RegisterUnion( + reflect.TypeOf((*MessageMetadataErrorUnion)(nil)).Elem(), + "name", + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(shared.ProviderAuthError{}), + DiscriminatorValue: "ProviderAuthError", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(shared.UnknownError{}), + DiscriminatorValue: "UnknownError", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(MessageMetadataErrorMessageOutputLengthError{}), + DiscriminatorValue: "MessageOutputLengthError", + }, + ) +} + +type MessageMetadataErrorMessageOutputLengthError struct { + Data interface{} `json:"data,required"` + Name MessageMetadataErrorMessageOutputLengthErrorName `json:"name,required"` + JSON messageMetadataErrorMessageOutputLengthErrorJSON `json:"-"` +} + +// messageMetadataErrorMessageOutputLengthErrorJSON contains the JSON metadata for +// the struct [MessageMetadataErrorMessageOutputLengthError] +type messageMetadataErrorMessageOutputLengthErrorJSON struct { + Data apijson.Field + Name apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *MessageMetadataErrorMessageOutputLengthError) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r messageMetadataErrorMessageOutputLengthErrorJSON) RawJSON() string { + return r.raw +} + +func (r MessageMetadataErrorMessageOutputLengthError) ImplementsMessageMetadataError() {} + +type MessageMetadataErrorMessageOutputLengthErrorName string + +const ( + MessageMetadataErrorMessageOutputLengthErrorNameMessageOutputLengthError MessageMetadataErrorMessageOutputLengthErrorName = "MessageOutputLengthError" +) + +func (r MessageMetadataErrorMessageOutputLengthErrorName) IsKnown() bool { + switch r { + case MessageMetadataErrorMessageOutputLengthErrorNameMessageOutputLengthError: + return true + } + return false +} + +type MessageMetadataErrorName string + +const ( + MessageMetadataErrorNameProviderAuthError MessageMetadataErrorName = "ProviderAuthError" + MessageMetadataErrorNameUnknownError MessageMetadataErrorName = "UnknownError" + MessageMetadataErrorNameMessageOutputLengthError MessageMetadataErrorName = "MessageOutputLengthError" +) + +func (r MessageMetadataErrorName) IsKnown() bool { + switch r { + case MessageMetadataErrorNameProviderAuthError, MessageMetadataErrorNameUnknownError, MessageMetadataErrorNameMessageOutputLengthError: + return true + } + return false +} + +type MessageRole string + +const ( + MessageRoleUser MessageRole = "user" + MessageRoleAssistant MessageRole = "assistant" +) + +func (r MessageRole) IsKnown() bool { + switch r { + case MessageRoleUser, MessageRoleAssistant: + return true + } + return false +} + +type MessagePart struct { + Type MessagePartType `json:"type,required"` + Filename string `json:"filename"` + MediaType string `json:"mediaType"` + // This field can have the runtime type of [map[string]interface{}]. + ProviderMetadata interface{} `json:"providerMetadata"` + SourceID string `json:"sourceId"` + Text string `json:"text"` + Title string `json:"title"` + // This field can have the runtime type of [ToolInvocationPartToolInvocation]. + ToolInvocation interface{} `json:"toolInvocation"` + URL string `json:"url"` + JSON messagePartJSON `json:"-"` + union MessagePartUnion +} + +// messagePartJSON contains the JSON metadata for the struct [MessagePart] +type messagePartJSON struct { + Type apijson.Field + Filename apijson.Field + MediaType apijson.Field + ProviderMetadata apijson.Field + SourceID apijson.Field + Text apijson.Field + Title apijson.Field + ToolInvocation apijson.Field + URL apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r messagePartJSON) RawJSON() string { + return r.raw +} + +func (r *MessagePart) UnmarshalJSON(data []byte) (err error) { + *r = MessagePart{} + err = apijson.UnmarshalRoot(data, &r.union) + if err != nil { + return err + } + return apijson.Port(r.union, &r) +} + +// AsUnion returns a [MessagePartUnion] interface which you can cast to the +// specific types for more type safety. +// +// Possible runtime types of the union are [TextPart], [ReasoningPart], +// [ToolInvocationPart], [SourceURLPart], [FilePart], [StepStartPart]. +func (r MessagePart) AsUnion() MessagePartUnion { + return r.union +} + +// Union satisfied by [TextPart], [ReasoningPart], [ToolInvocationPart], +// [SourceURLPart], [FilePart] or [StepStartPart]. +type MessagePartUnion interface { + implementsMessagePart() +} + +func init() { + apijson.RegisterUnion( + reflect.TypeOf((*MessagePartUnion)(nil)).Elem(), + "type", + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(TextPart{}), + DiscriminatorValue: "text", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(ReasoningPart{}), + DiscriminatorValue: "reasoning", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(ToolInvocationPart{}), + DiscriminatorValue: "tool-invocation", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(SourceURLPart{}), + DiscriminatorValue: "source-url", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(FilePart{}), + DiscriminatorValue: "file", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(StepStartPart{}), + DiscriminatorValue: "step-start", + }, + ) +} + +type MessagePartType string + +const ( + MessagePartTypeText MessagePartType = "text" + MessagePartTypeReasoning MessagePartType = "reasoning" + MessagePartTypeToolInvocation MessagePartType = "tool-invocation" + MessagePartTypeSourceURL MessagePartType = "source-url" + MessagePartTypeFile MessagePartType = "file" + MessagePartTypeStepStart MessagePartType = "step-start" +) + +func (r MessagePartType) IsKnown() bool { + switch r { + case MessagePartTypeText, MessagePartTypeReasoning, MessagePartTypeToolInvocation, MessagePartTypeSourceURL, MessagePartTypeFile, MessagePartTypeStepStart: + return true + } + return false +} + +type MessagePartParam struct { + Type param.Field[MessagePartType] `json:"type,required"` + Filename param.Field[string] `json:"filename"` + MediaType param.Field[string] `json:"mediaType"` + ProviderMetadata param.Field[interface{}] `json:"providerMetadata"` + SourceID param.Field[string] `json:"sourceId"` + Text param.Field[string] `json:"text"` + Title param.Field[string] `json:"title"` + ToolInvocation param.Field[interface{}] `json:"toolInvocation"` + URL param.Field[string] `json:"url"` +} + +func (r MessagePartParam) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +func (r MessagePartParam) implementsMessagePartUnionParam() {} + +// Satisfied by [TextPartParam], [ReasoningPartParam], [ToolInvocationPartParam], +// [SourceURLPartParam], [FilePartParam], [StepStartPartParam], [MessagePartParam]. +type MessagePartUnionParam interface { + implementsMessagePartUnionParam() +} + +type ReasoningPart struct { + Text string `json:"text,required"` + Type ReasoningPartType `json:"type,required"` + ProviderMetadata map[string]interface{} `json:"providerMetadata"` + JSON reasoningPartJSON `json:"-"` +} + +// reasoningPartJSON contains the JSON metadata for the struct [ReasoningPart] +type reasoningPartJSON struct { + Text apijson.Field + Type apijson.Field + ProviderMetadata apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ReasoningPart) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r reasoningPartJSON) RawJSON() string { + return r.raw +} + +func (r ReasoningPart) implementsMessagePart() {} + +type ReasoningPartType string + +const ( + ReasoningPartTypeReasoning ReasoningPartType = "reasoning" +) + +func (r ReasoningPartType) IsKnown() bool { + switch r { + case ReasoningPartTypeReasoning: + return true + } + return false +} + +type ReasoningPartParam struct { + Text param.Field[string] `json:"text,required"` + Type param.Field[ReasoningPartType] `json:"type,required"` + ProviderMetadata param.Field[map[string]interface{}] `json:"providerMetadata"` +} + +func (r ReasoningPartParam) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +func (r ReasoningPartParam) implementsMessagePartUnionParam() {} + +type Session struct { + ID string `json:"id,required"` + Time SessionTime `json:"time,required"` + Title string `json:"title,required"` + Version string `json:"version,required"` + ParentID string `json:"parentID"` + Revert SessionRevert `json:"revert"` + Share SessionShare `json:"share"` + JSON sessionJSON `json:"-"` +} + +// sessionJSON contains the JSON metadata for the struct [Session] +type sessionJSON struct { + ID apijson.Field + Time apijson.Field + Title apijson.Field + Version apijson.Field + ParentID apijson.Field + Revert apijson.Field + Share apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *Session) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r sessionJSON) RawJSON() string { + return r.raw +} + +type SessionTime struct { + Created float64 `json:"created,required"` + Updated float64 `json:"updated,required"` + JSON sessionTimeJSON `json:"-"` +} + +// sessionTimeJSON contains the JSON metadata for the struct [SessionTime] +type sessionTimeJSON struct { + Created apijson.Field + Updated apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *SessionTime) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r sessionTimeJSON) RawJSON() string { + return r.raw +} + +type SessionRevert struct { + MessageID string `json:"messageID,required"` + Part float64 `json:"part,required"` + Snapshot string `json:"snapshot"` + JSON sessionRevertJSON `json:"-"` +} + +// sessionRevertJSON contains the JSON metadata for the struct [SessionRevert] +type sessionRevertJSON struct { + MessageID apijson.Field + Part apijson.Field + Snapshot apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *SessionRevert) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r sessionRevertJSON) RawJSON() string { + return r.raw +} + +type SessionShare struct { + URL string `json:"url,required"` + JSON sessionShareJSON `json:"-"` +} + +// sessionShareJSON contains the JSON metadata for the struct [SessionShare] +type sessionShareJSON struct { + URL apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *SessionShare) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r sessionShareJSON) RawJSON() string { + return r.raw +} + +type SourceURLPart struct { + SourceID string `json:"sourceId,required"` + Type SourceURLPartType `json:"type,required"` + URL string `json:"url,required"` + ProviderMetadata map[string]interface{} `json:"providerMetadata"` + Title string `json:"title"` + JSON sourceURLPartJSON `json:"-"` +} + +// sourceURLPartJSON contains the JSON metadata for the struct [SourceURLPart] +type sourceURLPartJSON struct { + SourceID apijson.Field + Type apijson.Field + URL apijson.Field + ProviderMetadata apijson.Field + Title apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *SourceURLPart) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r sourceURLPartJSON) RawJSON() string { + return r.raw +} + +func (r SourceURLPart) implementsMessagePart() {} + +type SourceURLPartType string + +const ( + SourceURLPartTypeSourceURL SourceURLPartType = "source-url" +) + +func (r SourceURLPartType) IsKnown() bool { + switch r { + case SourceURLPartTypeSourceURL: + return true + } + return false +} + +type SourceURLPartParam struct { + SourceID param.Field[string] `json:"sourceId,required"` + Type param.Field[SourceURLPartType] `json:"type,required"` + URL param.Field[string] `json:"url,required"` + ProviderMetadata param.Field[map[string]interface{}] `json:"providerMetadata"` + Title param.Field[string] `json:"title"` +} + +func (r SourceURLPartParam) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +func (r SourceURLPartParam) implementsMessagePartUnionParam() {} + +type StepStartPart struct { + Type StepStartPartType `json:"type,required"` + JSON stepStartPartJSON `json:"-"` +} + +// stepStartPartJSON contains the JSON metadata for the struct [StepStartPart] +type stepStartPartJSON struct { + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *StepStartPart) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r stepStartPartJSON) RawJSON() string { + return r.raw +} + +func (r StepStartPart) implementsMessagePart() {} + +type StepStartPartType string + +const ( + StepStartPartTypeStepStart StepStartPartType = "step-start" +) + +func (r StepStartPartType) IsKnown() bool { + switch r { + case StepStartPartTypeStepStart: + return true + } + return false +} + +type StepStartPartParam struct { + Type param.Field[StepStartPartType] `json:"type,required"` +} + +func (r StepStartPartParam) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +func (r StepStartPartParam) implementsMessagePartUnionParam() {} + +type TextPart struct { + Text string `json:"text,required"` + Type TextPartType `json:"type,required"` + JSON textPartJSON `json:"-"` +} + +// textPartJSON contains the JSON metadata for the struct [TextPart] +type textPartJSON struct { + Text apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *TextPart) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r textPartJSON) RawJSON() string { + return r.raw +} + +func (r TextPart) implementsMessagePart() {} + +type TextPartType string + +const ( + TextPartTypeText TextPartType = "text" +) + +func (r TextPartType) IsKnown() bool { + switch r { + case TextPartTypeText: + return true + } + return false +} + +type TextPartParam struct { + Text param.Field[string] `json:"text,required"` + Type param.Field[TextPartType] `json:"type,required"` +} + +func (r TextPartParam) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +func (r TextPartParam) implementsMessagePartUnionParam() {} + +type ToolCall struct { + State ToolCallState `json:"state,required"` + ToolCallID string `json:"toolCallId,required"` + ToolName string `json:"toolName,required"` + Args interface{} `json:"args"` + Step float64 `json:"step"` + JSON toolCallJSON `json:"-"` +} + +// toolCallJSON contains the JSON metadata for the struct [ToolCall] +type toolCallJSON struct { + State apijson.Field + ToolCallID apijson.Field + ToolName apijson.Field + Args apijson.Field + Step apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ToolCall) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r toolCallJSON) RawJSON() string { + return r.raw +} + +func (r ToolCall) implementsToolInvocationPartToolInvocation() {} + +type ToolCallState string + +const ( + ToolCallStateCall ToolCallState = "call" +) + +func (r ToolCallState) IsKnown() bool { + switch r { + case ToolCallStateCall: + return true + } + return false +} + +type ToolCallParam struct { + State param.Field[ToolCallState] `json:"state,required"` + ToolCallID param.Field[string] `json:"toolCallId,required"` + ToolName param.Field[string] `json:"toolName,required"` + Args param.Field[interface{}] `json:"args"` + Step param.Field[float64] `json:"step"` +} + +func (r ToolCallParam) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +func (r ToolCallParam) implementsToolInvocationPartToolInvocationUnionParam() {} + +type ToolInvocationPart struct { + ToolInvocation ToolInvocationPartToolInvocation `json:"toolInvocation,required"` + Type ToolInvocationPartType `json:"type,required"` + JSON toolInvocationPartJSON `json:"-"` +} + +// toolInvocationPartJSON contains the JSON metadata for the struct +// [ToolInvocationPart] +type toolInvocationPartJSON struct { + ToolInvocation apijson.Field + Type apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ToolInvocationPart) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r toolInvocationPartJSON) RawJSON() string { + return r.raw +} + +func (r ToolInvocationPart) implementsMessagePart() {} + +type ToolInvocationPartToolInvocation struct { + State ToolInvocationPartToolInvocationState `json:"state,required"` + ToolCallID string `json:"toolCallId,required"` + ToolName string `json:"toolName,required"` + // This field can have the runtime type of [interface{}]. + Args interface{} `json:"args"` + Result string `json:"result"` + Step float64 `json:"step"` + JSON toolInvocationPartToolInvocationJSON `json:"-"` + union ToolInvocationPartToolInvocationUnion +} + +// toolInvocationPartToolInvocationJSON contains the JSON metadata for the struct +// [ToolInvocationPartToolInvocation] +type toolInvocationPartToolInvocationJSON struct { + State apijson.Field + ToolCallID apijson.Field + ToolName apijson.Field + Args apijson.Field + Result apijson.Field + Step apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r toolInvocationPartToolInvocationJSON) RawJSON() string { + return r.raw +} + +func (r *ToolInvocationPartToolInvocation) UnmarshalJSON(data []byte) (err error) { + *r = ToolInvocationPartToolInvocation{} + err = apijson.UnmarshalRoot(data, &r.union) + if err != nil { + return err + } + return apijson.Port(r.union, &r) +} + +// AsUnion returns a [ToolInvocationPartToolInvocationUnion] interface which you +// can cast to the specific types for more type safety. +// +// Possible runtime types of the union are [ToolCall], [ToolPartialCall], +// [ToolResult]. +func (r ToolInvocationPartToolInvocation) AsUnion() ToolInvocationPartToolInvocationUnion { + return r.union +} + +// Union satisfied by [ToolCall], [ToolPartialCall] or [ToolResult]. +type ToolInvocationPartToolInvocationUnion interface { + implementsToolInvocationPartToolInvocation() +} + +func init() { + apijson.RegisterUnion( + reflect.TypeOf((*ToolInvocationPartToolInvocationUnion)(nil)).Elem(), + "state", + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(ToolCall{}), + DiscriminatorValue: "call", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(ToolPartialCall{}), + DiscriminatorValue: "partial-call", + }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(ToolResult{}), + DiscriminatorValue: "result", + }, + ) +} + +type ToolInvocationPartToolInvocationState string + +const ( + ToolInvocationPartToolInvocationStateCall ToolInvocationPartToolInvocationState = "call" + ToolInvocationPartToolInvocationStatePartialCall ToolInvocationPartToolInvocationState = "partial-call" + ToolInvocationPartToolInvocationStateResult ToolInvocationPartToolInvocationState = "result" +) + +func (r ToolInvocationPartToolInvocationState) IsKnown() bool { + switch r { + case ToolInvocationPartToolInvocationStateCall, ToolInvocationPartToolInvocationStatePartialCall, ToolInvocationPartToolInvocationStateResult: + return true + } + return false +} + +type ToolInvocationPartType string + +const ( + ToolInvocationPartTypeToolInvocation ToolInvocationPartType = "tool-invocation" +) + +func (r ToolInvocationPartType) IsKnown() bool { + switch r { + case ToolInvocationPartTypeToolInvocation: + return true + } + return false +} + +type ToolInvocationPartParam struct { + ToolInvocation param.Field[ToolInvocationPartToolInvocationUnionParam] `json:"toolInvocation,required"` + Type param.Field[ToolInvocationPartType] `json:"type,required"` +} + +func (r ToolInvocationPartParam) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +func (r ToolInvocationPartParam) implementsMessagePartUnionParam() {} + +type ToolInvocationPartToolInvocationParam struct { + State param.Field[ToolInvocationPartToolInvocationState] `json:"state,required"` + ToolCallID param.Field[string] `json:"toolCallId,required"` + ToolName param.Field[string] `json:"toolName,required"` + Args param.Field[interface{}] `json:"args"` + Result param.Field[string] `json:"result"` + Step param.Field[float64] `json:"step"` +} + +func (r ToolInvocationPartToolInvocationParam) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +func (r ToolInvocationPartToolInvocationParam) implementsToolInvocationPartToolInvocationUnionParam() { +} + +// Satisfied by [ToolCallParam], [ToolPartialCallParam], [ToolResultParam], +// [ToolInvocationPartToolInvocationParam]. +type ToolInvocationPartToolInvocationUnionParam interface { + implementsToolInvocationPartToolInvocationUnionParam() +} + +type ToolPartialCall struct { + State ToolPartialCallState `json:"state,required"` + ToolCallID string `json:"toolCallId,required"` + ToolName string `json:"toolName,required"` + Args interface{} `json:"args"` + Step float64 `json:"step"` + JSON toolPartialCallJSON `json:"-"` +} + +// toolPartialCallJSON contains the JSON metadata for the struct [ToolPartialCall] +type toolPartialCallJSON struct { + State apijson.Field + ToolCallID apijson.Field + ToolName apijson.Field + Args apijson.Field + Step apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ToolPartialCall) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r toolPartialCallJSON) RawJSON() string { + return r.raw +} + +func (r ToolPartialCall) implementsToolInvocationPartToolInvocation() {} + +type ToolPartialCallState string + +const ( + ToolPartialCallStatePartialCall ToolPartialCallState = "partial-call" +) + +func (r ToolPartialCallState) IsKnown() bool { + switch r { + case ToolPartialCallStatePartialCall: + return true + } + return false +} + +type ToolPartialCallParam struct { + State param.Field[ToolPartialCallState] `json:"state,required"` + ToolCallID param.Field[string] `json:"toolCallId,required"` + ToolName param.Field[string] `json:"toolName,required"` + Args param.Field[interface{}] `json:"args"` + Step param.Field[float64] `json:"step"` +} + +func (r ToolPartialCallParam) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +func (r ToolPartialCallParam) implementsToolInvocationPartToolInvocationUnionParam() {} + +type ToolResult struct { + Result string `json:"result,required"` + State ToolResultState `json:"state,required"` + ToolCallID string `json:"toolCallId,required"` + ToolName string `json:"toolName,required"` + Args interface{} `json:"args"` + Step float64 `json:"step"` + JSON toolResultJSON `json:"-"` +} + +// toolResultJSON contains the JSON metadata for the struct [ToolResult] +type toolResultJSON struct { + Result apijson.Field + State apijson.Field + ToolCallID apijson.Field + ToolName apijson.Field + Args apijson.Field + Step apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ToolResult) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r toolResultJSON) RawJSON() string { + return r.raw +} + +func (r ToolResult) implementsToolInvocationPartToolInvocation() {} + +type ToolResultState string + +const ( + ToolResultStateResult ToolResultState = "result" +) + +func (r ToolResultState) IsKnown() bool { + switch r { + case ToolResultStateResult: + return true + } + return false +} + +type ToolResultParam struct { + Result param.Field[string] `json:"result,required"` + State param.Field[ToolResultState] `json:"state,required"` + ToolCallID param.Field[string] `json:"toolCallId,required"` + ToolName param.Field[string] `json:"toolName,required"` + Args param.Field[interface{}] `json:"args"` + Step param.Field[float64] `json:"step"` +} + +func (r ToolResultParam) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +func (r ToolResultParam) implementsToolInvocationPartToolInvocationUnionParam() {} + +type SessionChatParams struct { + ModelID param.Field[string] `json:"modelID,required"` + Parts param.Field[[]MessagePartUnionParam] `json:"parts,required"` + ProviderID param.Field[string] `json:"providerID,required"` +} + +func (r SessionChatParams) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +type SessionInitParams struct { + ModelID param.Field[string] `json:"modelID,required"` + ProviderID param.Field[string] `json:"providerID,required"` +} + +func (r SessionInitParams) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} + +type SessionSummarizeParams struct { + ModelID param.Field[string] `json:"modelID,required"` + ProviderID param.Field[string] `json:"providerID,required"` +} + +func (r SessionSummarizeParams) MarshalJSON() (data []byte, err error) { + return apijson.MarshalRoot(r) +} diff --git a/packages/tui/sdk/session_test.go b/packages/tui/sdk/session_test.go new file mode 100644 index 00000000..da9fb825 --- /dev/null +++ b/packages/tui/sdk/session_test.go @@ -0,0 +1,259 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/sst/opencode-sdk-go" + "github.com/sst/opencode-sdk-go/internal/testutil" + "github.com/sst/opencode-sdk-go/option" +) + +func TestSessionNew(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Session.New(context.TODO()) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestSessionList(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Session.List(context.TODO()) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestSessionDelete(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Session.Delete(context.TODO(), "id") + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestSessionAbort(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Session.Abort(context.TODO(), "id") + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestSessionChat(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Session.Chat( + context.TODO(), + "id", + opencode.SessionChatParams{ + ModelID: opencode.F("modelID"), + Parts: opencode.F([]opencode.MessagePartUnionParam{opencode.TextPartParam{ + Text: opencode.F("text"), + Type: opencode.F(opencode.TextPartTypeText), + }}), + ProviderID: opencode.F("providerID"), + }, + ) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestSessionInit(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Session.Init( + context.TODO(), + "id", + opencode.SessionInitParams{ + ModelID: opencode.F("modelID"), + ProviderID: opencode.F("providerID"), + }, + ) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestSessionMessages(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Session.Messages(context.TODO(), "id") + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestSessionShare(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Session.Share(context.TODO(), "id") + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestSessionSummarize(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Session.Summarize( + context.TODO(), + "id", + opencode.SessionSummarizeParams{ + ModelID: opencode.F("modelID"), + ProviderID: opencode.F("providerID"), + }, + ) + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestSessionUnshare(t *testing.T) { + t.Skip("skipped: tests are disabled for the time being") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + _, err := client.Session.Unshare(context.TODO(), "id") + if err != nil { + var apierr *opencode.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} diff --git a/packages/tui/sdk/shared/shared.go b/packages/tui/sdk/shared/shared.go new file mode 100644 index 00000000..121f64a5 --- /dev/null +++ b/packages/tui/sdk/shared/shared.go @@ -0,0 +1,132 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package shared + +import ( + "github.com/sst/opencode-sdk-go/internal/apijson" +) + +type ProviderAuthError struct { + Data ProviderAuthErrorData `json:"data,required"` + Name ProviderAuthErrorName `json:"name,required"` + JSON providerAuthErrorJSON `json:"-"` +} + +// providerAuthErrorJSON contains the JSON metadata for the struct +// [ProviderAuthError] +type providerAuthErrorJSON struct { + Data apijson.Field + Name apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ProviderAuthError) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r providerAuthErrorJSON) RawJSON() string { + return r.raw +} + +func (r ProviderAuthError) ImplementsEventListResponseEventSessionErrorPropertiesError() {} + +func (r ProviderAuthError) ImplementsMessageMetadataError() {} + +type ProviderAuthErrorData struct { + Message string `json:"message,required"` + ProviderID string `json:"providerID,required"` + JSON providerAuthErrorDataJSON `json:"-"` +} + +// providerAuthErrorDataJSON contains the JSON metadata for the struct +// [ProviderAuthErrorData] +type providerAuthErrorDataJSON struct { + Message apijson.Field + ProviderID apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ProviderAuthErrorData) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r providerAuthErrorDataJSON) RawJSON() string { + return r.raw +} + +type ProviderAuthErrorName string + +const ( + ProviderAuthErrorNameProviderAuthError ProviderAuthErrorName = "ProviderAuthError" +) + +func (r ProviderAuthErrorName) IsKnown() bool { + switch r { + case ProviderAuthErrorNameProviderAuthError: + return true + } + return false +} + +type UnknownError struct { + Data UnknownErrorData `json:"data,required"` + Name UnknownErrorName `json:"name,required"` + JSON unknownErrorJSON `json:"-"` +} + +// unknownErrorJSON contains the JSON metadata for the struct [UnknownError] +type unknownErrorJSON struct { + Data apijson.Field + Name apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *UnknownError) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r unknownErrorJSON) RawJSON() string { + return r.raw +} + +func (r UnknownError) ImplementsEventListResponseEventSessionErrorPropertiesError() {} + +func (r UnknownError) ImplementsMessageMetadataError() {} + +type UnknownErrorData struct { + Message string `json:"message,required"` + JSON unknownErrorDataJSON `json:"-"` +} + +// unknownErrorDataJSON contains the JSON metadata for the struct +// [UnknownErrorData] +type unknownErrorDataJSON struct { + Message apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *UnknownErrorData) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r unknownErrorDataJSON) RawJSON() string { + return r.raw +} + +type UnknownErrorName string + +const ( + UnknownErrorNameUnknownError UnknownErrorName = "UnknownError" +) + +func (r UnknownErrorName) IsKnown() bool { + switch r { + case UnknownErrorNameUnknownError: + return true + } + return false +} diff --git a/packages/tui/sdk/usage_test.go b/packages/tui/sdk/usage_test.go new file mode 100644 index 00000000..0e261a7a --- /dev/null +++ b/packages/tui/sdk/usage_test.go @@ -0,0 +1,32 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package opencode_test + +import ( + "context" + "os" + "testing" + + "github.com/sst/opencode-sdk-go" + "github.com/sst/opencode-sdk-go/internal/testutil" + "github.com/sst/opencode-sdk-go/option" +) + +func TestUsage(t *testing.T) { + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := opencode.NewClient( + option.WithBaseURL(baseURL), + ) + events, err := client.Event.List(context.TODO()) + if err != nil { + t.Error(err) + return + } + t.Logf("%+v\n", events) +} diff --git a/packages/web/package.json b/packages/web/package.json index 2d69de27..c1722b2b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -24,11 +24,12 @@ "lang-map": "0.4.0", "luxon": "3.6.1", "marked": "15.0.12", + "marked-shiki": "1.2.0", "rehype-autolink-headings": "7.1.0", "sharp": "0.32.5", "shiki": "3.4.2", "solid-js": "1.9.7", - "toolbeam-docs-theme": "0.3.0" + "toolbeam-docs-theme": "0.4.1" }, "devDependencies": { "opencode": "workspace:*", diff --git a/packages/web/src/components/CodeBlock.tsx b/packages/web/src/components/CodeBlock.tsx index 4c6aab48..6a684689 100644 --- a/packages/web/src/components/CodeBlock.tsx +++ b/packages/web/src/components/CodeBlock.tsx @@ -1,8 +1,6 @@ import { type JSX, - onCleanup, splitProps, - createEffect, createResource, } from "solid-js" import { codeToHtml } from "shiki" @@ -12,15 +10,15 @@ import { transformerNotationDiff } from "@shikijs/transformers" interface CodeBlockProps extends JSX.HTMLAttributes { code: string lang?: string - onRendered?: () => void } function CodeBlock(props: CodeBlockProps) { - const [local, rest] = splitProps(props, ["code", "lang", "onRendered"]) - let containerRef!: HTMLDivElement + const [local, rest] = splitProps(props, ["code", "lang"]) const [html] = createResource( () => [local.code, local.lang], async ([code, lang]) => { + // TODO: For testing delays + // await new Promise((resolve) => setTimeout(resolve, 3000)) return (await codeToHtml(code || "", { lang: lang || "text", themes: { @@ -32,25 +30,7 @@ function CodeBlock(props: CodeBlockProps) { }, ) - onCleanup(() => { - if (containerRef) containerRef.innerHTML = "" - }) - - createEffect(() => { - if (html() && containerRef) { - containerRef.innerHTML = html() as string - - local.onRendered?.() - } - }) - - return ( - <> - {html() ? ( -
- ) : null} - - ) + return
} export default CodeBlock diff --git a/packages/web/src/components/Head.astro b/packages/web/src/components/Head.astro index 36f11c95..f6166f58 100644 --- a/packages/web/src/components/Head.astro +++ b/packages/web/src/components/Head.astro @@ -7,13 +7,14 @@ import config from '../../config.mjs' const slug = Astro.url.pathname.replace(/^\//, "").replace(/\/$/, ""); const { entry: { - data: { title }, + data: { title , description }, }, } = Astro.locals.starlightRoute; const isDocs = slug.startsWith("docs") let encodedTitle = ''; let ogImage = `${config.url}/social-share.png`; +let truncatedDesc = ''; if (isDocs) { // Truncate to fit S3's max key size @@ -26,10 +27,19 @@ if (isDocs) { ) ) ); - ogImage = `${config.socialCard}/opencode-docs/${encodedTitle}.png`; + + if (description) { + truncatedDesc = encodeURIComponent(description.substring(0, 400)) + } + + ogImage = `${config.socialCard}/opencode-docs/${encodedTitle}.png?desc=${truncatedDesc}`; } --- +{ slug === "" && ( +{title} | AI coding agent built for the terminal +)} + { (isDocs || !slug.startsWith("s")) && ( diff --git a/packages/web/src/components/MarkdownView.tsx b/packages/web/src/components/MarkdownView.tsx index 5e21c0d7..7a63bc0c 100644 --- a/packages/web/src/components/MarkdownView.tsx +++ b/packages/web/src/components/MarkdownView.tsx @@ -1,21 +1,39 @@ import { type JSX, splitProps, createResource } from "solid-js" import { marked } from "marked" +import markedShiki from "marked-shiki" +import { codeToHtml } from "shiki" +import { transformerNotationDiff } from "@shikijs/transformers" import styles from "./markdownview.module.css" interface MarkdownViewProps extends JSX.HTMLAttributes { markdown: string } +const markedWithShiki = marked.use( + markedShiki({ + highlight(code, lang) { + return codeToHtml(code, { + lang: lang || "text", + themes: { + light: "github-light", + dark: "github-dark", + }, + transformers: [transformerNotationDiff()], + }) + }, + }), +) + function MarkdownView(props: MarkdownViewProps) { const [local, rest] = splitProps(props, ["markdown"]) - const [html] = createResource(() => local.markdown, async (markdown) => { - return marked.parse(markdown) - }) - - return ( -
+ const [html] = createResource( + () => local.markdown, + async (markdown) => { + return markedWithShiki.parse(markdown) + }, ) + + return
} export default MarkdownView - diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index 93aad6e6..ed889790 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -5,11 +5,13 @@ import { Match, Switch, onMount, + Suspense, onCleanup, splitProps, createMemo, createEffect, createSignal, + SuspenseList, } from "solid-js" import map from "lang-map" import { DateTime } from "luxon" @@ -22,7 +24,6 @@ import { IconAnthropic, } from "./icons/custom" import { - IconFolder, IconHashtag, IconSparkles, IconGlobeAlt, @@ -242,6 +243,44 @@ function getStatusText(status: [Status, string?]): string { } } +function checkOverflow(getEl: () => HTMLElement | undefined, watch?: () => any) { + const [needsToggle, setNeedsToggle] = createSignal(false) + + function measure() { + const el = getEl() + if (!el) return + setNeedsToggle(el.scrollHeight > el.clientHeight + 1) + } + + onMount(() => { + let raf = 0 + + function probe() { + const el = getEl() + if (el && el.offsetParent !== null && el.getBoundingClientRect().height) { + measure() + } + else { + raf = requestAnimationFrame(probe) + } + } + raf = requestAnimationFrame(probe) + + const ro = new ResizeObserver(measure) + const el = getEl() + if (el) ro.observe(el) + + onCleanup(() => { + cancelAnimationFrame(raf) + ro.disconnect() + }) + }) + + if (watch) createEffect(measure) + + return needsToggle +} + function ProviderIcon(props: { provider: string; size?: number }) { const size = props.size || 16 return ( @@ -293,49 +332,21 @@ function ResultsButton(props: ResultsButtonProps) { interface TextPartProps extends JSX.HTMLAttributes { text: string expand?: boolean - invert?: boolean - highlight?: boolean } function TextPart(props: TextPartProps) { - const [local, rest] = splitProps(props, [ - "text", - "expand", - "invert", - "highlight", - ]) - const [expanded, setExpanded] = createSignal(false) - const [overflowed, setOverflowed] = createSignal(false) let preEl: HTMLPreElement | undefined - function checkOverflow() { - if (preEl && !local.expand) { - setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1) - } - } - - onMount(() => { - checkOverflow() - window.addEventListener("resize", checkOverflow) - }) - - createEffect(() => { - local.text - setTimeout(checkOverflow, 0) - }) - - onCleanup(() => { - window.removeEventListener("resize", checkOverflow) - }) + const [local, rest] = splitProps(props, ["text", "expand"]) + const [expanded, setExpanded] = createSignal(false) + const overflowed = checkOverflow(() => preEl, () => local.expand) return (
-
 (preEl = el)}>{local.text}
+
{local.text}
{((!local.expand && overflowed()) || expanded()) && (