From 37327259cb3182f2e8594d0b95d6f189cc6a2d0a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 3 Jul 2025 20:30:02 -0400 Subject: [PATCH 01/52] ci: ignore --- bun.lock | 26 ++++++++++++++------------ package.json | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/bun.lock b/bun.lock index a14065e0..200e0587 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": { @@ -462,7 +462,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 +492,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 +604,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=="], @@ -1476,23 +1478,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=="], diff --git a/package.json b/package.json index 93482498..09248dcf 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "devDependencies": { "prettier": "3.5.3", - "sst": "3.17.6" + "sst": "3.17.8" }, "repository": { "type": "git", From 167a9dcaf312c2ceda2ed43e0adecf33d5e98c60 Mon Sep 17 00:00:00 2001 From: Jay V Date: Thu, 3 Jul 2025 20:30:17 -0400 Subject: [PATCH 02/52] docs: share fix scroll to anchor --- packages/web/src/components/Share.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index e1981100..ff838dab 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -957,7 +957,12 @@ export default function Share(props: { onMount(() => { const hash = window.location.hash.slice(1) - if (hash !== "" && hash === anchor()) { + // Wait till all parts are loaded + if ( + hash !== "" + && msg.parts.length === partIndex() + 1 + && data().messages.length === msgIndex() + 1 + ) { scrollToAnchor(hash) } }) From 571d60182a011cc2c71c451d3ddb3243b72cbbd8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 3 Jul 2025 21:17:15 -0400 Subject: [PATCH 03/52] improve snapshotting speed further --- bun.lock | 5 ++ packages/opencode/.gitignore | 1 - packages/opencode/src/snapshot/index.ts | 113 +++++++++--------------- 3 files changed, 46 insertions(+), 73 deletions(-) diff --git a/bun.lock b/bun.lock index 200e0587..a723e36b 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "@openauthjs/openauth": "0.4.3", "@standard-schema/spec": "1.0.0", "ai": "catalog:", + "air": "0.4.14", "decimal.js": "10.5.0", "diff": "8.0.2", "env-paths": "3.0.0", @@ -516,6 +517,8 @@ "ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="], + "air": ["air@0.4.14", "", { "dependencies": { "zephyr": "~1.3.5" } }, "sha512-E8bl9LlSGSQqjxxjeGIrpYpf8jVyJplsdK1bTobh61F7ks+3aLeXL4KbGSJIFsiaSSz5ZExLU51DGztmQSlZTQ=="], + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], @@ -1700,6 +1703,8 @@ "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], + "zephyr": ["zephyr@1.3.6", "", {}, "sha512-oYH52DGZzIbXNrkijskaR8YpVKnXAe8jNgH1KirglVBnTFOn6mK9/0SVCxGn+73l0Hjhr4UYNzYkO07LXSWy6w=="], + "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], "zod-openapi": ["zod-openapi@4.2.4", "", { "peerDependencies": { "zod": "^3.21.4" } }, "sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g=="], 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/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index fcf77c45..95d7776c 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" @@ -19,76 +12,52 @@ export namespace Snapshot { 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, - }) - log.info("found files", { count: files.length }) - // 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, - }) - log.info("initialized") - const status = await statusMatrix({ - fs, - gitdir: git, - dir: app.path.cwd, - }) - log.info("matrix", { - count: status.length, - }) - const added = [] - 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, - }) - continue - } - if (workdir !== head) { - added.push(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 } - log.info("removed files") - await add({ - fs, - gitdir: git, - parallel: true, - dir: app.path.cwd, - filepath: added, - }) + + 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 commit({ - fs, - gitdir: git, - dir: app.path.cwd, - message: "snapshot", - author: { - name: "opencode", - email: "mail@opencode.ai", - }, - }) - log.info("commit", { result }) - return result + + const result = + await $`git --git-dir ${git} commit --allow-empty -m "snapshot" --author="opencode "` + .quiet() + .cwd(app.path.cwd) + .nothrow() + log.info("commit") + + // Extract commit hash from output like "[main abc1234] snapshot" + const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/) + if (!match) throw new Error("Failed to extract commit hash") + 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) { From 121eb24e73ff8121f2f797a8679b842678a5af58 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 4 Jul 2025 12:26:16 +0000 Subject: [PATCH 04/52] Update download stats 2025-07-04 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index e73da4b4..256ffb5d 100644 --- a/STATS.md +++ b/STATS.md @@ -7,3 +7,4 @@ | 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) | From 23788674c81184d3d5ea85cc00b29756102de326 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 08:44:07 -0400 Subject: [PATCH 05/52] disable snapshots temporarily --- packages/opencode/src/snapshot/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 95d7776c..1bbb870f 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -9,6 +9,7 @@ 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) @@ -45,10 +46,9 @@ export namespace Snapshot { .nothrow() log.info("commit") - // Extract commit hash from output like "[main abc1234] snapshot" const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/) - if (!match) throw new Error("Failed to extract commit hash") - return match[1] + if (!match) return + return match![1] } export async function restore(sessionID: string, commit: string) { From 4a0be45d3d685ad952f51ef875c798ec4b3061de Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 4 Jul 2025 10:22:45 -0500 Subject: [PATCH 06/52] chore: document `instructions` configuration option (#670) --- packages/web/src/content/docs/docs/rules.mdx | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/web/src/content/docs/docs/rules.mdx b/packages/web/src/content/docs/docs/rules.mdx index aed08535..b7818d71 100644 --- a/packages/web/src/content/docs/docs/rules.mdx +++ b/packages/web/src/content/docs/docs/rules.mdx @@ -73,3 +73,29 @@ So when opencode starts, it looks for: 2. **Global file** by checking `~/.config/opencode/AGENTS.md` If you have both global and project-specific rules, opencode will combine them together. + +--- + +## Custom Instructions + +You can also specify custom instruction files using the `instructions` configuration in your `opencode.json` or global `~/.config/opencode/config.json`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [".cursor/rules/*.md"] +} +``` + +You can specify multiple files like `CONTRIBUTING.md` and `docs/guidelines.md`, and use glob patterns to match multiple files. + +For example, to reuse your existing Cursor rules: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [".cursor/rules/*.md"] +} +``` + +All instruction files are combined with your `AGENTS.md` files. From f13b0af4912ba062d89b1599281982455de54662 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 4 Jul 2025 19:24:13 +0400 Subject: [PATCH 07/52] docs: Fix invalid json in the mcp example config (#645) --- packages/web/src/content/docs/docs/mcp-servers.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/docs/mcp-servers.mdx b/packages/web/src/content/docs/docs/mcp-servers.mdx index 72c33e8a..0496e31c 100644 --- a/packages/web/src/content/docs/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/docs/mcp-servers.mdx @@ -30,6 +30,7 @@ Add a local MCP servers under `mcp.localmcp`. "enabled": true, "environment": { "MY_ENV_VAR": "my_env_var_value" + } } } } From 163e23a68b4a21e8939f4d280594fc084d3ea4de Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 11:32:12 -0400 Subject: [PATCH 08/52] removed banned command concept --- packages/opencode/src/tool/bash.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 3ef44bd5..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 -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], From 891ed6ebc006703d5a26f89ecc85bd86f9b2133e Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:04:45 -0500 Subject: [PATCH 09/52] fix(tui): slower startup due to file.status --- packages/tui/internal/completions/files-folders.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tui/internal/completions/files-folders.go b/packages/tui/internal/completions/files-folders.go index 8d6b9958..ec298af9 100644 --- a/packages/tui/internal/completions/files-folders.go +++ b/packages/tui/internal/completions/files-folders.go @@ -16,12 +16,11 @@ import ( type filesAndFoldersContextGroup struct { app *app.App - prefix string gitFiles []dialog.CompletionItemI } func (cg *filesAndFoldersContextGroup) GetId() string { - return cg.prefix + return "files" } func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string { @@ -107,9 +106,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries( func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider { cg := &filesAndFoldersContextGroup{ - app: app, - prefix: "file", + app: app, } - cg.gitFiles = cg.getGitFiles() + go func() { + cg.gitFiles = cg.getGitFiles() + }() return cg } From f9abc7c84f2544f5844d795bf835064114734817 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 10:29:40 -0500 Subject: [PATCH 10/52] feat(tui): file attachments --- packages/tui/go.mod | 1 + packages/tui/go.sum | 2 + packages/tui/internal/app/app.go | 67 +- .../tui/internal/components/chat/editor.go | 104 ++- .../internal/components/dialog/complete.go | 15 +- .../tui/internal/components/dialog/find.go | 4 +- .../tui/internal/components/dialog/models.go | 8 +- packages/tui/internal/components/list/list.go | 14 +- .../tui/internal/components/modal/modal.go | 4 +- packages/tui/internal/components/qr/qr.go | 2 +- .../internal/components/textarea/textarea.go | 812 +++++++++++++----- .../tui/internal/layout/flex_example_test.go | 41 - packages/tui/internal/layout/flex_test.go | 90 -- packages/tui/internal/tui/tui.go | 37 +- 14 files changed, 794 insertions(+), 407 deletions(-) delete mode 100644 packages/tui/internal/layout/flex_example_test.go delete mode 100644 packages/tui/internal/layout/flex_test.go diff --git a/packages/tui/go.mod b/packages/tui/go.mod index 043d9fcd..74047af1 100644 --- a/packages/tui/go.mod +++ b/packages/tui/go.mod @@ -37,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 29548273..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= diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 6b59acae..469857ab 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -44,7 +44,7 @@ type SessionClearedMsg struct{} type CompactSessionMsg struct{} type SendMsg struct { Text string - Attachments []Attachment + Attachments []opencode.FilePartParam } type OptimisticMessageAddedMsg struct { Message opencode.Message @@ -217,13 +217,6 @@ func getDefaultModel( 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 @@ -296,24 +289,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{ @@ -326,13 +335,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), }) @@ -346,7 +367,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/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 669ef47d..595fd4d5 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -3,11 +3,14 @@ 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" @@ -37,7 +40,6 @@ type EditorComponent interface { type editorComponent struct { app *app.App textarea textarea.Model - attachments []app.Attachment spinner spinner.Model interruptKeyInDebounce bool } @@ -66,17 +68,43 @@ 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, "/") 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() + 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 + } - // Replace the current token (after last space) + // 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 + fileName := filepath.Base(filePath) + attachment := &textarea.Attachment{ + ID: uuid.NewString(), + Display: "@" + fileName, + URL: fmt.Sprintf("file://%s", filePath), + Filename: fileName, + MediaType: "text/plain", + } + 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 + " ") @@ -128,7 +156,15 @@ func (m *editorComponent) Content(width int) 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") } @@ -195,14 +231,23 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { } 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 - 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...) } @@ -212,18 +257,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.SetValue(m.textarea.Value() + text) + // } return m, nil } @@ -254,12 +304,26 @@ 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 = " " diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go index caf754c7..7ba91dc5 100644 --- a/packages/tui/internal/components/dialog/complete.go +++ b/packages/tui/internal/components/dialog/complete.go @@ -64,7 +64,7 @@ type CompletionProvider interface { type CompletionSelectedMsg struct { SearchString string CompletionValue string - IsCommand bool + ProviderID string } type CompletionDialogCompleteItemMsg struct { @@ -121,9 +121,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 @@ -183,8 +180,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 } } @@ -213,14 +211,11 @@ func (c *completionDialogComponent) IsEmpty() bool { func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd { value := c.pseudoSearchTextArea.Value() - // 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 index 489b9f29..3fc6e599 100644 --- a/packages/tui/internal/components/dialog/find.go +++ b/packages/tui/internal/components/dialog/find.go @@ -124,7 +124,7 @@ func (f *findDialogComponent) View() string { f.list.SetMaxWidth(f.width - 4) inputView := f.textInput.View() inputView = styles.NewStyle(). - Background(t.BackgroundPanel()). + Background(t.BackgroundElement()). Height(1). Width(f.width-4). Padding(0, 0). @@ -171,7 +171,7 @@ func (f *findDialogComponent) Close() tea.Cmd { func createTextInput(existing *textinput.Model) textinput.Model { t := theme.CurrentTheme() - bgColor := t.BackgroundPanel() + bgColor := t.BackgroundElement() textColor := t.Text() textMutedColor := t.TextMuted() 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/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 aa81a83e..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 != "" { @@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string { modalView, background, layout.WithOverlayBorder(), - layout.WithOverlayBorderColor(t.BorderActive()), + layout.WithOverlayBorderColor(t.Primary()), ) } 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/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index 2ca08bb8..c2c92ea7 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]) { @@ -1154,7 +1512,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.transposeLeft() default: - m.insertRunesFromUserInput([]rune(msg.Text)) + m.insertRunesFromUserInput([]rune{msg.Code}) } case pasteMsg: @@ -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/layout/flex_example_test.go b/packages/tui/internal/layout/flex_example_test.go deleted file mode 100644 index a03346eb..00000000 --- a/packages/tui/internal/layout/flex_example_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package layout_test - -import ( - "fmt" - "github.com/sst/opencode/internal/layout" -) - -func ExampleRender_withGap() { - // Create a horizontal layout with 3px gap between items - result := layout.Render( - layout.FlexOptions{ - Direction: layout.Row, - Width: 30, - Height: 1, - Gap: 3, - }, - layout.FlexItem{View: "Item1"}, - layout.FlexItem{View: "Item2"}, - layout.FlexItem{View: "Item3"}, - ) - fmt.Println(result) - // Output: Item1 Item2 Item3 -} - -func ExampleRender_withGapAndJustify() { - // Create a horizontal layout with gap and space-between justification - result := layout.Render( - layout.FlexOptions{ - Direction: layout.Row, - Width: 30, - Height: 1, - Gap: 2, - Justify: layout.JustifySpaceBetween, - }, - layout.FlexItem{View: "A"}, - layout.FlexItem{View: "B"}, - layout.FlexItem{View: "C"}, - ) - fmt.Println(result) - // Output: A B C -} diff --git a/packages/tui/internal/layout/flex_test.go b/packages/tui/internal/layout/flex_test.go deleted file mode 100644 index cad38dc8..00000000 --- a/packages/tui/internal/layout/flex_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package layout - -import ( - "strings" - "testing" -) - -func TestFlexGap(t *testing.T) { - tests := []struct { - name string - opts FlexOptions - items []FlexItem - expected string - }{ - { - name: "Row with gap", - opts: FlexOptions{ - Direction: Row, - Width: 20, - Height: 1, - Gap: 2, - }, - items: []FlexItem{ - {View: "A"}, - {View: "B"}, - {View: "C"}, - }, - expected: "A B C", - }, - { - name: "Column with gap", - opts: FlexOptions{ - Direction: Column, - Width: 1, - Height: 5, - Gap: 1, - Align: AlignStart, - }, - items: []FlexItem{ - {View: "A", FixedSize: 1}, - {View: "B", FixedSize: 1}, - {View: "C", FixedSize: 1}, - }, - expected: "A\n \nB\n \nC", - }, - { - name: "Row with gap and justify space between", - opts: FlexOptions{ - Direction: Row, - Width: 15, - Height: 1, - Gap: 1, - Justify: JustifySpaceBetween, - }, - items: []FlexItem{ - {View: "A"}, - {View: "B"}, - {View: "C"}, - }, - expected: "A B C", - }, - { - name: "No gap specified", - opts: FlexOptions{ - Direction: Row, - Width: 10, - Height: 1, - }, - items: []FlexItem{ - {View: "A"}, - {View: "B"}, - {View: "C"}, - }, - expected: "ABC", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := Render(tt.opts, tt.items...) - // Trim any trailing spaces for comparison - result = strings.TrimRight(result, " ") - expected := strings.TrimRight(tt.expected, " ") - - if result != expected { - t.Errorf("Render() = %q, want %q", result, expected) - } - }) - } -} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 29235229..150a1b26 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -52,7 +52,9 @@ type appModel struct { messages chat.MessagesComponent completions dialog.CompletionDialog commandProvider dialog.CompletionProvider + fileProvider dialog.CompletionProvider showCompletionDialog bool + fileCompletionActive bool leaderBinding *key.Binding isLeaderSequence bool toastManager *toast.ToastManager @@ -180,11 +182,33 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { !a.showCompletionDialog && a.editor.Value() == "" { a.showCompletionDialog = true + a.fileCompletionActive = false updated, cmd := a.editor.Update(msg) a.editor = updated.(chat.EditorComponent) cmds = append(cmds, cmd) + // 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) @@ -194,7 +218,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.showCompletionDialog { switch keyString { - case "tab", "enter", "esc", "ctrl+c": + case "tab", "enter", "esc", "ctrl+c", "up", "down": updated, cmd := a.completions.Update(msg) a.completions = updated.(dialog.CompletionDialog) cmds = append(cmds, cmd) @@ -326,10 +350,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, toast.NewErrorToast(msg.Error()) case app.SendMsg: a.showCompletionDialog = false - 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.", @@ -778,11 +803,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) @@ -954,6 +976,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) func NewModel(app *app.App) tea.Model { commandProvider := completions.NewCommandCompletionProvider(app) + fileProvider := completions.NewFileAndFolderContextGroup(app) messages := chat.NewMessagesComponent(app) editor := chat.NewEditorComponent(app) @@ -972,9 +995,11 @@ func NewModel(app *app.App) tea.Model { messages: messages, completions: completions, commandProvider: commandProvider, + fileProvider: fileProvider, leaderBinding: leaderBinding, isLeaderSequence: false, showCompletionDialog: false, + fileCompletionActive: false, toastManager: toast.NewToastManager(), interruptKeyState: InterruptKeyIdle, fileViewer: fileviewer.New(app), From 94ef341c9dfd59a070ed4c855e973f99009bcf7e Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 10:54:53 -0500 Subject: [PATCH 11/52] feat(tui): render attachments --- .../tui/internal/components/chat/message.go | 7 ++- .../tui/internal/components/chat/messages.go | 47 ++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go index 4dde09ea..9e245c8b 100644 --- a/packages/tui/internal/components/chat/message.go +++ b/packages/tui/internal/components/chat/message.go @@ -223,6 +223,7 @@ func renderText( showToolDetails bool, highlight bool, width int, + extra string, toolCalls ...opencode.ToolInvocationPart, ) string { t := theme.CurrentTheme() @@ -269,7 +270,11 @@ 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: diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 52288078..d8060c7d 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -9,6 +9,7 @@ import ( "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" @@ -133,10 +134,49 @@ func (m *messagesComponent) renderView(width int) { switch message.Role { case opencode.MessageRoleUser: - for _, part := range message.Parts { + for partIndex, part := range message.Parts { switch part := part.AsUnion().(type) { case opencode.TextPart: - key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount) + 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" + case "application/pdf": + mediaType = "pdf" + } + + 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.Row, + Gap: 3, + }, + 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( @@ -147,6 +187,7 @@ func (m *messagesComponent) renderView(width int) { m.showToolDetails, m.partCount == m.selectedPart, width, + files, ) m.cache.Set(key, content) } @@ -206,6 +247,7 @@ func (m *messagesComponent) renderView(width int) { m.showToolDetails, m.partCount == m.selectedPart, width, + "", toolCallParts..., ) m.cache.Set(key, content) @@ -219,6 +261,7 @@ func (m *messagesComponent) renderView(width int) { m.showToolDetails, m.partCount == m.selectedPart, width, + "", toolCallParts..., ) } From f6108b7be87c06e8fbebb7f52c71ad54438742af Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:13:09 -0500 Subject: [PATCH 12/52] fix(tui): handle pdf and image @ files --- packages/tui/internal/components/chat/editor.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 595fd4d5..427fcc3c 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -93,12 +93,24 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // The cursor is now at `atIndex` after the replacement. filePath := msg.CompletionValue fileName := filepath.Base(filePath) + 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: "@" + fileName, URL: fmt.Sprintf("file://%s", filePath), Filename: fileName, - MediaType: "text/plain", + MediaType: mediaType, } m.textarea.InsertAttachment(attachment) m.textarea.InsertString(" ") From 32d5db4f0a0b0c1a90ba4301cbf0bb7bc2519613 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:16:38 -0500 Subject: [PATCH 13/52] fix(tui): markdown wrapping off sometimes --- packages/tui/internal/util/file.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/internal/util/file.go b/packages/tui/internal/util/file.go index 2c0987dc..b079f24c 100644 --- a/packages/tui/internal/util/file.go +++ b/packages/tui/internal/util/file.go @@ -83,7 +83,7 @@ func Extension(path string) string { } func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string { - r := styles.GetMarkdownRenderer(width-7, backgroundColor) + r := styles.GetMarkdownRenderer(width-6, backgroundColor) content = strings.ReplaceAll(content, RootPath+"/", "") rendered, _ := r.Render(content) lines := strings.Split(rendered, "\n") From ee01f01271f1e8c04a0efeacad0c36a44fd18515 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 12:16:55 -0400 Subject: [PATCH 14/52] file attachments --- packages/opencode/src/session/index.ts | 64 +++++++++++++++---- .../tui/internal/components/chat/editor.go | 2 +- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 5a2c1b5e..437ce09b 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,4 +1,4 @@ -import path from "path" +import path from "node:path" import { App } from "../app/app" import { Identifier } from "../id/id" import { Storage } from "../storage/storage" @@ -15,6 +15,7 @@ import { type UIMessage, type ProviderMetadata, wrapLanguageModel, + type Attachment, } from "ai" import { z, ZodSchema } from "zod" import { Decimal } from "decimal.js" @@ -187,7 +188,6 @@ export namespace Session { export async function unshare(id: string) { const share = await getShare(id) if (!share) return - console.log("share", share) await Storage.remove("session/share/" + id) await update(id, (draft) => { draft.share = undefined @@ -361,6 +361,36 @@ export namespace Session { if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id) const app = App.info() + input.parts = await Promise.all( + input.parts.map(async (part) => { + if (part.type === "file") { + const url = new URL(part.url) + switch (url.protocol) { + case "file:": + let content = await Bun.file( + path.join(app.path.cwd, url.pathname), + ).text() + const range = { + start: url.searchParams.get("start"), + end: url.searchParams.get("end"), + } + if (range.start != null) { + const lines = content.split("\n") + const start = parseInt(range.start) + const end = range.end ? parseInt(range.end) : lines.length + content = lines.slice(start, end).join("\n") + } + return { + type: "file", + url: "data:text/plain;base64," + btoa(content), + mediaType: "text/plain", + filename: part.filename, + } + } + } + return part + }), + ) if (msgs.length === 0 && !session.parentID) { generateText({ maxTokens: input.providerID === "google" ? 1024 : 20, @@ -376,7 +406,7 @@ export namespace Session { { role: "user", content: "", - parts: toParts(input.parts), + ...toParts(input.parts), }, ]), ], @@ -1028,7 +1058,7 @@ function toUIMessage(msg: Message.Info): UIMessage { id: msg.id, role: "assistant", content: "", - parts: toParts(msg.parts), + ...toParts(msg.parts), } } @@ -1037,35 +1067,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/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 427fcc3c..99925e16 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -108,7 +108,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { attachment := &textarea.Attachment{ ID: uuid.NewString(), Display: "@" + fileName, - URL: fmt.Sprintf("file://%s", filePath), + URL: fmt.Sprintf("file://./%s", filePath), Filename: fileName, MediaType: mediaType, } From b8d276a0494457dd59cd74ae57813ad23e432563 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:42:22 -0500 Subject: [PATCH 15/52] fix(tui): full paths for attachments --- packages/tui/internal/components/chat/editor.go | 7 ++++--- packages/tui/internal/components/chat/messages.go | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 99925e16..1516c0c2 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -109,7 +109,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ID: uuid.NewString(), Display: "@" + fileName, URL: fmt.Sprintf("file://./%s", filePath), - Filename: fileName, + Filename: filePath, MediaType: mediaType, } m.textarea.InsertAttachment(attachment) @@ -238,7 +238,8 @@ 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 } @@ -284,7 +285,7 @@ func (m *editorComponent) Paste() (tea.Model, tea.Cmd) { // } // m.attachments = append(m.attachments, attachment) // } else { - m.textarea.SetValue(m.textarea.Value() + text) + m.textarea.InsertString(text) // } return m, nil } diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index d8060c7d..49fdf723 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -156,10 +156,11 @@ func (m *messagesComponent) renderView(width int) { 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), }) @@ -170,8 +171,7 @@ func (m *messagesComponent) renderView(width int) { layout.FlexOptions{ Background: &bgColor, Width: width - 6, - Direction: layout.Row, - Gap: 3, + Direction: layout.Column, }, flexItems..., ) From 06dba28bd69134535ad4a1482b7bbda9f26f96d6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 12:50:41 -0400 Subject: [PATCH 16/52] wip: fix media type --- packages/opencode/src/session/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 437ce09b..2afba471 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -374,7 +374,7 @@ export namespace Session { start: url.searchParams.get("start"), end: url.searchParams.get("end"), } - if (range.start != null) { + if (range.start != null && part.mediaType === "text/plain") { const lines = content.split("\n") const start = parseInt(range.start) const end = range.end ? parseInt(range.end) : lines.length @@ -382,8 +382,8 @@ export namespace Session { } return { type: "file", - url: "data:text/plain;base64," + btoa(content), - mediaType: "text/plain", + url: `data:${part.mediaType};base64,` + btoa(content), + mediaType: part.mediaType, filename: part.filename, } } @@ -406,7 +406,7 @@ export namespace Session { { role: "user", content: "", - ...toParts(input.parts), + parts: toParts(input.parts).parts, }, ]), ], From 143fd8e07635274403874479a53f0b124ac5f433 Mon Sep 17 00:00:00 2001 From: Jay V Date: Fri, 4 Jul 2025 13:33:23 -0400 Subject: [PATCH 17/52] docs: share improve markdown rendering of ai responses --- bun.lock | 8 ++-- packages/web/package.json | 1 + packages/web/src/components/MarkdownView.tsx | 32 ++++++++++--- packages/web/src/components/Share.tsx | 9 +--- .../src/components/markdownview.module.css | 48 +++++++++++++++++-- packages/web/src/components/share.module.css | 7 ++- 6 files changed, 78 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index a723e36b..0fec03e8 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,6 @@ "@openauthjs/openauth": "0.4.3", "@standard-schema/spec": "1.0.0", "ai": "catalog:", - "air": "0.4.14", "decimal.js": "10.5.0", "diff": "8.0.2", "env-paths": "3.0.0", @@ -79,6 +78,7 @@ "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", @@ -517,8 +517,6 @@ "ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="], - "air": ["air@0.4.14", "", { "dependencies": { "zephyr": "~1.3.5" } }, "sha512-E8bl9LlSGSQqjxxjeGIrpYpf8jVyJplsdK1bTobh61F7ks+3aLeXL4KbGSJIFsiaSSz5ZExLU51DGztmQSlZTQ=="], - "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], @@ -1055,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=="], @@ -1703,8 +1703,6 @@ "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], - "zephyr": ["zephyr@1.3.6", "", {}, "sha512-oYH52DGZzIbXNrkijskaR8YpVKnXAe8jNgH1KirglVBnTFOn6mK9/0SVCxGn+73l0Hjhr4UYNzYkO07LXSWy6w=="], - "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], "zod-openapi": ["zod-openapi@4.2.4", "", { "peerDependencies": { "zod": "^3.21.4" } }, "sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g=="], diff --git a/packages/web/package.json b/packages/web/package.json index 383b979f..c1722b2b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -24,6 +24,7 @@ "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", 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 ff838dab..7f2c45b1 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -294,15 +294,11 @@ 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) @@ -332,8 +328,6 @@ function TextPart(props: TextPartProps) { return (
@@ -991,9 +985,9 @@ export default function Share(props: {
@@ -1021,7 +1015,6 @@ export default function Share(props: {
diff --git a/packages/web/src/components/markdownview.module.css b/packages/web/src/components/markdownview.module.css index a4360bde..9524c5cd 100644 --- a/packages/web/src/components/markdownview.module.css +++ b/packages/web/src/components/markdownview.module.css @@ -40,11 +40,17 @@ } pre { - white-space: pre-wrap; - border-radius: 0.25rem; - border: 1px solid rgba(0, 0, 0, 0.2); + --shiki-dark-bg: var(--sl-color-bg-surface) !important; + background-color: var(--sl-color-bg-surface) !important; padding: 0.5rem 0.75rem; + line-height: 1.6; font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + + span { + white-space: break-spaces; + } } code { @@ -61,4 +67,40 @@ } } } + + table { + border-collapse: collapse; + width: 100%; + } + + th, + td { + border: 1px solid var(--sl-color-border); + padding: 0.5rem 0.75rem; + text-align: left; + } + + th { + border-bottom: 1px solid var(--sl-color-border); + } + + /* Remove outer borders */ + table tr:first-child th, + table tr:first-child td { + border-top: none; + } + + table tr:last-child td { + border-bottom: none; + } + + table th:first-child, + table td:first-child { + border-left: none; + } + + table th:last-child, + table td:last-child { + border-right: none; + } } diff --git a/packages/web/src/components/share.module.css b/packages/web/src/components/share.module.css index dafbdd8a..d8eac0e5 100644 --- a/packages/web/src/components/share.module.css +++ b/packages/web/src/components/share.module.css @@ -493,9 +493,8 @@ } } - &[data-highlight="true"] { - background-color: var(--sl-color-blue-low); - } + &[data-background="none"] { background-color: transparent; } + &[data-background="blue"] { background-color: var(--sl-color-blue-low); } &[data-expanded="true"] { pre { @@ -669,7 +668,7 @@ } .message-markdown { - background-color: var(--sl-color-bg-surface); + border: 1px solid var(--sl-color-blue-high); padding: 0.5rem calc(0.5rem + 3px); border-radius: 0.25rem; display: flex; From 994368de15f580d02f54fa244bac6375aece9a46 Mon Sep 17 00:00:00 2001 From: Jay V Date: Fri, 4 Jul 2025 13:53:20 -0400 Subject: [PATCH 18/52] docs: share fix scrolling again --- packages/web/src/components/Share.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index 7f2c45b1..e2e880f6 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -601,6 +601,7 @@ export default function Share(props: { messages: Record }) { let lastScrollY = 0 + let hasScrolledToAnchor = false let scrollTimeout: number | undefined let scrollSentinel: HTMLElement | undefined let scrollObserver: IntersectionObserver | undefined @@ -954,9 +955,11 @@ export default function Share(props: { // Wait till all parts are loaded if ( hash !== "" + && !hasScrolledToAnchor && msg.parts.length === partIndex() + 1 && data().messages.length === msgIndex() + 1 ) { + hasScrolledToAnchor = true scrollToAnchor(hash) } }) From 45b139390caa95038266d97c7de7e5b86fda5e7c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 16:20:12 -0400 Subject: [PATCH 19/52] make file attachments work good like --- packages/opencode/src/session/index.ts | 64 +++++++++++++------ .../tui/internal/components/chat/messages.go | 3 + 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 2afba471..7dd0b325 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -362,35 +362,57 @@ export namespace Session { const app = App.info() input.parts = await Promise.all( - input.parts.map(async (part) => { + input.parts.map(async (part): Promise => { if (part.type === "file") { const url = new URL(part.url) switch (url.protocol) { case "file:": - let content = await Bun.file( - path.join(app.path.cwd, url.pathname), - ).text() - const range = { - start: url.searchParams.get("start"), - end: url.searchParams.get("end"), - } - if (range.start != null && part.mediaType === "text/plain") { - const lines = content.split("\n") - const start = parseInt(range.start) - const end = range.end ? parseInt(range.end) : lines.length - content = lines.slice(start, end).join("\n") - } - return { - type: "file", - url: `data:${part.mediaType};base64,` + btoa(content), - mediaType: part.mediaType, - filename: part.filename, + let content = Bun.file(path.join(app.path.cwd, url.pathname)) + + if (part.mediaType === "text/plain") { + let text = await content.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") + } + 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 content.bytes()).toString("base64url"), + mediaType: part.mediaType, + filename: path.basename(part.filename!), + }, + ] } } - return part + return [part] }), - ) + ).then((x) => x.flat()) if (msgs.length === 0 && !session.parentID) { generateText({ maxTokens: input.providerID === "google" ? 1024 : 20, diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 49fdf723..3d001130 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -134,6 +134,7 @@ func (m *messagesComponent) renderView(width int) { switch message.Role { case opencode.MessageRoleUser: + userLoop: for partIndex, part := range message.Parts { switch part := part.AsUnion().(type) { case opencode.TextPart: @@ -195,6 +196,8 @@ func (m *messagesComponent) renderView(width int) { m = m.updateSelected(content, part.Text) blocks = append(blocks, content) } + // Only render the first text part + break userLoop } } From 997cb2d945278ea8c37506b96d30024e2fc6a68b Mon Sep 17 00:00:00 2001 From: Timo Clasen Date: Fri, 4 Jul 2025 23:06:57 +0200 Subject: [PATCH 20/52] fix(tui): optimistic rendering (#692) --- packages/tui/internal/components/chat/messages.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 3d001130..a59b5d79 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -68,11 +68,9 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectedPart = -1 return m, nil case app.OptimisticMessageAddedMsg: - m.renderView(m.width) - 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 From 85214d7c598959be5d4723625cb4f559645eadc2 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 17:21:36 -0400 Subject: [PATCH 21/52] fix input bar not rendering capital letters --- packages/tui/internal/components/textarea/textarea.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index c2c92ea7..5ff936f1 100644 --- a/packages/tui/internal/components/textarea/textarea.go +++ b/packages/tui/internal/components/textarea/textarea.go @@ -1512,7 +1512,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.transposeLeft() default: - m.insertRunesFromUserInput([]rune{msg.Code}) + m.insertRunesFromUserInput([]rune(msg.Text)) } case pasteMsg: From 107363b1d9f3eec6b180170e428f66162bf622c7 Mon Sep 17 00:00:00 2001 From: Jay V Date: Fri, 4 Jul 2025 17:57:10 -0400 Subject: [PATCH 22/52] docs: fix show more in share page --- packages/web/src/components/Share.tsx | 167 +++++++------------ packages/web/src/components/share.module.css | 2 +- 2 files changed, 62 insertions(+), 107 deletions(-) diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index e2e880f6..ed889790 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -243,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 ( @@ -296,34 +334,11 @@ interface TextPartProps extends JSX.HTMLAttributes { expand?: boolean } function TextPart(props: TextPartProps) { - const [local, rest] = splitProps(props, [ - "text", - "expand", - ]) - 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 - local.expand - 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()) && ( - ) -} - -interface TextPartProps extends JSX.HTMLAttributes { - text: string - expand?: boolean -} -function TextPart(props: TextPartProps) { - let preEl: HTMLPreElement | undefined - - const [local, rest] = splitProps(props, ["text", "expand"]) - const [expanded, setExpanded] = createSignal(false) - const overflowed = checkOverflow(() => preEl, () => local.expand) - - return ( -
-
{local.text}
- {((!local.expand && overflowed()) || expanded()) && ( - - )} -
- ) -} - -interface ErrorPartProps extends JSX.HTMLAttributes { - expand?: boolean -} -function ErrorPart(props: ErrorPartProps) { - let preEl: HTMLDivElement | undefined - - const [local, rest] = splitProps(props, ["expand", "children"]) - const [expanded, setExpanded] = createSignal(false) - const overflowed = checkOverflow(() => preEl, () => local.expand) - - return ( -
-
- {local.children} -
- {((!local.expand && overflowed()) || expanded()) && ( - - )} -
- ) -} - -interface MarkdownPartProps extends JSX.HTMLAttributes { - text: string - expand?: boolean - highlight?: boolean -} -function MarkdownPart(props: MarkdownPartProps) { - let divEl: HTMLDivElement | undefined - - const [local, rest] = splitProps(props, ["text", "expand", "highlight"]) - const [expanded, setExpanded] = createSignal(false) - const overflowed = checkOverflow(() => divEl, () => local.expand) - - return ( -
- (divEl = el)} - /> - {((!local.expand && overflowed()) || expanded()) && ( - - )} -
- ) -} - -interface TerminalPartProps extends JSX.HTMLAttributes { - command: string - error?: string - result?: string - desc?: string - expand?: boolean -} -function TerminalPart(props: TerminalPartProps) { - const [local, rest] = splitProps(props, [ - "command", - "error", - "result", - "desc", - "expand", - ]) - let preEl: HTMLDivElement | undefined - - const [expanded, setExpanded] = createSignal(false) - const overflowed = checkOverflow( - () => { - if (!preEl) return - return preEl.getElementsByTagName("pre")[0] - }, - () => local.expand - ) - - return ( -
-
-
- {local.desc} -
-
- - - - - - - - - -
-
- {((!local.expand && overflowed()) || expanded()) && ( - - )} -
- ) -} - -function ToolFooter(props: { time: number }) { - return props.time > MIN_DURATION ? ( - - {formatDuration(props.time)} - - ) : ( -
- ) -} - -interface AnchorProps extends JSX.HTMLAttributes { - id: string -} -function AnchorIcon(props: AnchorProps) { - const [local, rest] = splitProps(props, ["id", "children"]) - const [copied, setCopied] = createSignal(false) - - return ( - - ) -} - export default function Share(props: { id: string api: string info: Session.Info - messages: Record + messages: Record }) { let lastScrollY = 0 let hasScrolledToAnchor = false @@ -571,14 +85,10 @@ export default function Share(props: { const [store, setStore] = createStore<{ info?: Session.Info - messages: Record + messages: Record }>({ info: props.info, messages: props.messages }) - const messages = createMemo(() => - Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id)), - ) - const [connectionStatus, setConnectionStatus] = createSignal< - [Status, string?] - >(["disconnected", "Disconnected"]) + const messages = createMemo(() => Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id))) + const [connectionStatus, setConnectionStatus] = createSignal<[Status, string?]>(["disconnected", "Disconnected"]) onMount(() => { const apiUrl = props.api @@ -653,10 +163,7 @@ export default function Share(props: { // Try to reconnect after 2 seconds clearTimeout(reconnectTimer) - reconnectTimer = window.setTimeout( - setupWebSocket, - 2000, - ) as unknown as number + reconnectTimer = window.setTimeout(setupWebSocket, 2000) as unknown as number } } @@ -754,7 +261,7 @@ export default function Share(props: { rootDir: undefined as string | undefined, created: undefined as number | undefined, completed: undefined as number | undefined, - messages: [] as Message.Info[], + messages: [] as MessageV2.Info[], models: {} as Record, cost: 0, tokens: { @@ -766,46 +273,41 @@ export default function Share(props: { result.created = props.info.time.created - for (let i = 0; i < messages().length; i++) { - const msg = messages()[i] - - const assistant = msg.metadata?.assistant + const msgs = messages() + for (let i = 0; i < msgs.length; i++) { + const msg = "metadata" in msgs[i] ? fromV1(msgs[i] as Message.Info) : (msgs[i] as MessageV2.Info) result.messages.push(msg) - if (assistant) { - result.cost += assistant.cost - result.tokens.input += assistant.tokens.input - result.tokens.output += assistant.tokens.output - result.tokens.reasoning += assistant.tokens.reasoning + if (msg.role === "assistant") { + result.cost += msg.cost + result.tokens.input += msg.tokens.input + result.tokens.output += msg.tokens.output + result.tokens.reasoning += msg.tokens.reasoning - result.models[`${assistant.providerID} ${assistant.modelID}`] = [ - assistant.providerID, - assistant.modelID, - ] + result.models[`${msg.providerID} ${msg.modelID}`] = [msg.providerID, msg.modelID] - if (assistant.path?.root) { - result.rootDir = assistant.path.root + if (msg.path.root) { + result.rootDir = msg.path.root } - if (msg.metadata?.time.completed) { - result.completed = msg.metadata?.time.completed + if (msg.time.completed) { + result.completed = msg.time.completed } } } + console.log(result.messages) return result }) return ( -
-
-
-

{store.info?.title}

-
-
-
    -
  • -
    +
    +
    +

    {store.info?.title}

    +
    +
      +
    • +
      @@ -815,11 +317,11 @@ export default function Share(props: { {Object.values(data().models).length > 0 ? ( {([provider, model]) => ( -
    • -
      +
    • +
      - {model} + {model}
    • )} @@ -830,1086 +332,52 @@ export default function Share(props: { )}
    -
    - {data().created ? ( - - {DateTime.fromMillis(data().created || 0).toLocaleString( - DateTime.DATETIME_MED, - )} - - ) : ( - - Started at — - - )} +
    + {DateTime.fromMillis(data().created || 0).toLocaleString(DateTime.DATETIME_MED)}
    - 0} - fallback={

    Waiting for messages...

    } - > + 0} fallback={

    Waiting for messages...

    }>
    {(msg, msgIndex) => ( - + { + if (x.type === "step-start" && index > 0) return false + if (x.type === "tool" && x.tool === "todoread") return false + if (x.type === "text" && !x.text) return false + if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running")) + return false + return true + })} + > {(part, partIndex) => { - if ( - (part.type === "step-start" && - (partIndex() > 0 || !msg.metadata?.assistant)) || - (msg.role === "assistant" && - part.type === "tool-invocation" && - part.toolInvocation.toolName === "todoread") + const last = createMemo( + () => data().messages.length === msgIndex() + 1 && msg.parts.length === partIndex() + 1, ) - return null - - const anchor = createMemo( - () => `${msg.id}-${partIndex()}`, - ) - const [showResults, setShowResults] = - createSignal(false) - const isLastPart = createMemo( - () => - data().messages.length === msgIndex() + 1 && - msg.parts.length === partIndex() + 1, - ) - const toolData = createMemo(() => { - if ( - msg.role !== "assistant" || - part.type !== "tool-invocation" - ) - return {} - - const metadata = - msg.metadata?.tool[part.toolInvocation.toolCallId] - const args = part.toolInvocation.args - const result = - part.toolInvocation.state === "result" && - part.toolInvocation.result - const duration = DateTime.fromMillis( - metadata?.time.end || 0, - ) - .diff( - DateTime.fromMillis(metadata?.time.start || 0), - ) - .toMillis() - - return { metadata, args, result, duration } - }) onMount(() => { const hash = window.location.hash.slice(1) // Wait till all parts are loaded if ( - hash !== "" - && !hasScrolledToAnchor - && msg.parts.length === partIndex() + 1 - && data().messages.length === msgIndex() + 1 + hash !== "" && + !hasScrolledToAnchor && + msg.parts.length === partIndex() + 1 && + data().messages.length === msgIndex() + 1 ) { hasScrolledToAnchor = true scrollToAnchor(hash) } }) - return ( - - {/* User text */} - - {(part) => ( -
    -
    - - - -
    -
    -
    - -
    -
    - )} -
    - {/* AI text */} - - {(part) => ( -
    -
    - - - -
    -
    -
    - - - - {DateTime.fromMillis( - data().completed || 0, - ).toLocaleString(DateTime.DATETIME_MED)} - - -
    -
    - )} -
    - {/* AI model */} - - {(assistant) => { - return ( -
    -
    - - - -
    -
    -
    -
    -
    - - {assistant().providerID} - -
    - - {assistant().modelID} - -
    -
    -
    - ) - }} -
    - - {/* Grep tool */} - - {(_part) => { - const matches = () => - toolData()?.metadata?.matches - const splitArgs = () => { - const { pattern, ...rest } = toolData()?.args - return { pattern, rest } - } - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - Grep - - “{splitArgs().pattern}” - -
    - 0 - } - > -
    - - {([name, value]) => ( - <> -
    -
    {name}
    -
    {value}
    - - )} -
    -
    -
    - - 0}> -
    - - setShowResults((e) => !e) - } - /> - - - -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    - ) - }} -
    - {/* Glob tool */} - - {(_part) => { - const count = () => toolData()?.metadata?.count - const pattern = () => toolData()?.args.pattern - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - Glob - “{pattern()}” -
    - - 0}> -
    - - setShowResults((e) => !e) - } - /> - - - -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    - ) - }} -
    - {/* LS tool */} - - {(_part) => { - const path = createMemo(() => - toolData()?.args?.path !== data().rootDir - ? stripWorkingDirectory( - toolData()?.args?.path, - data().rootDir, - ) - : toolData()?.args?.path, - ) - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - LS - - {path()} - -
    - - -
    - - setShowResults((e) => !e) - } - /> - - - -
    -
    -
    -
    - -
    -
    - ) - }} -
    - {/* Read tool */} - - {(_part) => { - const filePath = createMemo(() => - stripWorkingDirectory( - toolData()?.args?.filePath, - data().rootDir, - ), - ) - const hasError = () => - toolData()?.metadata?.error - const preview = () => - toolData()?.metadata?.preview - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - Read - - {filePath()} - -
    - - -
    - - {formatErrorString( - toolData()?.result, - )} - -
    -
    - {/* Always try to show CodeBlock if preview is available (even if empty string) */} - -
    - - setShowResults((e) => !e) - } - /> - -
    - -
    -
    -
    -
    - {/* Fallback to TextPart if preview is not a string (e.g. undefined) AND result exists */} - -
    - - setShowResults((e) => !e) - } - /> - - - -
    -
    -
    -
    - -
    -
    - ) - }} -
    - {/* Write tool */} - - {(_part) => { - const filePath = createMemo(() => - stripWorkingDirectory( - toolData()?.args?.filePath, - data().rootDir, - ), - ) - const hasError = () => - toolData()?.metadata?.error - const content = () => toolData()?.args?.content - const diagnostics = createMemo(() => - getDiagnostics( - toolData()?.metadata?.diagnostics, - toolData()?.args.filePath, - ), - ) - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - Write - - {filePath()} - -
    - 0}> - {diagnostics()} - - - -
    - - {formatErrorString( - toolData()?.result, - )} - -
    -
    - -
    - - setShowResults((e) => !e) - } - /> - -
    - -
    -
    -
    -
    -
    -
    - -
    -
    - ) - }} -
    - {/* Edit tool */} - - {(_part) => { - const diff = () => toolData()?.metadata?.diff - const message = () => - toolData()?.metadata?.message - const hasError = () => - toolData()?.metadata?.error - const filePath = createMemo(() => - stripWorkingDirectory( - toolData()?.args.filePath, - data().rootDir, - ), - ) - const diagnostics = createMemo(() => - getDiagnostics( - toolData()?.metadata?.diagnostics, - toolData()?.args.filePath, - ), - ) - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - Edit - - {filePath()} - -
    - - -
    - - {formatErrorString(message())} - -
    -
    - -
    - -
    -
    -
    - 0}> - {diagnostics()} - -
    - -
    -
    - ) - }} -
    - {/* Bash tool */} - - {(_part) => { - const command = () => - toolData()?.metadata?.title - const desc = () => - toolData()?.metadata?.description - const result = () => - toolData()?.metadata?.stdout - const error = () => toolData()?.metadata?.stderr - - return ( -
    -
    - - - -
    -
    -
    - {command() && ( -
    - -
    - )} - -
    -
    - ) - }} -
    - {/* Todo write */} - - {(_part) => { - const todos = createMemo(() => - sortTodosByStatus( - toolData()?.args?.todos ?? [], - ), - ) - const starting = () => - todos().every((t) => t.status === "pending") - const finished = () => - todos().every((t) => t.status === "completed") - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - - - - Creating plan - - - Completing plan - - - -
    - 0}> -
      - - {(todo) => ( -
    • - - {todo.content} -
    • - )} -
      -
    -
    -
    - -
    -
    - ) - }} -
    - {/* Fetch tool */} - - {(_part) => { - const url = () => toolData()?.args.url - const format = () => toolData()?.args.format - const hasError = () => - toolData()?.metadata?.error - - return ( -
    -
    - - - -
    -
    -
    -
    -
    - Fetch - {url()} -
    - - -
    - - {formatErrorString( - toolData()?.result, - )} - -
    -
    - -
    - - setShowResults((e) => !e) - } - /> - -
    - -
    -
    -
    -
    -
    -
    - -
    -
    - ) - }} -
    - {/* Tool call */} - - {(part) => { - return ( -
    -
    - - - -
    -
    -
    -
    -
    - {part().toolInvocation.toolName} -
    -
    - - {(arg) => ( - <> -
    -
    {arg[0]}
    -
    {arg[1]}
    - - )} -
    -
    - - -
    - - setShowResults((e) => !e) - } - /> - - - -
    -
    - - - -
    -
    - -
    -
    - ) - }} -
    - {/* Fallback */} - -
    -
    - - - } - > - - - - - - - - - -
    -
    -
    -
    -
    - - {part.type} - -
    - -
    -
    -
    -
    -
    - ) + return }}
    @@ -1934,19 +402,11 @@ export default function Share(props: {
  • Input Tokens - {data().tokens.input ? ( - {data().tokens.input} - ) : ( - - )} + {data().tokens.input ? {data().tokens.input} : }
  • Output Tokens - {data().tokens.output ? ( - {data().tokens.output} - ) : ( - - )} + {data().tokens.output ? {data().tokens.output} : }
  • Reasoning Tokens @@ -1972,10 +432,7 @@ export default function Share(props: { "overflow-y": "auto", }} > - 0} - fallback={

    Waiting for messages...

    } - > + 0} fallback={

    Waiting for messages...

    }>
      {(msg) => ( @@ -2003,9 +460,7 @@ export default function Share(props: {
) } + +export function fromV1(v1: Message.Info): MessageV2.Info { + if (v1.role === "assistant") { + const result: MessageV2.Assistant = { + id: v1.id, + sessionID: v1.metadata.sessionID, + role: "assistant", + time: { + created: v1.metadata.time.created, + completed: v1.metadata.time.completed, + }, + cost: v1.metadata.assistant!.cost, + path: v1.metadata.assistant!.path, + summary: v1.metadata.assistant!.summary, + tokens: v1.metadata.assistant!.tokens, + modelID: v1.metadata.assistant!.modelID, + providerID: v1.metadata.assistant!.providerID, + system: v1.metadata.assistant!.system, + error: v1.metadata.error, + parts: v1.parts.flatMap((part): MessageV2.AssistantPart[] => { + if (part.type === "text") { + return [ + { + type: "text", + text: part.text, + }, + ] + } + if (part.type === "step-start") { + return [ + { + type: "step-start", + }, + ] + } + if (part.type === "tool-invocation") { + return [ + { + type: "tool", + id: part.toolInvocation.toolCallId, + tool: part.toolInvocation.toolName, + state: (() => { + if (part.toolInvocation.state === "partial-call") { + return { + status: "pending", + } + } + + const { title, time, ...metadata } = v1.metadata.tool[part.toolInvocation.toolCallId] + if (part.toolInvocation.state === "call") { + return { + status: "running", + input: part.toolInvocation.args, + time: { + start: time.start, + }, + } + } + + if (part.toolInvocation.state === "result") { + return { + status: "completed", + input: part.toolInvocation.args, + output: part.toolInvocation.result, + title, + time, + metadata, + } + } + throw new Error("unknown tool invocation state") + })(), + }, + ] + } + return [] + }), + } + return result + } + + if (v1.role === "user") { + const result: MessageV2.User = { + id: v1.id, + sessionID: v1.metadata.sessionID, + role: "user", + time: { + created: v1.metadata.time.created, + }, + parts: v1.parts.flatMap((part): MessageV2.UserPart[] => { + if (part.type === "text") { + return [ + { + type: "text", + text: part.text, + }, + ] + } + if (part.type === "file") { + return [ + { + type: "file", + mime: part.mediaType, + filename: part.filename, + url: part.url, + }, + ] + } + return [] + }), + } + return result + } + + throw new Error("unknown message type") +} diff --git a/packages/web/src/components/codeblock.module.css b/packages/web/src/components/codeblock.module.css index ddd88ef1..53120120 100644 --- a/packages/web/src/components/codeblock.module.css +++ b/packages/web/src/components/codeblock.module.css @@ -8,4 +8,3 @@ } } } - diff --git a/packages/web/src/components/diffview.module.css b/packages/web/src/components/diffview.module.css deleted file mode 100644 index a748c5d0..00000000 --- a/packages/web/src/components/diffview.module.css +++ /dev/null @@ -1,121 +0,0 @@ -.diff { - display: flex; - flex-direction: column; - border: 1px solid var(--sl-color-divider); - background-color: var(--sl-color-bg-surface); - border-radius: 0.25rem; -} - -.desktopView { - display: block; -} - -.mobileView { - display: none; -} - -.mobileBlock { - display: flex; - flex-direction: column; -} - -.row { - display: grid; - grid-template-columns: 1fr 1fr; - align-items: stretch; -} - -.beforeColumn, -.afterColumn { - display: flex; - flex-direction: column; - overflow-x: visible; - min-width: 0; - align-items: stretch; -} - -.beforeColumn { - border-right: 1px solid var(--sl-color-divider); -} - -.diff > .row:first-child [data-section="cell"]:first-child { - padding-top: 0.5rem; -} - -.diff > .row:last-child [data-section="cell"]:last-child { - padding-bottom: 0.5rem; -} - -[data-section="cell"] { - position: relative; - flex: 1; - display: flex; - flex-direction: column; - - width: 100%; - padding: 0.1875rem 0.5rem 0.1875rem 2.2ch; - margin: 0; - - &[data-display-mobile="true"] { - display: none; - } - - pre { - --shiki-dark-bg: var(--sl-color-bg-surface) !important; - background-color: var(--sl-color-bg-surface) !important; - - white-space: pre-wrap; - word-break: break-word; - - code > span:empty::before { - content: "\00a0"; - white-space: pre; - display: inline-block; - width: 0; - } - } -} - -[data-diff-type="removed"] { - background-color: var(--sl-color-red-low); - - pre { - --shiki-dark-bg: var(--sl-color-red-low) !important; - background-color: var(--sl-color-red-low) !important; - } - - &::before { - content: "-"; - position: absolute; - left: 0.5ch; - user-select: none; - color: var(--sl-color-red-high); - } -} - -[data-diff-type="added"] { - background-color: var(--sl-color-green-low); - - pre { - --shiki-dark-bg: var(--sl-color-green-low) !important; - background-color: var(--sl-color-green-low) !important; - } - - &::before { - content: "+"; - position: absolute; - left: 0.6ch; - user-select: none; - color: var(--sl-color-green-high); - } -} - -@media (max-width: 40rem) { - .desktopView { - display: none; - } - - .mobileView { - display: block; - } -} diff --git a/packages/web/src/components/icons/custom.tsx b/packages/web/src/components/icons/custom.tsx index b4e32d0c..be1e2b4d 100644 --- a/packages/web/src/components/icons/custom.tsx +++ b/packages/web/src/components/icons/custom.tsx @@ -39,7 +39,12 @@ export function IconGemini(props: JSX.SvgSVGAttributes) { export function IconOpencode(props: JSX.SvgSVGAttributes) { return ( - + ) diff --git a/packages/web/src/components/icons/index.tsx b/packages/web/src/components/icons/index.tsx index a788d8f4..62445611 100644 --- a/packages/web/src/components/icons/index.tsx +++ b/packages/web/src/components/icons/index.tsx @@ -3,12 +3,7 @@ import { type JSX } from "solid-js" export function IconAcademicCap(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconAdjustmentsHorizontal( - props: JSX.SvgSVGAttributes, -) { +export function IconAdjustmentsHorizontal(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconAdjustmentsVertical( - props: JSX.SvgSVGAttributes, -) { +export function IconAdjustmentsVertical(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArchiveBoxArrowDown( - props: JSX.SvgSVGAttributes, -) { +export function IconArchiveBoxArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArchiveBoxXMark( - props: JSX.SvgSVGAttributes, -) { +export function IconArchiveBoxXMark(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconArrowDownCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowDownCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconArrowDownOnSquareStack( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowDownOnSquareStack(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowDownOnSquare( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowDownOnSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconArrowDownTray(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconArrowLeftCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowLeftCircle(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowLeftOnRectangle( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowLeftOnRectangle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconArrowLongDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowLongLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowLongRight(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowLongUp(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconArrowPathRoundedSquare( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowPathRoundedSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconArrowRightCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowRightCircle(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowRightOnRectangle( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowRightOnRectangle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconArrowSmallDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowSmallLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconArrowSmallRight( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowSmallRight(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconArrowTopRightOnSquare( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowTopRightOnSquare(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowTrendingDown( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowTrendingDown(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowTrendingUp( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowTrendingUp(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconArrowUpLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconArrowUpOnSquareStack( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowUpOnSquareStack(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowUpOnSquare( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowUpOnSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconArrowUpTray(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowUp(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowUturnDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconArrowUturnLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconArrowUturnRight( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowUturnRight(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconArrowsPointingIn( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowsPointingIn(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowsPointingOut( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowsPointingOut(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconArrowsRightLeft( - props: JSX.SvgSVGAttributes, -) { +export function IconArrowsRightLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconAtSymbol(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBackspace(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBackward(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBanknotes(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBars2(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconBars3BottomLeft( - props: JSX.SvgSVGAttributes, -) { +export function IconBars3BottomLeft(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconBars3BottomRight( - props: JSX.SvgSVGAttributes, -) { +export function IconBars3BottomRight(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconBars3CenterLeft( - props: JSX.SvgSVGAttributes, -) { +export function IconBars3CenterLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconBars4(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBarsArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBarsArrowUp(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBattery0(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBattery100(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBattery50(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBeaker(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBellAlert(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBellSlash(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBellSnooze(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBell(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBoltSlash(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBolt(props: JSX.SvgSVGAttributes) { return ( - + ) { export function IconBoltSolid(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBookOpen(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBookmarkSlash(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBookmarkSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBookmark(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBriefcase(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconBugAnt(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconBuildingLibrary( - props: JSX.SvgSVGAttributes, -) { +export function IconBuildingLibrary(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconBuildingOffice2( - props: JSX.SvgSVGAttributes, -) { +export function IconBuildingOffice2(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconBuildingStorefront( - props: JSX.SvgSVGAttributes, -) { +export function IconBuildingStorefront(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconCalculator(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCalendarDays(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCalendar(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCamera(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconChartBarSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconChartBar(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconChartPie(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconChatBubbleBottomCenterText( - props: JSX.SvgSVGAttributes, -) { +export function IconChatBubbleBottomCenterText(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChatBubbleBottomCenter( - props: JSX.SvgSVGAttributes, -) { +export function IconChatBubbleBottomCenter(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChatBubbleLeftEllipsis( - props: JSX.SvgSVGAttributes, -) { +export function IconChatBubbleLeftEllipsis(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChatBubbleLeftRight( - props: JSX.SvgSVGAttributes, -) { +export function IconChatBubbleLeftRight(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconChatBubbleOvalLeftEllipsis( - props: JSX.SvgSVGAttributes, -) { +export function IconChatBubbleOvalLeftEllipsis(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChatBubbleOvalLeft( - props: JSX.SvgSVGAttributes, -) { +export function IconChatBubbleOvalLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconCheckCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCheck(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconChevronDoubleDown( - props: JSX.SvgSVGAttributes, -) { +export function IconChevronDoubleDown(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChevronDoubleLeft( - props: JSX.SvgSVGAttributes, -) { +export function IconChevronDoubleLeft(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChevronDoubleRight( - props: JSX.SvgSVGAttributes, -) { +export function IconChevronDoubleRight(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconChevronDoubleUp( - props: JSX.SvgSVGAttributes, -) { +export function IconChevronDoubleUp(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconChevronLeft(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconChevronRight(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconChevronUpDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconChevronUp(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCircleStack(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconClipboardDocumentCheck( - props: JSX.SvgSVGAttributes, -) { +export function IconClipboardDocumentCheck(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconClipboardDocumentList( - props: JSX.SvgSVGAttributes, -) { +export function IconClipboardDocumentList(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconClipboardDocument( - props: JSX.SvgSVGAttributes, -) { +export function IconClipboardDocument(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconClock(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCloudArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCloudArrowUp(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCloud(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconCodeBracketSquare( - props: JSX.SvgSVGAttributes, -) { +export function IconCodeBracketSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconCog6Tooth(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCog8Tooth(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCog(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCommandLine(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconComputerDesktop( - props: JSX.SvgSVGAttributes, -) { +export function IconComputerDesktop(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconCreditCard(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconCubeTransparent( - props: JSX.SvgSVGAttributes, -) { +export function IconCubeTransparent(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconCurrencyBangladeshi( - props: JSX.SvgSVGAttributes, -) { +export function IconCurrencyBangladeshi(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconCurrencyEuro(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCurrencyPound(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCurrencyRupee(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconCurrencyYen(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconCursorArrowRays( - props: JSX.SvgSVGAttributes, -) { +export function IconCursorArrowRays(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconCursorArrowRipple( - props: JSX.SvgSVGAttributes, -) { +export function IconCursorArrowRipple(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconDevicePhoneMobile( - props: JSX.SvgSVGAttributes, -) { +export function IconDevicePhoneMobile(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconDocumentArrowDown( - props: JSX.SvgSVGAttributes, -) { +export function IconDocumentArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconDocumentArrowUp( - props: JSX.SvgSVGAttributes, -) { +export function IconDocumentArrowUp(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconDocumentChartBar( - props: JSX.SvgSVGAttributes, -) { +export function IconDocumentChartBar(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { ) } -export function IconDocumentDuplicate( - props: JSX.SvgSVGAttributes, -) { +export function IconDocumentDuplicate(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconDocumentMagnifyingGlass( - props: JSX.SvgSVGAttributes, -) { +export function IconDocumentMagnifyingGlass(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconDocumentPlus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconDocumentText(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconDocument(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconEllipsisHorizontalCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconEllipsisHorizontalCircle(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconEllipsisHorizontal( - props: JSX.SvgSVGAttributes, -) { +export function IconEllipsisHorizontal(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconEllipsisVertical( - props: JSX.SvgSVGAttributes, -) { +export function IconEllipsisVertical(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconEnvelope(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconEnvelopeSolid(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconExclamationCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconExclamationCircle(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconExclamationTriangle( - props: JSX.SvgSVGAttributes, -) { +export function IconExclamationTriangle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconEyeSlash(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconEye(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFaceFrown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFaceSmile(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFilm(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFingerPrint(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFire(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFlag(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconFolderArrowDown( - props: JSX.SvgSVGAttributes, -) { +export function IconFolderArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconFolderOpen(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFolderPlus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFolder(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconForward(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconFunnel(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconGif(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconGiftTop(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconGift(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconGlobeAlt(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconGlobeAmericas(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconGlobeAsiaAustralia( - props: JSX.SvgSVGAttributes, -) { +export function IconGlobeAsiaAustralia(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconGlobeEuropeAfrica( - props: JSX.SvgSVGAttributes, -) { +export function IconGlobeEuropeAfrica(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconHandThumbDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconHandThumbUp(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconHashtag(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconHeart(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconHomeModern(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconHome(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconIdentification(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconInboxArrowDown(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconInboxStack(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconInbox(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconInformationCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconInformationCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconLanguage(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconLifebuoy(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconLightBulb(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconLink(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconListBullet(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconLockClosed(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconLockOpen(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconMagnifyingGlassCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconMagnifyingGlassCircle(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconMagnifyingGlassMinus( - props: JSX.SvgSVGAttributes, -) { +export function IconMagnifyingGlassMinus(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconMagnifyingGlassPlus( - props: JSX.SvgSVGAttributes, -) { +export function IconMagnifyingGlassPlus(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconMagnifyingGlass( - props: JSX.SvgSVGAttributes, -) { +export function IconMagnifyingGlass(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconMap(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMegaphone(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMicrophone(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMinusCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMinusSmall(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMinus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMoon(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMusicalNote(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconNewspaper(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconNoSymbol(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPaintBrush(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPaperAirplane(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPaperClip(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPauseCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPause(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPencilSquare(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPencil(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconPhoneArrowDownLeft( - props: JSX.SvgSVGAttributes, -) { +export function IconPhoneArrowDownLeft(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconPhoneArrowUpRight( - props: JSX.SvgSVGAttributes, -) { +export function IconPhoneArrowUpRight(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconPhone(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPhoto(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPlayCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPlayPause(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPlay(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPlusCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPlusSmall(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPlus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconPower(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconPresentationChartBar( - props: JSX.SvgSVGAttributes, -) { +export function IconPresentationChartBar(props: JSX.SvgSVGAttributes) { return ( - + ) } -export function IconPresentationChartLine( - props: JSX.SvgSVGAttributes, -) { +export function IconPresentationChartLine(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconPuzzlePiece(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconQrCode(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconQuestionMarkCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconQuestionMarkCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconRadio(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconReceiptPercent(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconReceiptRefund(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconRectangleGroup(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconRectangleStack(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconRocketLaunch(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconRss(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconScale(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconScissors(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconServerStack(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconServer(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconShare(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconShieldCheck(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconShieldExclamation( - props: JSX.SvgSVGAttributes, -) { +export function IconShieldExclamation(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconShoppingCart(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSignalSlash(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSignal(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSparkles(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSpeakerWave(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSpeakerXMark(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSquare2Stack(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSquare3Stack3d(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSquares2x2(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSquaresPlus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconStar(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconStopCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconStop(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSun(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSwatch(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTableCells(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTag(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTicket(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTrash(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTrophy(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTruck(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconTv(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconUserCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconUserGroup(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconUserMinus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconUserPlus(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconUser(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconUsers(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconVariable(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconVideoCameraSlash( - props: JSX.SvgSVGAttributes, -) { +export function IconVideoCameraSlash(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconViewColumns(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconViewfinderCircle( - props: JSX.SvgSVGAttributes, -) { +export function IconViewfinderCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconWifi(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconWindow(props: JSX.SvgSVGAttributes) { return ( - + ) { ) } -export function IconWrenchScrewdriver( - props: JSX.SvgSVGAttributes, -) { +export function IconWrenchScrewdriver(props: JSX.SvgSVGAttributes) { return ( - + ) { return ( - + ) { } export function IconXCircle(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconXMark(props: JSX.SvgSVGAttributes) { return ( - + ) { // index export function IconCommand(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconLetter(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconMultiSelect(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSettings(props: JSX.SvgSVGAttributes) { return ( - + ) { } export function IconSingleSelect(props: JSX.SvgSVGAttributes) { return ( - + *:last-child { - margin-bottom: 0; - } - - pre { - --shiki-dark-bg: var(--sl-color-bg-surface) !important; - background-color: var(--sl-color-bg-surface) !important; - padding: 0.5rem 0.75rem; - line-height: 1.6; - font-size: 0.75rem; - white-space: pre-wrap; - word-break: break-word; - - span { - white-space: break-spaces; - } - } - - code { - font-weight: 500; - - &:not(pre code) { - &::before { - content: "`"; - font-weight: 700; - } - &::after { - content: "`"; - font-weight: 700; - } - } - } - - table { - border-collapse: collapse; - width: 100%; - } - - th, - td { - border: 1px solid var(--sl-color-border); - padding: 0.5rem 0.75rem; - text-align: left; - } - - th { - border-bottom: 1px solid var(--sl-color-border); - } - - /* Remove outer borders */ - table tr:first-child th, - table tr:first-child td { - border-top: none; - } - - table tr:last-child td { - border-bottom: none; - } - - table th:first-child, - table td:first-child { - border-left: none; - } - - table th:last-child, - table td:last-child { - border-right: none; - } -} diff --git a/packages/web/src/components/share.module.css b/packages/web/src/components/share.module.css index c339d2b8..14680736 100644 --- a/packages/web/src/components/share.module.css +++ b/packages/web/src/components/share.module.css @@ -15,76 +15,42 @@ --lg-tool-width: 56rem; --term-icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2060%2016'%20preserveAspectRatio%3D'xMidYMid%20meet'%3E%3Ccircle%20cx%3D'8'%20cy%3D'8'%20r%3D'8'%2F%3E%3Ccircle%20cx%3D'30'%20cy%3D'8'%20r%3D'8'%2F%3E%3Ccircle%20cx%3D'52'%20cy%3D'8'%20r%3D'8'%2F%3E%3C%2Fsvg%3E"); -} -[data-element-button-text] { - cursor: pointer; - appearance: none; - background-color: transparent; - border: none; - padding: 0; - color: var(--sl-color-text-secondary); - - &:hover { - color: var(--sl-color-text); - } - - &[data-element-button-more] { + [data-component="header"] { display: flex; - align-items: center; - gap: 0.125rem; - - span[data-button-icon] { - line-height: 1; - opacity: 0.85; - svg { - display: block; - } - } - } -} - -[data-element-label] { - text-transform: uppercase; - letter-spacing: -0.5px; - color: var(--sl-color-text-dimmed); -} - -.header { - display: flex; - flex-direction: column; - gap: 1rem; - - @media (max-width: 30rem) { + flex-direction: column; gap: 1rem; - } - [data-section="title"] { - h1 { - font-size: 2.75rem; - font-weight: 500; - line-height: 1.2; - letter-spacing: -0.05em; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - overflow: hidden; - - @media (max-width: 30rem) { - font-size: 1.75rem; - line-height: 1.25; - -webkit-line-clamp: 3; - } + @media (max-width: 30rem) { + gap: 1rem; } } - [data-section="row"] { + [data-component="header-title"] { + font-size: 2.75rem; + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.05em; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + + @media (max-width: 30rem) { + font-size: 1.75rem; + line-height: 1.25; + -webkit-line-clamp: 3; + } + } + + [data-component="header-details"] { display: flex; flex-direction: column; gap: 0.5rem; } - [data-section="stats"] { + [data-component="header-stats"] { list-style-type: none; padding: 0; margin: 0; @@ -92,41 +58,62 @@ gap: 0.5rem 0.875rem; flex-wrap: wrap; - li { + [data-slot="item"] { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.3125rem; font-size: 0.875rem; span[data-placeholder] { color: var(--sl-color-text-dimmed); } } + + [data-slot="icon"] { + flex: 0 0 auto; + color: var(--sl-color-text-dimmed); + opacity: 0.85; + + svg { + display: block; + } + } + + [data-slot="model"] { + color: var(--sl-color-text); + } } - [data-section="stats"] { - li { - gap: 0.3125rem; + [data-component="header-time"] { + color: var(--sl-color-text-dimmed); + font-size: 0.875rem; + } - [data-stat-icon] { - flex: 0 0 auto; - color: var(--sl-color-text-dimmed); + [data-component="text-button"] { + cursor: pointer; + appearance: none; + background-color: transparent; + border: none; + padding: 0; + color: var(--sl-color-text-secondary); + + &:hover { + color: var(--sl-color-text); + } + + &[data-element-button-more] { + display: flex; + align-items: center; + gap: 0.125rem; + + span[data-button-icon] { + line-height: 1; opacity: 0.85; + svg { display: block; } } - - span[data-stat-model] { - color: var(--sl-color-text); - } - } - } - - [data-section="time"] { - span { - color: var(--sl-color-text-dimmed); - font-size: 0.875rem; } } } @@ -170,10 +157,12 @@ svg:nth-child(3) { display: none; } + &:hover { svg:nth-child(1) { display: none; } + svg:nth-child(2) { display: block; } @@ -213,12 +202,14 @@ opacity: 1; visibility: visible; } + a, a:hover { svg:nth-child(1), svg:nth-child(2) { display: none; } + svg:nth-child(3) { display: block; } @@ -264,7 +255,7 @@ } b { - color: var(--sl-color-text); + color: var(--sl-color-text); word-break: break-all; font-weight: 500; } @@ -348,8 +339,7 @@ } [data-part-type="tool-grep"] { - &:not(:has([data-part-tool-args])) - > [data-section="content"] > [data-part-tool-body] { + &:not(:has([data-part-tool-args])) > [data-section="content"] > [data-part-tool-body] { gap: 0.5rem; } } @@ -374,6 +364,7 @@ } } } + [data-part-type="summary"] { & > [data-section="decoration"] { span:first-child { @@ -388,15 +379,19 @@ &[data-status="connected"] { background-color: var(--sl-color-green); } + &[data-status="connecting"] { background-color: var(--sl-color-orange); } + &[data-status="disconnected"] { background-color: var(--sl-color-divider); } + &[data-status="reconnecting"] { background-color: var(--sl-color-orange); } + &[data-status="error"] { background-color: var(--sl-color-red); } @@ -493,14 +488,20 @@ } } - &[data-background="none"] { background-color: transparent; } - &[data-background="blue"] { background-color: var(--sl-color-blue-low); } + &[data-background="none"] { + background-color: transparent; + } + + &[data-background="blue"] { + background-color: var(--sl-color-blue-low); + } &[data-expanded="true"] { pre { display: block; } } + &[data-expanded="false"] { pre { display: -webkit-box; @@ -536,20 +537,25 @@ span { margin-right: 0.25rem; + &:last-child { margin-right: 0; } } + span[data-color="red"] { color: var(--sl-color-red); } + span[data-color="dimmed"] { color: var(--sl-color-text-dimmed); } + span[data-marker="label"] { text-transform: uppercase; letter-spacing: -0.5px; } + span[data-separator] { margin-right: 0.375rem; } @@ -561,6 +567,7 @@ display: block; } } + &[data-expanded="false"] { [data-section="content"] { display: -webkit-box; @@ -575,7 +582,6 @@ padding: 2px 0; font-size: 0.75rem; } - } .message-terminal { @@ -611,7 +617,7 @@ } &::before { - content: ''; + content: ""; position: absolute; pointer-events: none; top: 8px; @@ -651,6 +657,7 @@ display: block; } } + &[data-expanded="false"] { pre { display: -webkit-box; @@ -693,6 +700,7 @@ display: block; } } + &[data-expanded="false"] { [data-element-markdown] { display: -webkit-box; @@ -750,10 +758,14 @@ &[data-status="pending"] { color: var(--sl-color-text); } + &[data-status="in_progress"] { color: var(--sl-color-text); - & > span { border-color: var(--sl-color-orange); } + & > span { + border-color: var(--sl-color-orange); + } + & > span::before { content: ""; position: absolute; @@ -764,10 +776,14 @@ box-shadow: inset 1rem 1rem var(--sl-color-orange-low); } } + &[data-status="completed"] { color: var(--sl-color-text-secondary); - & > span { border-color: var(--sl-color-green-low); } + & > span { + border-color: var(--sl-color-green-low); + } + & > span::before { content: ""; position: absolute; @@ -798,7 +814,9 @@ display: flex; align-items: center; justify-content: center; - transition: all 0.15s ease, opacity 0.5s ease; + transition: + all 0.15s ease, + opacity 0.5s ease; z-index: 100; appearance: none; opacity: 1; diff --git a/packages/web/src/components/share/common.tsx b/packages/web/src/components/share/common.tsx new file mode 100644 index 00000000..9f5221de --- /dev/null +++ b/packages/web/src/components/share/common.tsx @@ -0,0 +1,60 @@ +import { createSignal, onCleanup, splitProps } from "solid-js" +import type { JSX } from "solid-js/jsx-runtime" +import { IconCheckCircle, IconHashtag } from "../icons" + +interface AnchorProps extends JSX.HTMLAttributes { + id: string +} +export function AnchorIcon(props: AnchorProps) { + const [local, rest] = splitProps(props, ["id", "children"]) + const [copied, setCopied] = createSignal(false) + + return ( + + ) +} + +export function createOverflow() { + const [overflow, setOverflow] = createSignal(false) + return { + get status() { + return overflow() + }, + ref(el: HTMLElement) { + const ro = new ResizeObserver(() => { + if (el.scrollHeight > el.clientHeight + 1) { + setOverflow(true) + } + return + }) + ro.observe(el) + + onCleanup(() => { + ro.disconnect() + }) + }, + } +} diff --git a/packages/web/src/components/share/content-code.module.css b/packages/web/src/components/share/content-code.module.css new file mode 100644 index 00000000..b95f936d --- /dev/null +++ b/packages/web/src/components/share/content-code.module.css @@ -0,0 +1,25 @@ +.root { + max-width: var(--md-tool-width); + border: 1px solid var(--sl-color-divider); + background-color: var(--sl-color-bg-surface); + border-radius: 0.25rem; + padding: 0.5rem calc(0.5rem + 3px); + + &[data-flush="true"] { + border: none; + background-color: transparent; + padding: 0; + } + + pre { + --shiki-dark-bg: var(--sl-color-bg-surface) !important; + line-height: 1.6; + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + + span { + white-space: break-spaces; + } + } +} diff --git a/packages/web/src/components/share/content-code.tsx b/packages/web/src/components/share/content-code.tsx new file mode 100644 index 00000000..b8c4f2cc --- /dev/null +++ b/packages/web/src/components/share/content-code.tsx @@ -0,0 +1,32 @@ +import { type JSX, splitProps, createResource, Suspense } from "solid-js" +import { codeToHtml } from "shiki" +import style from "./content-code.module.css" +import { transformerNotationDiff } from "@shikijs/transformers" + +interface Props { + code: string + lang?: string + flush?: boolean +} +export function ContentCode(props: Props) { + const [html] = createResource( + () => [props.code, props.lang], + async ([code, lang]) => { + // TODO: For testing delays + // await new Promise((resolve) => setTimeout(resolve, 3000)) + return (await codeToHtml(code || "", { + lang: lang || "text", + themes: { + light: "github-light", + dark: "github-dark", + }, + transformers: [transformerNotationDiff()], + })) as string + }, + ) + return ( + +
+ + ) +} diff --git a/packages/web/src/components/share/content-diff.module.css b/packages/web/src/components/share/content-diff.module.css new file mode 100644 index 00000000..718ae369 --- /dev/null +++ b/packages/web/src/components/share/content-diff.module.css @@ -0,0 +1,125 @@ +.root { + display: flex; + flex-direction: column; + border: 1px solid var(--sl-color-divider); + background-color: var(--sl-color-bg-surface); + border-radius: 0.25rem; + + [data-component="desktop"] { + display: block; + } + + [data-component="mobile"] { + display: none; + } + + [data-component="diff-block"] { + display: flex; + flex-direction: column; + } + + [data-component="diff-row"] { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: stretch; + + [data-slot="before"], + [data-slot="after"] { + position: relative; + display: flex; + flex-direction: column; + overflow-x: visible; + min-width: 0; + align-items: stretch; + padding: 0 1rem; + + &[data-diff-type="removed"] { + background-color: var(--sl-color-red-low); + + pre { + --shiki-dark-bg: var(--sl-color-red-low) !important; + background-color: var(--sl-color-red-low) !important; + } + + &::before { + content: "-"; + position: absolute; + left: 0.5ch; + top: 1px; + user-select: none; + color: var(--sl-color-red-high); + } + } + + &[data-diff-type="added"] { + background-color: var(--sl-color-green-low); + + pre { + --shiki-dark-bg: var(--sl-color-green-low) !important; + background-color: var(--sl-color-green-low) !important; + } + + &::before { + content: "+"; + position: absolute; + user-select: none; + color: var(--sl-color-green-high); + left: 0.5ch; + top: 1px; + } + } + } + + [data-slot="before"] { + border-right: 1px solid var(--sl-color-divider); + } + } + + .diff > .row:first-child [data-section="cell"]:first-child { + padding-top: 0.5rem; + } + + .diff > .row:last-child [data-section="cell"]:last-child { + padding-bottom: 0.5rem; + } + + [data-section="cell"] { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + + width: 100%; + padding: 0.1875rem 0.5rem 0.1875rem 2.2ch; + margin: 0; + + &[data-display-mobile="true"] { + display: none; + } + + pre { + --shiki-dark-bg: var(--sl-color-bg-surface) !important; + background-color: var(--sl-color-bg-surface) !important; + + white-space: pre-wrap; + word-break: break-word; + + code > span:empty::before { + content: "\00a0"; + white-space: pre; + display: inline-block; + width: 0; + } + } + } + + @media (max-width: 40rem) { + [data-slot="desktop"] { + display: none; + } + + [data-slot="mobile"] { + display: block; + } + } +} diff --git a/packages/web/src/components/DiffView.tsx b/packages/web/src/components/share/content-diff.tsx similarity index 67% rename from packages/web/src/components/DiffView.tsx rename to packages/web/src/components/share/content-diff.tsx index 66dd7f0f..894145c3 100644 --- a/packages/web/src/components/DiffView.tsx +++ b/packages/web/src/components/share/content-diff.tsx @@ -1,7 +1,7 @@ import { type Component, createMemo } from "solid-js" import { parsePatch } from "diff" -import CodeBlock from "./CodeBlock" -import styles from "./diffview.module.css" +import { ContentCode } from "./content-code" +import styles from "./content-diff.module.css" type DiffRow = { left: string @@ -9,14 +9,12 @@ type DiffRow = { type: "added" | "removed" | "unchanged" | "modified" } -interface DiffViewProps { +interface Props { diff: string lang?: string - class?: string } -const DiffView: Component = (props) => { - +export function ContentDiff(props: Props) { const rows = createMemo(() => { const diffRows: DiffRow[] = [] @@ -33,20 +31,20 @@ const DiffView: Component = (props) => { const content = line.slice(1) const prefix = line[0] - if (prefix === '-') { + if (prefix === "-") { // Look ahead for consecutive additions to pair with removals const removals: string[] = [content] let j = i + 1 // Collect all consecutive removals - while (j < lines.length && lines[j][0] === '-') { + while (j < lines.length && lines[j][0] === "-") { removals.push(lines[j].slice(1)) j++ } // Collect all consecutive additions that follow const additions: string[] = [] - while (j < lines.length && lines[j][0] === '+') { + while (j < lines.length && lines[j][0] === "+") { additions.push(lines[j].slice(1)) j++ } @@ -62,39 +60,39 @@ const DiffView: Component = (props) => { diffRows.push({ left: removals[k], right: additions[k], - type: "modified" + type: "modified", }) } else if (hasLeft) { // Pure removal diffRows.push({ left: removals[k], right: "", - type: "removed" + type: "removed", }) } else if (hasRight) { // Pure addition - only create if we actually have content diffRows.push({ left: "", right: additions[k], - type: "added" + type: "added", }) } } i = j - } else if (prefix === '+') { + } else if (prefix === "+") { // Standalone addition (not paired with removal) diffRows.push({ left: "", right: content, - type: "added" + type: "added", }) i++ - } else if (prefix === ' ') { + } else if (prefix === " ") { diffRows.push({ left: content, right: content, - type: "unchanged" + type: "unchanged", }) i++ } else { @@ -112,7 +110,7 @@ const DiffView: Component = (props) => { }) const mobileRows = createMemo(() => { - const mobileBlocks: { type: 'removed' | 'added' | 'unchanged', lines: string[] }[] = [] + const mobileBlocks: { type: "removed" | "added" | "unchanged"; lines: string[] }[] = [] const currentRows = rows() let i = 0 @@ -121,15 +119,15 @@ const DiffView: Component = (props) => { const addedLines: string[] = [] // Collect consecutive modified/removed/added rows - while (i < currentRows.length && - (currentRows[i].type === 'modified' || - currentRows[i].type === 'removed' || - currentRows[i].type === 'added')) { + while ( + i < currentRows.length && + (currentRows[i].type === "modified" || currentRows[i].type === "removed" || currentRows[i].type === "added") + ) { const row = currentRows[i] - if (row.left && (row.type === 'removed' || row.type === 'modified')) { + if (row.left && (row.type === "removed" || row.type === "modified")) { removedLines.push(row.left) } - if (row.right && (row.type === 'added' || row.type === 'modified')) { + if (row.right && (row.type === "added" || row.type === "modified")) { addedLines.push(row.right) } i++ @@ -137,17 +135,17 @@ const DiffView: Component = (props) => { // Add grouped blocks if (removedLines.length > 0) { - mobileBlocks.push({ type: 'removed', lines: removedLines }) + mobileBlocks.push({ type: "removed", lines: removedLines }) } if (addedLines.length > 0) { - mobileBlocks.push({ type: 'added', lines: addedLines }) + mobileBlocks.push({ type: "added", lines: addedLines }) } // Add unchanged rows as-is - if (i < currentRows.length && currentRows[i].type === 'unchanged') { + if (i < currentRows.length && currentRows[i].type === "unchanged") { mobileBlocks.push({ - type: 'unchanged', - lines: [currentRows[i].left] + type: "unchanged", + lines: [currentRows[i].left], }) i++ } @@ -157,40 +155,29 @@ const DiffView: Component = (props) => { }) return ( -
-
+
+
{rows().map((r) => ( -
-
- +
+
+
-
- +
+
))}
-
+
{mobileRows().map((block) => ( -
+
{block.lines.map((line) => ( - ))}
@@ -200,8 +187,6 @@ const DiffView: Component = (props) => { ) } -export default DiffView - // const testDiff = `--- combined_before.txt 2025-06-24 16:38:08 // +++ combined_after.txt 2025-06-24 16:38:12 // @@ -1,21 +1,25 @@ @@ -210,12 +195,12 @@ export default DiffView // -old content // +added line // +new content -// +// // -removed empty line below // +added empty line above -// +// // - tab indented -// -trailing spaces +// -trailing spaces // -very long line that will definitely wrap in most editors and cause potential alignment issues when displayed in a two column diff view // -unicode content: 🚀 ✨ 中文 // -mixed content with tabs and spaces @@ -226,14 +211,14 @@ export default DiffView // +different unicode: 🎉 💻 日本語 // +normalized content with consistent spacing // +newline to content -// +// // -content to remove -// -whitespace only: +// -whitespace only: // -multiple // -consecutive // -deletions // -single deletion -// + +// + // +single addition // +first addition // +second addition diff --git a/packages/web/src/components/share/content-markdown.module.css b/packages/web/src/components/share/content-markdown.module.css new file mode 100644 index 00000000..da1aa112 --- /dev/null +++ b/packages/web/src/components/share/content-markdown.module.css @@ -0,0 +1,140 @@ +.root { + border: 1px solid var(--sl-color-blue-high); + padding: 0.5rem calc(0.5rem + 3px); + border-radius: 0.25rem; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1rem; + align-self: flex-start; + max-width: var(--md-tool-width); + + &[data-highlight="true"] { + background-color: var(--sl-color-blue-low); + } + + [data-slot="expand-button"] { + flex: 0 0 auto; + padding: 2px 0; + font-size: 0.75rem; + } + + [data-slot="markdown"] { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + + [data-expanded] & { + display: block; + } + + font-size: 0.875rem; + line-height: 1.5; + + p, + blockquote, + ul, + ol, + dl, + table, + pre { + margin-bottom: 1rem; + } + + strong { + font-weight: 600; + } + + ol { + list-style-position: inside; + padding-left: 0.75rem; + } + + ul { + padding-left: 1.5rem; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.5rem; + } + + & > *:last-child { + margin-bottom: 0; + } + + pre { + --shiki-dark-bg: var(--sl-color-bg-surface) !important; + background-color: var(--sl-color-bg-surface) !important; + padding: 0.5rem 0.75rem; + line-height: 1.6; + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + + span { + white-space: break-spaces; + } + } + + code { + font-weight: 500; + + &:not(pre code) { + &::before { + content: "`"; + font-weight: 700; + } + + &::after { + content: "`"; + font-weight: 700; + } + } + } + + table { + border-collapse: collapse; + width: 100%; + } + + th, + td { + border: 1px solid var(--sl-color-border); + padding: 0.5rem 0.75rem; + text-align: left; + } + + th { + border-bottom: 1px solid var(--sl-color-border); + } + + /* Remove outer borders */ + table tr:first-child th, + table tr:first-child td { + border-top: none; + } + + table tr:last-child td { + border-bottom: none; + } + + table th:first-child, + table td:first-child { + border-left: none; + } + + table th:last-child, + table td:last-child { + border-right: none; + } + } +} diff --git a/packages/web/src/components/share/content-markdown.tsx b/packages/web/src/components/share/content-markdown.tsx new file mode 100644 index 00000000..f7927129 --- /dev/null +++ b/packages/web/src/components/share/content-markdown.tsx @@ -0,0 +1,65 @@ +import style from "./content-markdown.module.css" +import { createResource, createSignal } from "solid-js" +import { createOverflow } from "./common" +import { transformerNotationDiff } from "@shikijs/transformers" +import { marked } from "marked" +import markedShiki from "marked-shiki" +import { codeToHtml } from "shiki" + +const markedWithShiki = marked.use( + markedShiki({ + highlight(code, lang) { + return codeToHtml(code, { + lang: lang || "text", + themes: { + light: "github-light", + dark: "github-dark", + }, + transformers: [transformerNotationDiff()], + }) + }, + }), +) + +interface Props { + text: string + expand?: boolean + highlight?: boolean +} +export function ContentMarkdown(props: Props) { + const [html] = createResource( + () => strip(props.text), + async (markdown) => { + return markedWithShiki.parse(markdown) + }, + ) + const [expanded, setExpanded] = createSignal(false) + const overflow = createOverflow() + + return ( +
+
+ + {!props.expand && overflow.status && ( + + )} +
+ ) +} + +function strip(text: string): string { + const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/ + const match = text.match(wrappedRe) + return match ? match[2] : text +} diff --git a/packages/web/src/components/share/content-text.module.css b/packages/web/src/components/share/content-text.module.css new file mode 100644 index 00000000..f8d0b0b9 --- /dev/null +++ b/packages/web/src/components/share/content-text.module.css @@ -0,0 +1,57 @@ +.root { + color: var(--sl-color-text); + background-color: var(--sl-color-bg-surface); + padding: 0.5rem calc(0.5rem + 3px); + border-radius: 0.25rem; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1rem; + align-self: flex-start; + max-width: var(--md-tool-width); + font-size: 0.875rem; + + &[data-compact] { + font-size: 0.75rem; + color: var(--sl-color-text-dimmed); + } + + [data-slot="text"] { + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + + [data-expanded] & { + display: block; + } + } + + [data-slot="expand-button"] { + flex: 0 0 auto; + padding: 2px 0; + font-size: 0.75rem; + } + + &[data-theme="invert"] { + background-color: var(--sl-color-blue-high); + color: var(--sl-color-text-invert); + + [data-slot="expand-button"] { + opacity: 0.85; + color: var(--sl-color-text-invert); + + &:hover { + opacity: 1; + } + } + } + + &[data-theme="blue"] { + background-color: var(--sl-color-blue-low); + } +} diff --git a/packages/web/src/components/share/content-text.tsx b/packages/web/src/components/share/content-text.tsx new file mode 100644 index 00000000..c52e0dfc --- /dev/null +++ b/packages/web/src/components/share/content-text.tsx @@ -0,0 +1,35 @@ +import style from "./content-text.module.css" +import { createSignal } from "solid-js" +import { createOverflow } from "./common" + +interface Props { + text: string + expand?: boolean + compact?: boolean +} +export function ContentText(props: Props) { + const [expanded, setExpanded] = createSignal(false) + const overflow = createOverflow() + + return ( +
+
+        {props.text}
+      
+ {((!props.expand && overflow.status) || expanded()) && ( + + )} +
+ ) +} diff --git a/packages/web/src/components/share/part.module.css b/packages/web/src/components/share/part.module.css new file mode 100644 index 00000000..9145cddf --- /dev/null +++ b/packages/web/src/components/share/part.module.css @@ -0,0 +1,375 @@ +.root { + display: flex; + gap: 0.625rem; + + [data-component="decoration"] { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 0.625rem; + align-items: center; + justify-content: flex-start; + + [data-slot="anchor"] { + position: relative; + + a:first-child { + display: block; + flex: 0 0 auto; + width: 18px; + opacity: 0.65; + + svg { + color: var(--sl-color-text-secondary); + display: block; + + &:nth-child(3) { + color: var(--sl-color-green-high); + } + } + + svg:nth-child(2), + svg:nth-child(3) { + display: none; + } + + &:hover { + svg:nth-child(1) { + display: none; + } + + svg:nth-child(2) { + display: block; + } + } + } + + [data-copied] & { + a, + a:hover { + svg:nth-child(1), + svg:nth-child(2) { + display: none; + } + + svg:nth-child(3) { + display: block; + } + } + } + } + + [data-slot="bar"] { + width: 3px; + height: 100%; + border-radius: 1px; + background-color: var(--sl-color-hairline); + } + + [data-slot="tooltip"] { + position: absolute; + top: 50%; + left: calc(100% + 12px); + transform: translate(0, -50%); + line-height: 1.1; + padding: 0.375em 0.5em calc(0.375em + 2px); + background: var(--sl-color-white); + color: var(--sl-color-text-invert); + font-size: 0.6875rem; + border-radius: 7px; + white-space: nowrap; + + z-index: 1; + opacity: 0; + visibility: hidden; + + &::after { + content: ""; + position: absolute; + top: 50%; + left: -15px; + transform: translateY(-50%); + border: 8px solid transparent; + border-right-color: var(--sl-color-white); + } + + [data-copied] & { + opacity: 1; + visibility: visible; + } + } + } + + [data-component="content"] { + display: flex; + flex-direction: column; + gap: 1rem; + flex-grow: 1; + } + + [data-component="spacer"] { + height: 0rem; + } + + [data-component="content-footer"] { + align-self: flex-start; + font-size: 0.75rem; + color: var(--sl-color-text-dimmed); + } + + [data-component="step-start"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.375rem; + padding-bottom: 1rem; + + [data-slot="provider"] { + line-height: 18px; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: -0.5px; + color: var(--sl-color-text-secondary); + } + + [data-slot="model"] { + line-height: 1.5; + } + } + + [data-component="button-text"] { + cursor: pointer; + appearance: none; + background-color: transparent; + border: none; + padding: 0; + color: var(--sl-color-text-secondary); + font-size: 0.75rem; + + &:hover { + color: var(--sl-color-text); + } + + &[data-more] { + display: flex; + align-items: center; + gap: 0.125rem; + + span[data-slot="icon"] { + line-height: 1; + opacity: 0.85; + + svg { + display: block; + } + } + } + } + + [data-component="tool"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.375rem; + padding-bottom: 1rem; + } + + [data-component="tool-title"] { + line-height: 18px; + font-size: 0.875rem; + color: var(--sl-color-text-secondary); + max-width: var(--md-tool-width); + display: flex; + align-items: flex-start; + gap: 0.375rem; + + [data-slot="name"] { + text-transform: uppercase; + letter-spacing: -0.5px; + } + + [data-slot="target"] { + color: var(--sl-color-text); + word-break: break-all; + font-weight: 500; + } + } + + [data-component="tool-result"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + [data-component="todos"] { + list-style-type: none; + padding: 0; + margin: 0; + width: 100%; + max-width: var(--sm-tool-width); + border: 1px solid var(--sl-color-divider); + border-radius: 0.25rem; + + [data-slot="item"] { + margin: 0; + position: relative; + padding-left: 1.5rem; + font-size: 0.75rem; + padding: 0.375rem 0.625rem 0.375rem 1.75rem; + border-bottom: 1px solid var(--sl-color-divider); + line-height: 1.5; + word-break: break-word; + + &:last-child { + border-bottom: none; + } + + & > span { + position: absolute; + display: inline-block; + left: 0.5rem; + top: calc(0.5rem + 1px); + width: 0.75rem; + height: 0.75rem; + border: 1px solid var(--sl-color-divider); + border-radius: 0.15rem; + + &::before { + } + } + + &[data-status="pending"] { + color: var(--sl-color-text); + } + + &[data-status="in_progress"] { + color: var(--sl-color-text); + + & > span { + border-color: var(--sl-color-orange); + } + + & > span::before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: calc(0.75rem - 2px - 4px); + height: calc(0.75rem - 2px - 4px); + box-shadow: inset 1rem 1rem var(--sl-color-orange-low); + } + } + + &[data-status="completed"] { + color: var(--sl-color-text-secondary); + + & > span { + border-color: var(--sl-color-green-low); + } + + & > span::before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: calc(0.75rem - 2px - 4px); + height: calc(0.75rem - 2px - 4px); + box-shadow: inset 1rem 1rem var(--sl-color-green); + + transform-origin: bottom left; + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); + } + } + } + } + + [data-component="terminal"] { + width: 100%; + max-width: var(--sm-tool-width); + + [data-slot="body"] { + display: flex; + flex-direction: column; + border: 1px solid var(--sl-color-divider); + border-radius: 0.25rem; + overflow: hidden; + } + + [data-slot="header"] { + position: relative; + border-bottom: 1px solid var(--sl-color-divider); + width: 100%; + height: 1.625rem; + text-align: center; + padding: 0 3.25rem; + + > span { + max-width: min(100%, 140ch); + display: inline-block; + white-space: nowrap; + overflow: hidden; + line-height: 1.625rem; + font-size: 0.75rem; + text-overflow: ellipsis; + color: var(--sl-color-text-dimmed); + } + + &::before { + content: ""; + position: absolute; + pointer-events: none; + top: 8px; + left: 10px; + width: 2rem; + height: 0.5rem; + line-height: 0; + background-color: var(--sl-color-hairline); + mask-image: var(--term-icon); + mask-repeat: no-repeat; + } + } + + [data-slot="content"] { + display: flex; + flex-direction: column; + padding: 0.5rem calc(0.5rem + 3px); + + pre { + --shiki-dark-bg: var(--sl-color-bg) !important; + background-color: var(--sl-color-bg) !important; + line-height: 1.6; + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + } + } + } + + [data-component="tool-args"] { + display: inline-grid; + align-items: center; + grid-template-columns: max-content max-content minmax(0, 1fr); + max-width: var(--md-tool-width); + gap: 0.25rem 0.375rem; + + & > div:nth-child(3n + 1) { + width: 8px; + height: 2px; + border-radius: 1px; + background: var(--sl-color-divider); + } + + & > div:nth-child(3n + 2), + & > div:nth-child(3n + 3) { + font-size: 0.75rem; + line-height: 1.5; + } + + & > div:nth-child(3n + 3) { + padding-left: 0.125rem; + word-break: break-word; + color: var(--sl-color-text-secondary); + } + } +} diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx new file mode 100644 index 00000000..3ee2c61a --- /dev/null +++ b/packages/web/src/components/share/part.tsx @@ -0,0 +1,664 @@ +import { createMemo, createSignal, For, Match, Show, Switch, type JSX, type ParentProps } from "solid-js" +import { + IconCheckCircle, + IconChevronDown, + IconChevronRight, + IconHashtag, + IconSparkles, + IconGlobeAlt, + IconDocument, + IconQueueList, + IconCommandLine, + IconDocumentPlus, + IconPencilSquare, + IconRectangleStack, + IconMagnifyingGlass, + IconDocumentMagnifyingGlass, +} from "../icons" +import styles from "./part.module.css" +import type { MessageV2 } from "opencode/session/message-v2" +import { ContentText } from "./content-text" +import { ContentMarkdown } from "./content-markdown" +import { DateTime } from "luxon" +import CodeBlock from "../CodeBlock" +import map from "lang-map" +import type { Diagnostic } from "vscode-languageserver-types" + +import { ContentCode } from "./content-code" +import { ContentDiff } from "./content-diff" + +export interface PartProps { + index: number + message: MessageV2.Info + part: MessageV2.AssistantPart | MessageV2.UserPart + last: boolean +} + +export function Part(props: PartProps) { + const [copied, setCopied] = createSignal(false) + const id = createMemo(() => props.message.id + "-" + props.index) + + return ( +
+ +
+ {props.message.role === "user" && props.part.type === "text" && ( + <> + + + )} + {props.message.role === "assistant" && props.part.type === "text" && ( + <> + + {props.last && props.message.role === "assistant" && props.message.time.completed && ( +
+ {DateTime.fromMillis(props.message.time.completed).toLocaleString(DateTime.DATETIME_MED)} +
+ )} + + + )} + {props.part.type === "step-start" && props.message.role === "assistant" && ( +
+
{props.message.providerID}
+
{props.message.modelID}
+
+ )} + {props.part.type === "tool" && + props.part.state.status === "completed" && + props.message.role === "assistant" && ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ )} +
+
+ ) +} + +type ToolProps = { + id: MessageV2.ToolPart["id"] + tool: MessageV2.ToolPart["tool"] + state: MessageV2.ToolStateCompleted + message: MessageV2.Assistant + isLastPart?: boolean +} + +interface Todo { + id: string + content: string + status: "pending" | "in_progress" | "completed" + priority: "low" | "medium" | "high" +} + +function stripWorkingDirectory(filePath?: string, workingDir?: string) { + if (filePath === undefined || workingDir === undefined) return filePath + + const prefix = workingDir.endsWith("/") ? workingDir : workingDir + "/" + + if (filePath === workingDir) { + return "" + } + + if (filePath.startsWith(prefix)) { + return filePath.slice(prefix.length) + } + + return filePath +} + +function getShikiLang(filename: string) { + const ext = filename.split(".").pop()?.toLowerCase() ?? "" + const langs = map.languages(ext) + const type = langs?.[0]?.toLowerCase() + + const overrides: Record = { + conf: "shellscript", + } + + return type ? (overrides[type] ?? type) : "plaintext" +} + +function getDiagnostics(diagnosticsByFile: Record, currentFile: string): JSX.Element[] { + const result: JSX.Element[] = [] + + if (diagnosticsByFile === undefined || diagnosticsByFile[currentFile] === undefined) return result + + for (const diags of Object.values(diagnosticsByFile)) { + for (const d of diags) { + if (d.severity !== 1) continue + + const line = d.range.start.line + 1 + const column = d.range.start.character + 1 + + result.push( +
+          
+            Error
+          
+          
+            [{line}:{column}]
+          
+          {d.message}
+        
, + ) + } + } + + return result +} + +function formatErrorString(error: string): JSX.Element { + const errorMarker = "Error: " + const startsWithError = error.startsWith(errorMarker) + + return startsWithError ? ( +
+      
+        Error
+      
+      {error.slice(errorMarker.length)}
+    
+ ) : ( +
+      {error}
+    
+ ) +} + +export function TodoWriteTool(props: ToolProps) { + const priority: Record = { + in_progress: 0, + pending: 1, + completed: 2, + } + const todos = createMemo(() => + ((props.state.input?.todos ?? []) as Todo[]).slice().sort((a, b) => priority[a.status] - priority[b.status]), + ) + const starting = () => todos().every((t: Todo) => t.status === "pending") + const finished = () => todos().every((t: Todo) => t.status === "completed") + + return ( + <> +
+ + + Creating plan + Completing plan + + +
+ 0}> +
    + + {(todo) => ( +
  • + + {todo.content} +
  • + )} +
    +
+
+ + ) +} + +export function GrepTool(props: ToolProps) { + return ( + <> +
+ Grep + “{props.state.input.pattern}” +
+
+ + 0}> + + + + + + + + +
+ + ) +} + +export function ListTool(props: ToolProps) { + const path = createMemo(() => + props.state.input?.path !== props.message.path.cwd + ? stripWorkingDirectory(props.state.input?.path, props.message.path.cwd) + : props.state.input?.path, + ) + + return ( + <> +
+ LS + + {path()} + +
+
+ + + + + + + +
+ + ) +} + +export function WebFetchTool(props: ToolProps) { + return ( + <> +
+ Fetch + {props.state.input.url} +
+
+ + +
{formatErrorString(props.state.output)}
+
+ + + + + +
+
+ + ) +} + +export function ReadTool(props: ToolProps) { + const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd)) + + return ( + <> +
+ Read + + {filePath()} + +
+
+ + +
{formatErrorString(props.state.output)}
+
+ + + + + + + + + + +
+
+ + ) +} + +export function WriteTool(props: ToolProps) { + const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd)) + const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath)) + + return ( + <> +
+ Write + + {filePath()} + +
+ 0}> +
{diagnostics()}
+
+
+ + +
{formatErrorString(props.state.output)}
+
+ + + + + +
+
+ + ) +} + +export function EditTool(props: ToolProps) { + const filePath = createMemo(() => stripWorkingDirectory(props.state.input.filePath, props.message.path.cwd)) + const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath)) + + return ( + <> +
+ Edit + + {filePath()} + +
+
+ + +
{formatErrorString(props.state.metadata?.message || "")}
+
+ +
+ +
+
+
+
+ 0}> +
{diagnostics()}
+
+ + ) +} + +export function BashTool(props: ToolProps) { + return ( + <> +
+
+
+ {props.state.metadata.description} +
+
+ + +
+
+
+ + ) +} + +export function GlobTool(props: ToolProps) { + return ( + <> +
+ Glob + “{props.state.input.pattern}” +
+ + 0}> +
+ + + +
+
+ + + +
+ + ) +} + +interface ResultsButtonProps extends ParentProps { + showCopy?: string + hideCopy?: string +} +function ResultsButton(props: ResultsButtonProps) { + const [show, setShow] = createSignal(false) + + return ( + <> + + {props.children} + + ) +} + +export function Spacer() { + return
+} + +function Footer(props: ParentProps<{ title: string }>) { + return ( +
+ {props.children} +
+ ) +} + +export function FallbackTool(props: ToolProps) { + return ( + <> +
+ {props.tool} +
+
+ + {(arg) => ( + <> +
+
{arg[0]}
+
{arg[1]}
+ + )} +
+
+ + +
+ + + +
+
+
+ + ) +} + +// Converts nested objects/arrays into [path, value] pairs. +// E.g. {a:{b:{c:1}}, d:[{e:2}, 3]} => [["a.b.c",1], ["d[0].e",2], ["d[1]",3]] +function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> { + const entries: Array<[string, any]> = [] + + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key + + if (value !== null && typeof value === "object") { + if (Array.isArray(value)) { + value.forEach((item, index) => { + const arrayPath = `${path}[${index}]` + if (item !== null && typeof item === "object") { + entries.push(...flattenToolArgs(item, arrayPath)) + } else { + entries.push([arrayPath, item]) + } + }) + } else { + entries.push(...flattenToolArgs(value, path)) + } + } else { + entries.push([path, value]) + } + } + + return entries +} diff --git a/packages/web/src/content/docs/docs/cli.mdx b/packages/web/src/content/docs/docs/cli.mdx index 49d343be..57e59521 100644 --- a/packages/web/src/content/docs/docs/cli.mdx +++ b/packages/web/src/content/docs/docs/cli.mdx @@ -39,12 +39,12 @@ opencode run Explain the use of context in Go #### Flags -| Flag | Short | Description | -| ----------------- | ----- | --------------------- | -| `--continue` | `-c` | Continue the last session | -| `--session` | `-s` | Session ID to continue | -| `--share` | | Share the session | -| `--model` | `-m` | Model to use in the form of provider/model | +| Flag | Short | Description | +| ------------ | ----- | ------------------------------------------ | +| `--continue` | `-c` | Continue the last session | +| `--session` | `-s` | Session ID to continue | +| `--share` | | Share the session | +| `--model` | `-m` | Model to use in the form of provider/model | --- @@ -122,8 +122,8 @@ opencode upgrade v0.1.48 The opencode CLI takes the following flags. -| Flag | Short | Description | -| ----------------- | ----- | --------------------- | -| `--help` | `-h` | Display help | -| `--version` | | Print version number | -| `--print-logs` | | Print logs to stderr | +| Flag | Short | Description | +| -------------- | ----- | -------------------- | +| `--help` | `-h` | Display help | +| `--version` | | Print version number | +| `--print-logs` | | Print logs to stderr | diff --git a/packages/web/src/content/docs/docs/config.mdx b/packages/web/src/content/docs/docs/config.mdx index d88749c6..40583ea0 100644 --- a/packages/web/src/content/docs/docs/config.mdx +++ b/packages/web/src/content/docs/docs/config.mdx @@ -39,7 +39,7 @@ You can configure the providers and models you want to use in your opencode conf ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "provider": { }, + "provider": {}, "model": "" } ``` @@ -70,7 +70,7 @@ You can customize your keybinds through the `keybinds` option. ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "keybinds": { } + "keybinds": {} } ``` @@ -85,7 +85,7 @@ You can configure MCP servers you want to use through the `mcp` option. ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "mcp": { } + "mcp": {} } ``` @@ -105,6 +105,7 @@ You can disable providers that are loaded automatically through the `disabled_pr ``` The `disabled_providers` option accepts an array of provider IDs. When a provider is disabled: + - It won't be loaded even if environment variables are set - It won't be loaded even if API keys are configured through `opencode auth login` - The provider's models won't appear in the model selection list diff --git a/packages/web/src/content/docs/docs/index.mdx b/packages/web/src/content/docs/docs/index.mdx index b39ce452..9ea95844 100644 --- a/packages/web/src/content/docs/docs/index.mdx +++ b/packages/web/src/content/docs/docs/index.mdx @@ -3,7 +3,7 @@ title: Intro description: Get started with opencode. --- -import { Tabs, TabItem } from '@astrojs/starlight/components'; +import { Tabs, TabItem } from "@astrojs/starlight/components" [**opencode**](/) is an AI coding agent built for the terminal. It features: @@ -21,26 +21,10 @@ import { Tabs, TabItem } from '@astrojs/starlight/components'; ## Install - - ```bash - npm install -g opencode-ai - ``` - - - ```bash - bun install -g opencode-ai - ``` - - - ```bash - pnpm install -g opencode-ai - ``` - - - ```bash - yarn global add opencode-ai - ``` - + ```bash npm install -g opencode-ai ``` + ```bash bun install -g opencode-ai ``` + ```bash pnpm install -g opencode-ai ``` + ```bash yarn global add opencode-ai ``` You can also install the opencode binary through the following. diff --git a/packages/web/src/content/docs/docs/rules.mdx b/packages/web/src/content/docs/docs/rules.mdx index b7818d71..b1b55b02 100644 --- a/packages/web/src/content/docs/docs/rules.mdx +++ b/packages/web/src/content/docs/docs/rules.mdx @@ -31,17 +31,20 @@ You can also just create this file manually. Here's an example of some things yo This is an SST v3 monorepo with TypeScript. The project uses bun workspaces for package management. ## Project Structure + - `packages/` - Contains all workspace packages (functions, core, web, etc.) - `infra/` - Infrastructure definitions split by service (storage.ts, api.ts, web.ts) - `sst.config.ts` - Main SST configuration with dynamic imports ## Code Standards + - Use TypeScript with strict mode enabled - Shared code goes in `packages/core/` with proper exports configuration - Functions go in `packages/functions/` - Infrastructure should be split into logical files in `infra/` ## Monorepo Conventions + - Import shared modules using workspace names: `@my-app/core/example` ``` diff --git a/packages/web/src/content/docs/docs/themes.mdx b/packages/web/src/content/docs/docs/themes.mdx index da612284..12559153 100644 --- a/packages/web/src/content/docs/docs/themes.mdx +++ b/packages/web/src/content/docs/docs/themes.mdx @@ -13,18 +13,18 @@ By default, opencode uses our own `opencode` theme. opencode comes with several built-in themes. -| Name | Description | -| --- | --- | -| `system` | Adapts to your terminal's background color | -| `tokyonight` | Based on the Tokyonight theme | -| `everforest` | Based on the Everforest theme | -| `ayu` | Based on the Ayu dark theme | -| `catppuccin` | Based on the Catppuccin theme | -| `gruvbox` | Based on the Gruvbox theme | -| `kanagawa` | Based on the Kanagawa theme | -| `nord` | Based on the Nord theme | -| `matrix` | Hacker-style green on black theme | -| `one-dark` | Based on the Atom One Dark theme | +| Name | Description | +| ------------ | ------------------------------------------ | +| `system` | Adapts to your terminal's background color | +| `tokyonight` | Based on the Tokyonight theme | +| `everforest` | Based on the Everforest theme | +| `ayu` | Based on the Ayu dark theme | +| `catppuccin` | Based on the Catppuccin theme | +| `gruvbox` | Based on the Gruvbox theme | +| `kanagawa` | Based on the Kanagawa theme | +| `nord` | Based on the Nord theme | +| `matrix` | Hacker-style green on black theme | +| `one-dark` | Based on the Atom One Dark theme | And more, we are constantly adding new themes. @@ -61,7 +61,7 @@ You can select a theme by bringing up the theme select with the `/theme` command ## Custom themes -opencode supports a flexible JSON-based theme system that allows users to create and customize themes easily. +opencode supports a flexible JSON-based theme system that allows users to create and customize themes easily. --- diff --git a/packages/web/src/types/lang-map.d.ts b/packages/web/src/types/lang-map.d.ts index b21d2a00..6df26d6a 100644 --- a/packages/web/src/types/lang-map.d.ts +++ b/packages/web/src/types/lang-map.d.ts @@ -2,9 +2,9 @@ declare module "lang-map" { /** Returned by calling `map()` */ export interface MapReturn { /** All extensions keyed by language name */ - extensions: Record; + extensions: Record /** All languages keyed by file-extension */ - languages: Record; + languages: Record } /** @@ -14,14 +14,14 @@ declare module "lang-map" { * const { extensions, languages } = map(); * ``` */ - function map(): MapReturn; + function map(): MapReturn /** Static method: get extensions for a given language */ namespace map { - function extensions(language: string): string[]; + function extensions(language: string): string[] /** Static method: get languages for a given extension */ - function languages(extension: string): string[]; + function languages(extension: string): string[] } - export = map; + export = map } diff --git a/packages/web/sst-env.d.ts b/packages/web/sst-env.d.ts index b6a7e906..0397645b 100644 --- a/packages/web/sst-env.d.ts +++ b/packages/web/sst-env.d.ts @@ -6,4 +6,4 @@ /// import "sst" -export {} \ No newline at end of file +export {} diff --git a/scripts/stats.ts b/scripts/stats.ts index b30e57d9..2abe7e1c 100755 --- a/scripts/stats.ts +++ b/scripts/stats.ts @@ -26,13 +26,9 @@ async function fetchNpmDownloads(packageName: string): Promise { // Use a range from 2020 to current year + 5 years to ensure it works forever const currentYear = new Date().getFullYear() const endYear = currentYear + 5 - const response = await fetch( - `https://api.npmjs.org/downloads/range/2020-01-01:${endYear}-12-31/${packageName}`, - ) + const response = await fetch(`https://api.npmjs.org/downloads/range/2020-01-01:${endYear}-12-31/${packageName}`) if (!response.ok) { - console.warn( - `Failed to fetch npm downloads for ${packageName}: ${response.status}`, - ) + console.warn(`Failed to fetch npm downloads for ${packageName}: ${response.status}`) return 0 } const data: NpmDownloadsRange = await response.json() @@ -53,9 +49,7 @@ async function fetchReleases(): Promise { const response = await fetch(url) if (!response.ok) { - throw new Error( - `GitHub API error: ${response.status} ${response.statusText}`, - ) + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`) } const batch: Release[] = await response.json() @@ -115,11 +109,7 @@ async function save(githubTotal: number, npmDownloads: number) { for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim() - if ( - line.startsWith("|") && - !line.includes("Date") && - !line.includes("---") - ) { + if (line.startsWith("|") && !line.includes("Date") && !line.includes("---")) { const match = line.match( /\|\s*[\d-]+\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|/, ) @@ -147,11 +137,7 @@ async function save(githubTotal: number, npmDownloads: number) { ? ` (${githubChange.toLocaleString()})` : " (+0)" const npmChangeStr = - npmChange > 0 - ? ` (+${npmChange.toLocaleString()})` - : npmChange < 0 - ? ` (${npmChange.toLocaleString()})` - : " (+0)" + npmChange > 0 ? ` (+${npmChange.toLocaleString()})` : npmChange < 0 ? ` (${npmChange.toLocaleString()})` : " (+0)" const totalChangeStr = totalChange > 0 ? ` (+${totalChange.toLocaleString()})` @@ -182,9 +168,7 @@ const { total: githubTotal, stats } = calculate(releases) console.log("Fetching npm all-time downloads for opencode-ai...\n") const npmDownloads = await fetchNpmDownloads("opencode-ai") -console.log( - `Fetched npm all-time downloads: ${npmDownloads.toLocaleString()}\n`, -) +console.log(`Fetched npm all-time downloads: ${npmDownloads.toLocaleString()}\n`) await save(githubTotal, npmDownloads) @@ -202,24 +186,18 @@ console.log("-".repeat(60)) stats .sort((a, b) => b.downloads - a.downloads) .forEach((release) => { - console.log( - `${release.tag.padEnd(15)} ${release.downloads.toLocaleString().padStart(10)} downloads`, - ) + console.log(`${release.tag.padEnd(15)} ${release.downloads.toLocaleString().padStart(10)} downloads`) if (release.assets.length > 1) { release.assets .sort((a, b) => b.downloads - a.downloads) .forEach((asset) => { - console.log( - ` └─ ${asset.name.padEnd(25)} ${asset.downloads.toLocaleString().padStart(8)}`, - ) + console.log(` └─ ${asset.name.padEnd(25)} ${asset.downloads.toLocaleString().padStart(8)}`) }) } }) console.log("-".repeat(60)) -console.log( - `GitHub Total: ${githubTotal.toLocaleString()} downloads across ${releases.length} releases`, -) +console.log(`GitHub Total: ${githubTotal.toLocaleString()} downloads across ${releases.length} releases`) console.log(`npm Total: ${npmDownloads.toLocaleString()} downloads`) console.log(`Combined Total: ${totalDownloads.toLocaleString()} downloads`) diff --git a/sst-env.d.ts b/sst-env.d.ts index 627d74a5..45c07b66 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -5,20 +5,20 @@ declare module "sst" { export interface Resource { - "Api": { - "type": "sst.cloudflare.Worker" - "url": string + Api: { + type: "sst.cloudflare.Worker" + url: string } - "Bucket": { - "type": "sst.cloudflare.Bucket" + Bucket: { + type: "sst.cloudflare.Bucket" } - "Web": { - "type": "sst.cloudflare.Astro" - "url": string + Web: { + type: "sst.cloudflare.Astro" + url: string } } } /// import "sst" -export {} \ No newline at end of file +export {} diff --git a/stainless.yml b/stainless.yml index f8d654fb..941c4f38 100644 --- a/stainless.yml +++ b/stainless.yml @@ -78,16 +78,19 @@ resources: models: session: Session message: Message - toolCall: ToolCall - toolPartialCall: ToolPartialCall - toolResult: ToolResult textPart: TextPart - reasoningPart: ReasoningPart - toolInvocationPart: ToolInvocationPart - sourceUrlPart: SourceUrlPart filePart: FilePart + toolPart: ToolPart stepStartPart: StepStartPart - messagePart: MessagePart + assistantMessage: AssistantMessage + assistantMessagePart: AssistantMessagePart + userMessage: UserMessage + userMessagePart: UserMessagePart + toolStatePending: ToolStatePending + toolStateRunning: ToolStateRunning + toolStateCompleted: ToolStateCompleted + toolStateError: ToolStateError + methods: list: get /session create: post /session From b478e5655ccbc22a1b86093f64abc4b4a0d7f4f0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 16:12:47 -0400 Subject: [PATCH 31/52] fix interrupt --- packages/opencode/src/session/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b3567a5c..4c50d51d 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -708,6 +708,8 @@ export namespace Session { } await updateMessage(next) } + next.time.completed = Date.now() + await updateMessage(next) return next } From 661b74def671bb4c604d54162bad9230aa3472c0 Mon Sep 17 00:00:00 2001 From: Jay V Date: Mon, 7 Jul 2025 16:13:24 -0400 Subject: [PATCH 32/52] docs: debug info --- packages/web/astro.config.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 63b93b9d..05866c5d 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -10,6 +10,8 @@ import rehypeAutolinkHeadings from "rehype-autolink-headings" const github = "https://github.com/sst/opencode" +console.log(process.env.SST_STAGE) + // https://astro.build/config export default defineConfig({ site: config.url, From 7cfa297a78a549ac45b98c3126bc2c1d6a5a22ac Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 16:24:22 -0400 Subject: [PATCH 33/52] wip: model and prompt flags for tui --- packages/opencode/src/cli/cmd/tui.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index f0ec4a53..886863a1 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -15,10 +15,21 @@ export const TuiCommand = cmd({ command: "$0 [project]", describe: "start opencode tui", builder: (yargs) => - yargs.positional("project", { - type: "string", - describe: "path to start opencode in", - }), + yargs + .positional("project", { + type: "string", + describe: "path to start opencode in", + }) + .option("model", { + type: "string", + alias: ["m"], + describe: "model to use in the format of provider/model", + }) + .option("prompt", { + alias: ["p"], + type: "string", + describe: "prompt to use", + }), handler: async (args) => { while (true) { const cwd = args.project ? path.resolve(args.project) : process.cwd() @@ -60,7 +71,11 @@ export const TuiCommand = cmd({ cmd, }) const proc = Bun.spawn({ - cmd: [...cmd, ...process.argv.slice(2)], + cmd: [ + ...cmd, + ...(args.model ? ["--model", args.model] : []), + ...(args.prompt ? ["--prompt", args.prompt] : []), + ], cwd, stdout: "inherit", stderr: "inherit", From 9253a3ca9e561bb44e08d634295706ddade6f00e Mon Sep 17 00:00:00 2001 From: Jay V Date: Mon, 7 Jul 2025 16:25:39 -0400 Subject: [PATCH 34/52] docs: debug --- infra/app.ts | 1 + packages/web/astro.config.mjs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/infra/app.ts b/infra/app.ts index 834936b7..f585748a 100644 --- a/infra/app.ts +++ b/infra/app.ts @@ -39,6 +39,7 @@ new sst.cloudflare.x.Astro("Web", { domain, path: "packages/web", environment: { + SST_STAGE: $app.stage, VITE_API_URL: api.url, }, }) diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 05866c5d..742d5295 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -10,7 +10,7 @@ import rehypeAutolinkHeadings from "rehype-autolink-headings" const github = "https://github.com/sst/opencode" -console.log(process.env.SST_STAGE) +console.log("stage", process.env.SST_STAGE) // https://astro.build/config export default defineConfig({ From c51de945a5620d77ccb25652c732d259035a8cf7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 16:29:04 -0400 Subject: [PATCH 35/52] Add stdin support to run command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow piping content to opencode run when no message arguments are provided, enabling standard Unix pipe patterns for better CLI integration. 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode --- packages/opencode/src/cli/cmd/run.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 2d0262aa..be271ceb 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -54,7 +54,14 @@ export const RunCommand = cmd({ }) }, handler: async (args) => { - const message = args.message.join(" ") + let message = args.message.join(" ") + + // Read from stdin if no message provided and stdin is available + if (!message && !process.stdin.isTTY) { + message = await Bun.stdin.text() + message = message.trim() + } + await bootstrap({ cwd: process.cwd() }, async () => { const session = await (async () => { if (args.continue) { From facd851b119f3570a00769a2cb8755e5d245fdff Mon Sep 17 00:00:00 2001 From: Jay V Date: Mon, 7 Jul 2025 16:31:10 -0400 Subject: [PATCH 36/52] docs: dynamic domain --- infra/app.ts | 1 + packages/web/astro.config.mjs | 7 ++++--- packages/web/config.mjs | 2 +- packages/web/src/components/Head.astro | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/infra/app.ts b/infra/app.ts index f585748a..caaea0e9 100644 --- a/infra/app.ts +++ b/infra/app.ts @@ -39,6 +39,7 @@ new sst.cloudflare.x.Astro("Web", { domain, path: "packages/web", environment: { + // For astro config SST_STAGE: $app.stage, VITE_API_URL: api.url, }, diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 742d5295..538784ac 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -9,12 +9,13 @@ import { rehypeHeadingIds } from "@astrojs/markdown-remark" import rehypeAutolinkHeadings from "rehype-autolink-headings" const github = "https://github.com/sst/opencode" - -console.log("stage", process.env.SST_STAGE) +const stage = process.env.SST_STAGE || "dev" // https://astro.build/config export default defineConfig({ - site: config.url, + site: stage === "production" + ? `https://${config.domain}` + : `https://${stage}.${config.domain}`, output: "server", adapter: cloudflare({ imageService: "passthrough", diff --git a/packages/web/config.mjs b/packages/web/config.mjs index f4c2fe99..f0ae3cb6 100644 --- a/packages/web/config.mjs +++ b/packages/web/config.mjs @@ -1,5 +1,5 @@ export default { - url: "https://opencode.ai", + domain: "opencode.ai", socialCard: "https://social-cards.sst.dev", github: "https://github.com/sst/opencode", discord: "https://discord.gg/opencode", diff --git a/packages/web/src/components/Head.astro b/packages/web/src/components/Head.astro index f6166f58..9ebf734c 100644 --- a/packages/web/src/components/Head.astro +++ b/packages/web/src/components/Head.astro @@ -13,7 +13,7 @@ const { const isDocs = slug.startsWith("docs") let encodedTitle = ''; -let ogImage = `${config.url}/social-share.png`; +let ogImage = `https://${config.domain}/social-share.png`; let truncatedDesc = ''; if (isDocs) { From da909d9684ca7eec64858b9f394fa41e36f947fc Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 16:32:48 -0400 Subject: [PATCH 37/52] append piped stdin to prompt --- packages/opencode/src/cli/cmd/run.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index be271ceb..0c232634 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -56,11 +56,7 @@ export const RunCommand = cmd({ handler: async (args) => { let message = args.message.join(" ") - // Read from stdin if no message provided and stdin is available - if (!message && !process.stdin.isTTY) { - message = await Bun.stdin.text() - message = message.trim() - } + if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) await bootstrap({ cwd: process.cwd() }, async () => { const session = await (async () => { From 0f93ecd564c87cefba40b779c9f35d0930719b67 Mon Sep 17 00:00:00 2001 From: Jay V Date: Mon, 7 Jul 2025 16:36:52 -0400 Subject: [PATCH 38/52] docs: canonical url --- packages/web/astro.config.mjs | 5 +---- packages/web/config.mjs | 6 +++++- packages/web/src/components/Head.astro | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 538784ac..63b93b9d 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -9,13 +9,10 @@ import { rehypeHeadingIds } from "@astrojs/markdown-remark" import rehypeAutolinkHeadings from "rehype-autolink-headings" const github = "https://github.com/sst/opencode" -const stage = process.env.SST_STAGE || "dev" // https://astro.build/config export default defineConfig({ - site: stage === "production" - ? `https://${config.domain}` - : `https://${stage}.${config.domain}`, + site: config.url, output: "server", adapter: cloudflare({ imageService: "passthrough", diff --git a/packages/web/config.mjs b/packages/web/config.mjs index f0ae3cb6..5e4c571d 100644 --- a/packages/web/config.mjs +++ b/packages/web/config.mjs @@ -1,5 +1,9 @@ +const stage = process.env.SST_STAGE || "dev" + export default { - domain: "opencode.ai", + url: stage === "production" + ? "https://opencode.ai" + : `https://${stage}.opencode.ai`, socialCard: "https://social-cards.sst.dev", github: "https://github.com/sst/opencode", discord: "https://discord.gg/opencode", diff --git a/packages/web/src/components/Head.astro b/packages/web/src/components/Head.astro index 9ebf734c..f6166f58 100644 --- a/packages/web/src/components/Head.astro +++ b/packages/web/src/components/Head.astro @@ -13,7 +13,7 @@ const { const isDocs = slug.startsWith("docs") let encodedTitle = ''; -let ogImage = `https://${config.domain}/social-share.png`; +let ogImage = `${config.url}/social-share.png`; let truncatedDesc = ''; if (isDocs) { From 27f7e02f12a1f0291d141686ecdedb72127a6523 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 16:41:28 -0400 Subject: [PATCH 39/52] run: truncate prompt --- packages/opencode/src/cli/cmd/run.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0c232634..453b273d 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -81,7 +81,8 @@ export const RunCommand = cmd({ UI.empty() UI.println(UI.logo()) UI.empty() - UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message) + const displayMessage = message.length > 300 ? message.slice(0, 300) + "..." : message + UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", displayMessage) UI.empty() const cfg = await Config.get() From 0d50c867ff16686d47101fa6d29e07539fe40d8f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 17:05:16 -0400 Subject: [PATCH 40/52] fix mcp tools corrupting session --- opencode.json | 6 ++++++ packages/opencode/src/session/index.ts | 22 ++++++++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/opencode.json b/opencode.json index 57b94008..ff206980 100644 --- a/opencode.json +++ b/opencode.json @@ -1,5 +1,11 @@ { "$schema": "https://opencode.ai/config.json", + "mcp": { + "weather": { + "type": "local", + "command": ["opencode", "x", "@h1deya/mcp-server-weather"] + } + }, "experimental": { "hook": { "file_edited": { diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 4c50d51d..4e24fa51 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -496,14 +496,20 @@ export namespace Session { const execute = item.execute if (!execute) continue item.execute = async (args, opts) => { - try { - const result = await execute(args, opts) - return result.content - .filter((x: any) => x.type === "text") - .map((x: any) => x.text) - .join("\n\n") - } catch (e: any) { - return e.toString() + const result = await execute(args, opts) + const output = result.content + .filter((x: any) => x.type === "text") + .map((x: any) => x.text) + .join("\n\n") + + return { + output, + } + } + item.toModelOutput = (result) => { + return { + type: "text", + value: result.output, } } tools[key] = item From 9948fcf1b6e6cea328085bdf3ad96ab05a139f52 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 17:39:52 -0400 Subject: [PATCH 41/52] fix crash when running on new project --- packages/opencode/src/storage/storage.ts | 30 +++++++++++++----------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index ccafb34d..7093fb25 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -17,21 +17,23 @@ export namespace Storage { const MIGRATIONS: Migration[] = [ async (dir: string) => { - const files = new Bun.Glob("session/message/*/*.json").scanSync({ - cwd: dir, - absolute: true, - }) - for (const file of files) { - const content = await Bun.file(file).json() - if (!content.metadata) continue - log.info("migrating to v2 message", { file }) - try { - const result = MessageV2.fromV1(content) - await Bun.write(file, JSON.stringify(result, null, 2)) - } catch (e) { - await fs.rename(file, file.replace("storage", "broken")) + try { + const files = new Bun.Glob("session/message/*/*.json").scanSync({ + cwd: dir, + absolute: true, + }) + for (const file of files) { + const content = await Bun.file(file).json() + if (!content.metadata) continue + log.info("migrating to v2 message", { file }) + try { + const result = MessageV2.fromV1(content) + await Bun.write(file, JSON.stringify(result, null, 2)) + } catch (e) { + await fs.rename(file, file.replace("storage", "broken")) + } } - } + } catch {} }, ] From a272b58fe988addc5c0d18bbaba2b09fac1d9fef Mon Sep 17 00:00:00 2001 From: Jay V Date: Mon, 7 Jul 2025 17:41:44 -0400 Subject: [PATCH 42/52] docs: intro --- packages/web/src/content/docs/docs/index.mdx | 24 ++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/web/src/content/docs/docs/index.mdx b/packages/web/src/content/docs/docs/index.mdx index 9ea95844..0484e9b3 100644 --- a/packages/web/src/content/docs/docs/index.mdx +++ b/packages/web/src/content/docs/docs/index.mdx @@ -21,10 +21,26 @@ import { Tabs, TabItem } from "@astrojs/starlight/components" ## Install - ```bash npm install -g opencode-ai ``` - ```bash bun install -g opencode-ai ``` - ```bash pnpm install -g opencode-ai ``` - ```bash yarn global add opencode-ai ``` + + ```bash + npm install -g opencode-ai + ``` + + + ```bash + bun install -g opencode-ai + ``` + + + ```bash + pnpm install -g opencode-ai + ``` + + + ```bash + yarn global add opencode-ai + ``` + You can also install the opencode binary through the following. From c7a59ee2b10644a3a31dda14a44115ca9baada44 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 20:58:47 -0400 Subject: [PATCH 43/52] better handling of aborting sessions --- packages/opencode/src/session/index.ts | 319 ++++++++++-------- packages/opencode/src/session/message-v2.ts | 8 +- .../tui/internal/components/chat/messages.go | 2 + packages/tui/sdk/.github/workflows/ci.yml | 14 +- .../tui/sdk/.release-please-manifest.json | 2 +- packages/tui/sdk/.stats.yml | 6 +- packages/tui/sdk/CHANGELOG.md | 25 +- packages/tui/sdk/aliases.go | 9 + packages/tui/sdk/api.md | 1 + packages/tui/sdk/event.go | 172 +++++----- packages/tui/sdk/release-please-config.json | 7 +- packages/tui/sdk/session.go | 33 +- packages/tui/sdk/shared/shared.go | 41 +++ stainless.yml | 1 + 14 files changed, 374 insertions(+), 266 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 4e24fa51..614e7e5a 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -552,167 +552,196 @@ export namespace Session { ], }), }) - for await (const value of result.fullStream) { - l.info("part", { - type: value.type, - }) - switch (value.type) { - case "start": - break + try { + for await (const value of result.fullStream) { + l.info("part", { + type: value.type, + }) + switch (value.type) { + case "start": + break - case "tool-input-start": - next.parts.push({ - type: "tool", - tool: value.toolName, - id: value.id, - state: { - status: "pending", - }, - }) - Bus.publish(MessageV2.Event.PartUpdated, { - part: next.parts[next.parts.length - 1], - sessionID: next.sessionID, - messageID: next.id, - }) - break - - case "tool-input-delta": - break - - case "tool-call": { - const match = next.parts.find((p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId) - if (match) { - match.state = { - status: "running", - input: value.input, - time: { - start: Date.now(), + case "tool-input-start": + next.parts.push({ + type: "tool", + tool: value.toolName, + id: value.id, + state: { + status: "pending", }, - } + }) Bus.publish(MessageV2.Event.PartUpdated, { - part: match, + part: next.parts[next.parts.length - 1], sessionID: next.sessionID, messageID: next.id, }) - } - break - } - case "tool-result": { - const match = next.parts.find((p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId) - if (match && match.state.status === "running") { - match.state = { - status: "completed", - input: value.input, - output: value.output.output, - metadata: value.output.metadata, - title: value.output.title, - time: { - start: match.state.time.start, - end: Date.now(), - }, - } - Bus.publish(MessageV2.Event.PartUpdated, { - part: match, - sessionID: next.sessionID, - messageID: next.id, - }) - } - break - } + break - case "tool-error": { - const match = next.parts.find((p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId) - if (match && match.state.status === "running") { - match.state = { - status: "error", - input: value.input, - error: (value.error as any).toString(), - time: { - start: match.state.time.start, - end: Date.now(), - }, - } - Bus.publish(MessageV2.Event.PartUpdated, { - part: match, - sessionID: next.sessionID, - messageID: next.id, - }) - } - break - } + case "tool-input-delta": + break - case "error": - const e = value.error - log.error("", { - error: e, - }) - switch (true) { - case MessageV2.OutputLengthError.isInstance(e): - next.error = e - break - case LoadAPIKeyError.isInstance(e): - next.error = new Provider.AuthError( - { - providerID: input.providerID, - message: e.message, + case "tool-call": { + const match = next.parts.find( + (p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId, + ) + if (match) { + match.state = { + status: "running", + input: value.input, + time: { + start: Date.now(), }, - { cause: e }, - ).toObject() - break - case e instanceof Error: - next.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject() - break - default: - next.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }) + } + Bus.publish(MessageV2.Event.PartUpdated, { + part: match, + sessionID: next.sessionID, + messageID: next.id, + }) + } + break } - Bus.publish(Event.Error, { - error: next.error, - }) - break - - case "start-step": - next.parts.push({ - type: "step-start", - }) - break - - case "finish-step": - const usage = getUsage(model.info, value.usage, value.providerMetadata) - next.cost += usage.cost - next.tokens = usage.tokens - break - - case "text-start": - text = { - type: "text", - text: "", + case "tool-result": { + const match = next.parts.find( + (p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId, + ) + if (match && match.state.status === "running") { + match.state = { + status: "completed", + input: value.input, + output: value.output.output, + metadata: value.output.metadata, + title: value.output.title, + time: { + start: match.state.time.start, + end: Date.now(), + }, + } + Bus.publish(MessageV2.Event.PartUpdated, { + part: match, + sessionID: next.sessionID, + messageID: next.id, + }) + } + break } - break - case "text": - if (text.text === "") next.parts.push(text) - text.text += value.text - break + case "tool-error": { + const match = next.parts.find( + (p): p is MessageV2.ToolPart => p.type === "tool" && p.id === value.toolCallId, + ) + if (match && match.state.status === "running") { + match.state = { + status: "error", + input: value.input, + error: (value.error as any).toString(), + time: { + start: match.state.time.start, + end: Date.now(), + }, + } + Bus.publish(MessageV2.Event.PartUpdated, { + part: match, + sessionID: next.sessionID, + messageID: next.id, + }) + } + break + } - case "text-end": - Bus.publish(MessageV2.Event.PartUpdated, { - part: text, - sessionID: next.sessionID, - messageID: next.id, - }) - break + case "error": + throw value.error - case "finish": - next.time.completed = Date.now() - break + case "start-step": + next.parts.push({ + type: "step-start", + }) + break - default: - l.info("unhandled", { - ...value, - }) - continue + case "finish-step": + const usage = getUsage(model.info, value.usage, value.providerMetadata) + next.cost += usage.cost + next.tokens = usage.tokens + break + + case "text-start": + text = { + type: "text", + text: "", + } + break + + case "text": + if (text.text === "") next.parts.push(text) + text.text += value.text + break + + case "text-end": + Bus.publish(MessageV2.Event.PartUpdated, { + part: text, + sessionID: next.sessionID, + messageID: next.id, + }) + break + + case "finish": + next.time.completed = Date.now() + break + + default: + l.info("unhandled", { + ...value, + }) + continue + } + await updateMessage(next) + } + } catch (e) { + log.error("", { + error: e, + }) + switch (true) { + case e instanceof DOMException && e.name === "AbortError": + next.error = new MessageV2.AbortedError( + { message: e.message }, + { + cause: e, + }, + ).toObject() + break + case MessageV2.OutputLengthError.isInstance(e): + next.error = e + break + case LoadAPIKeyError.isInstance(e): + next.error = new Provider.AuthError( + { + providerID: input.providerID, + message: e.message, + }, + { cause: e }, + ).toObject() + break + case e instanceof Error: + next.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject() + break + default: + next.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }) + } + Bus.publish(Event.Error, { + error: next.error, + }) + } + for (const part of next.parts) { + if (part.type === "tool" && part.state.status !== "completed") { + part.state = { + status: "error", + error: "Tool execution aborted", + time: { + start: Date.now(), + end: Date.now(), + }, + input: {}, + } } - await updateMessage(next) } next.time.completed = Date.now() await updateMessage(next) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2b93775a..8b09e68e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -7,6 +7,7 @@ import { convertToModelMessages, type ModelMessage, type UIMessage } from "ai" export namespace MessageV2 { export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) + export const AbortedError = NamedError.create("MessageAbortedError", z.object({})) export const ToolStatePending = z .object({ @@ -148,7 +149,12 @@ export namespace MessageV2 { completed: z.number().optional(), }), error: z - .discriminatedUnion("name", [Provider.AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema]) + .discriminatedUnion("name", [ + Provider.AuthError.Schema, + NamedError.Unknown.Schema, + OutputLengthError.Schema, + AbortedError.Schema, + ]) .optional(), system: z.string().array(), modelID: z.string(), diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 26bee871..6e9c26df 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -320,6 +320,8 @@ func (m *messagesComponent) renderView(width int) { error = "Message output length exceeded" case opencode.ProviderAuthError: error = err.Data.Message + case opencode.MessageAbortedError: + error = "Request was aborted" case opencode.UnknownError: error = err.Data.Message } diff --git a/packages/tui/sdk/.github/workflows/ci.yml b/packages/tui/sdk/.github/workflows/ci.yml index 0f5d45dc..4bf1e907 100644 --- a/packages/tui/sdk/.github/workflows/ci.yml +++ b/packages/tui/sdk/.github/workflows/ci.yml @@ -2,15 +2,15 @@ name: CI on: push: branches-ignore: - - "generated" - - "codegen/**" - - "integrated/**" - - "stl-preview-head/**" - - "stl-preview-base/**" + - 'generated' + - 'codegen/**' + - 'integrated/**' + - 'stl-preview-head/**' + - 'stl-preview-base/**' pull_request: branches-ignore: - - "stl-preview-head/**" - - "stl-preview-base/**" + - 'stl-preview-head/**' + - 'stl-preview-base/**' jobs: lint: diff --git a/packages/tui/sdk/.release-please-manifest.json b/packages/tui/sdk/.release-please-manifest.json index 332798e1..c373724d 100644 --- a/packages/tui/sdk/.release-please-manifest.json +++ b/packages/tui/sdk/.release-please-manifest.json @@ -1,3 +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 index 068170ca..c8411903 100644 --- a/packages/tui/sdk/.stats.yml +++ b/packages/tui/sdk/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 20 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-15eeb028f79b9a065b4e54a6ea6a58631e9bd5004f97820f0c79d18e3f8bac84.yml -openapi_spec_hash: 38c8bacb6c8e4c46852a3e81e3fb9fda -config_hash: e03e9d1aad76081fa1163086e89f201b +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-4955370de3d0a21bb41c4e51257210b3284deb5bc3dbace6e7572de0d1635c9e.yml +openapi_spec_hash: b7591d636977423cd7455aa02caa718f +config_hash: de53ecf98e1038f2cc2fd273b582f082 diff --git a/packages/tui/sdk/CHANGELOG.md b/packages/tui/sdk/CHANGELOG.md index fe25e6e8..bc407fad 100644 --- a/packages/tui/sdk/CHANGELOG.md +++ b/packages/tui/sdk/CHANGELOG.md @@ -6,7 +6,7 @@ Full Changelog: [v0.1.0-alpha.7...v0.1.0-alpha.8](https://github.com/sst/opencod ### Features -- **api:** update via SDK Studio ([651e937](https://github.com/sst/opencode-sdk-go/commit/651e937c334e1caba3b968e6cac865c219879519)) +* **api:** update via SDK Studio ([651e937](https://github.com/sst/opencode-sdk-go/commit/651e937c334e1caba3b968e6cac865c219879519)) ## 0.1.0-alpha.7 (2025-06-30) @@ -14,12 +14,13 @@ Full Changelog: [v0.1.0-alpha.6...v0.1.0-alpha.7](https://github.com/sst/opencod ### 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)) +* **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)) +* **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) @@ -27,7 +28,7 @@ Full Changelog: [v0.1.0-alpha.5...v0.1.0-alpha.6](https://github.com/sst/opencod ### Bug Fixes -- don't try to deserialize as json when ResponseBodyInto is []byte ([5988d04](https://github.com/sst/opencode-sdk-go/commit/5988d04839cb78b6613057280b91b72a60fef33d)) +* 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) @@ -35,7 +36,7 @@ Full Changelog: [v0.1.0-alpha.4...v0.1.0-alpha.5](https://github.com/sst/opencod ### Features -- **api:** update via SDK Studio ([9e39a59](https://github.com/sst/opencode-sdk-go/commit/9e39a59b3d5d1bd5e64633732521fb28362cc70e)) +* **api:** update via SDK Studio ([9e39a59](https://github.com/sst/opencode-sdk-go/commit/9e39a59b3d5d1bd5e64633732521fb28362cc70e)) ## 0.1.0-alpha.4 (2025-06-27) @@ -43,7 +44,7 @@ Full Changelog: [v0.1.0-alpha.3...v0.1.0-alpha.4](https://github.com/sst/opencod ### Features -- **api:** update via SDK Studio ([9609d1b](https://github.com/sst/opencode-sdk-go/commit/9609d1b1db7806d00cb846c9914cb4935cdedf52)) +* **api:** update via SDK Studio ([9609d1b](https://github.com/sst/opencode-sdk-go/commit/9609d1b1db7806d00cb846c9914cb4935cdedf52)) ## 0.1.0-alpha.3 (2025-06-27) @@ -51,7 +52,7 @@ Full Changelog: [v0.1.0-alpha.2...v0.1.0-alpha.3](https://github.com/sst/opencod ### Features -- **api:** update via SDK Studio ([57f3230](https://github.com/sst/opencode-sdk-go/commit/57f32309023cc1f0f20c20d02a3907e390a71f61)) +* **api:** update via SDK Studio ([57f3230](https://github.com/sst/opencode-sdk-go/commit/57f32309023cc1f0f20c20d02a3907e390a71f61)) ## 0.1.0-alpha.2 (2025-06-27) @@ -59,7 +60,7 @@ Full Changelog: [v0.1.0-alpha.1...v0.1.0-alpha.2](https://github.com/sst/opencod ### Features -- **api:** update via SDK Studio ([a766f1c](https://github.com/sst/opencode-sdk-go/commit/a766f1c54f02bbc1380151b0e22d97cc2c5892e6)) +* **api:** update via SDK Studio ([a766f1c](https://github.com/sst/opencode-sdk-go/commit/a766f1c54f02bbc1380151b0e22d97cc2c5892e6)) ## 0.1.0-alpha.1 (2025-06-27) @@ -67,6 +68,6 @@ Full Changelog: [v0.0.1-alpha.0...v0.1.0-alpha.1](https://github.com/sst/opencod ### 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)) +* **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/aliases.go b/packages/tui/sdk/aliases.go index 84dd614a..6ab36d04 100644 --- a/packages/tui/sdk/aliases.go +++ b/packages/tui/sdk/aliases.go @@ -9,6 +9,15 @@ import ( type Error = apierror.Error +// This is an alias to an internal type. +type MessageAbortedError = shared.MessageAbortedError + +// This is an alias to an internal type. +type MessageAbortedErrorName = shared.MessageAbortedErrorName + +// This is an alias to an internal value. +const MessageAbortedErrorNameMessageAbortedError = shared.MessageAbortedErrorNameMessageAbortedError + // This is an alias to an internal type. type ProviderAuthError = shared.ProviderAuthError diff --git a/packages/tui/sdk/api.md b/packages/tui/sdk/api.md index b8f3769d..967b8afe 100644 --- a/packages/tui/sdk/api.md +++ b/packages/tui/sdk/api.md @@ -1,5 +1,6 @@ # Shared Response Types +- shared.MessageAbortedError - shared.ProviderAuthError - shared.UnknownError diff --git a/packages/tui/sdk/event.go b/packages/tui/sdk/event.go index 48eb129f..89da8a07 100644 --- a/packages/tui/sdk/event.go +++ b/packages/tui/sdk/event.go @@ -52,10 +52,10 @@ type EventListResponse struct { // [EventListResponseEventPermissionUpdatedProperties], // [EventListResponseEventFileEditedProperties], // [EventListResponseEventInstallationUpdatedProperties], - // [EventListResponseEventStorageWriteProperties], // [EventListResponseEventMessageUpdatedProperties], // [EventListResponseEventMessageRemovedProperties], // [EventListResponseEventMessagePartUpdatedProperties], + // [EventListResponseEventStorageWriteProperties], // [EventListResponseEventSessionUpdatedProperties], // [EventListResponseEventSessionDeletedProperties], // [EventListResponseEventSessionIdleProperties], @@ -96,11 +96,11 @@ func (r *EventListResponse) UnmarshalJSON(data []byte) (err error) { // [EventListResponseEventLspClientDiagnostics], // [EventListResponseEventPermissionUpdated], [EventListResponseEventFileEdited], // [EventListResponseEventInstallationUpdated], -// [EventListResponseEventStorageWrite], [EventListResponseEventMessageUpdated], -// [EventListResponseEventMessageRemoved], +// [EventListResponseEventMessageUpdated], [EventListResponseEventMessageRemoved], // [EventListResponseEventMessagePartUpdated], -// [EventListResponseEventSessionUpdated], [EventListResponseEventSessionDeleted], -// [EventListResponseEventSessionIdle], [EventListResponseEventSessionError], +// [EventListResponseEventStorageWrite], [EventListResponseEventSessionUpdated], +// [EventListResponseEventSessionDeleted], [EventListResponseEventSessionIdle], +// [EventListResponseEventSessionError], // [EventListResponseEventFileWatcherUpdated]. func (r EventListResponse) AsUnion() EventListResponseUnion { return r.union @@ -109,11 +109,11 @@ func (r EventListResponse) AsUnion() EventListResponseUnion { // Union satisfied by [EventListResponseEventLspClientDiagnostics], // [EventListResponseEventPermissionUpdated], [EventListResponseEventFileEdited], // [EventListResponseEventInstallationUpdated], -// [EventListResponseEventStorageWrite], [EventListResponseEventMessageUpdated], -// [EventListResponseEventMessageRemoved], +// [EventListResponseEventMessageUpdated], [EventListResponseEventMessageRemoved], // [EventListResponseEventMessagePartUpdated], -// [EventListResponseEventSessionUpdated], [EventListResponseEventSessionDeleted], -// [EventListResponseEventSessionIdle], [EventListResponseEventSessionError] or +// [EventListResponseEventStorageWrite], [EventListResponseEventSessionUpdated], +// [EventListResponseEventSessionDeleted], [EventListResponseEventSessionIdle], +// [EventListResponseEventSessionError] or // [EventListResponseEventFileWatcherUpdated]. type EventListResponseUnion interface { implementsEventListResponse() @@ -143,11 +143,6 @@ func init() { Type: reflect.TypeOf(EventListResponseEventInstallationUpdated{}), DiscriminatorValue: "installation.updated", }, - apijson.UnionVariant{ - TypeFilter: gjson.JSON, - Type: reflect.TypeOf(EventListResponseEventStorageWrite{}), - DiscriminatorValue: "storage.write", - }, apijson.UnionVariant{ TypeFilter: gjson.JSON, Type: reflect.TypeOf(EventListResponseEventMessageUpdated{}), @@ -163,6 +158,11 @@ func init() { Type: reflect.TypeOf(EventListResponseEventMessagePartUpdated{}), DiscriminatorValue: "message.part.updated", }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(EventListResponseEventStorageWrite{}), + DiscriminatorValue: "storage.write", + }, apijson.UnionVariant{ TypeFilter: gjson.JSON, Type: reflect.TypeOf(EventListResponseEventSessionUpdated{}), @@ -462,68 +462,6 @@ func (r EventListResponseEventInstallationUpdatedType) IsKnown() bool { 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 EventListResponseEventMessageUpdated struct { Properties EventListResponseEventMessageUpdatedProperties `json:"properties,required"` Type EventListResponseEventMessageUpdatedType `json:"type,required"` @@ -710,6 +648,68 @@ func (r EventListResponseEventMessagePartUpdatedType) IsKnown() bool { 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 EventListResponseEventSessionUpdated struct { Properties EventListResponseEventSessionUpdatedProperties `json:"properties,required"` Type EventListResponseEventSessionUpdatedType `json:"type,required"` @@ -972,13 +972,15 @@ func (r *EventListResponseEventSessionErrorPropertiesError) UnmarshalJSON(data [ // // Possible runtime types of the union are [shared.ProviderAuthError], // [shared.UnknownError], -// [EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError]. +// [EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError], +// [shared.MessageAbortedError]. func (r EventListResponseEventSessionErrorPropertiesError) AsUnion() EventListResponseEventSessionErrorPropertiesErrorUnion { return r.union } -// Union satisfied by [shared.ProviderAuthError], [shared.UnknownError] or -// [EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError]. +// Union satisfied by [shared.ProviderAuthError], [shared.UnknownError], +// [EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError] or +// [shared.MessageAbortedError]. type EventListResponseEventSessionErrorPropertiesErrorUnion interface { ImplementsEventListResponseEventSessionErrorPropertiesError() } @@ -1002,6 +1004,11 @@ func init() { Type: reflect.TypeOf(EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError{}), DiscriminatorValue: "MessageOutputLengthError", }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(shared.MessageAbortedError{}), + DiscriminatorValue: "MessageAbortedError", + }, ) } @@ -1052,11 +1059,12 @@ const ( EventListResponseEventSessionErrorPropertiesErrorNameProviderAuthError EventListResponseEventSessionErrorPropertiesErrorName = "ProviderAuthError" EventListResponseEventSessionErrorPropertiesErrorNameUnknownError EventListResponseEventSessionErrorPropertiesErrorName = "UnknownError" EventListResponseEventSessionErrorPropertiesErrorNameMessageOutputLengthError EventListResponseEventSessionErrorPropertiesErrorName = "MessageOutputLengthError" + EventListResponseEventSessionErrorPropertiesErrorNameMessageAbortedError EventListResponseEventSessionErrorPropertiesErrorName = "MessageAbortedError" ) func (r EventListResponseEventSessionErrorPropertiesErrorName) IsKnown() bool { switch r { - case EventListResponseEventSessionErrorPropertiesErrorNameProviderAuthError, EventListResponseEventSessionErrorPropertiesErrorNameUnknownError, EventListResponseEventSessionErrorPropertiesErrorNameMessageOutputLengthError: + case EventListResponseEventSessionErrorPropertiesErrorNameProviderAuthError, EventListResponseEventSessionErrorPropertiesErrorNameUnknownError, EventListResponseEventSessionErrorPropertiesErrorNameMessageOutputLengthError, EventListResponseEventSessionErrorPropertiesErrorNameMessageAbortedError: return true } return false @@ -1160,10 +1168,10 @@ const ( EventListResponseTypePermissionUpdated EventListResponseType = "permission.updated" EventListResponseTypeFileEdited EventListResponseType = "file.edited" EventListResponseTypeInstallationUpdated EventListResponseType = "installation.updated" - EventListResponseTypeStorageWrite EventListResponseType = "storage.write" EventListResponseTypeMessageUpdated EventListResponseType = "message.updated" EventListResponseTypeMessageRemoved EventListResponseType = "message.removed" EventListResponseTypeMessagePartUpdated EventListResponseType = "message.part.updated" + EventListResponseTypeStorageWrite EventListResponseType = "storage.write" EventListResponseTypeSessionUpdated EventListResponseType = "session.updated" EventListResponseTypeSessionDeleted EventListResponseType = "session.deleted" EventListResponseTypeSessionIdle EventListResponseType = "session.idle" @@ -1173,7 +1181,7 @@ const ( func (r EventListResponseType) IsKnown() bool { switch r { - case EventListResponseTypeLspClientDiagnostics, EventListResponseTypePermissionUpdated, EventListResponseTypeFileEdited, EventListResponseTypeInstallationUpdated, EventListResponseTypeStorageWrite, EventListResponseTypeMessageUpdated, EventListResponseTypeMessageRemoved, EventListResponseTypeMessagePartUpdated, EventListResponseTypeSessionUpdated, EventListResponseTypeSessionDeleted, EventListResponseTypeSessionIdle, EventListResponseTypeSessionError, EventListResponseTypeFileWatcherUpdated: + case EventListResponseTypeLspClientDiagnostics, EventListResponseTypePermissionUpdated, EventListResponseTypeFileEdited, EventListResponseTypeInstallationUpdated, EventListResponseTypeMessageUpdated, EventListResponseTypeMessageRemoved, EventListResponseTypeMessagePartUpdated, EventListResponseTypeStorageWrite, EventListResponseTypeSessionUpdated, EventListResponseTypeSessionDeleted, EventListResponseTypeSessionIdle, EventListResponseTypeSessionError, EventListResponseTypeFileWatcherUpdated: return true } return false diff --git a/packages/tui/sdk/release-please-config.json b/packages/tui/sdk/release-please-config.json index 32960ce2..a38198ec 100644 --- a/packages/tui/sdk/release-please-config.json +++ b/packages/tui/sdk/release-please-config.json @@ -60,5 +60,8 @@ } ], "release-type": "go", - "extra-files": ["internal/version.go", "README.md"] -} + "extra-files": [ + "internal/version.go", + "README.md" + ] +} \ No newline at end of file diff --git a/packages/tui/sdk/session.go b/packages/tui/sdk/session.go index 816ee7e3..fc55c691 100644 --- a/packages/tui/sdk/session.go +++ b/packages/tui/sdk/session.go @@ -340,13 +340,14 @@ func (r *AssistantMessageError) UnmarshalJSON(data []byte) (err error) { // the specific types for more type safety. // // Possible runtime types of the union are [shared.ProviderAuthError], -// [shared.UnknownError], [AssistantMessageErrorMessageOutputLengthError]. +// [shared.UnknownError], [AssistantMessageErrorMessageOutputLengthError], +// [shared.MessageAbortedError]. func (r AssistantMessageError) AsUnion() AssistantMessageErrorUnion { return r.union } -// Union satisfied by [shared.ProviderAuthError], [shared.UnknownError] or -// [AssistantMessageErrorMessageOutputLengthError]. +// Union satisfied by [shared.ProviderAuthError], [shared.UnknownError], +// [AssistantMessageErrorMessageOutputLengthError] or [shared.MessageAbortedError]. type AssistantMessageErrorUnion interface { ImplementsAssistantMessageError() } @@ -370,6 +371,11 @@ func init() { Type: reflect.TypeOf(AssistantMessageErrorMessageOutputLengthError{}), DiscriminatorValue: "MessageOutputLengthError", }, + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(shared.MessageAbortedError{}), + DiscriminatorValue: "MessageAbortedError", + }, ) } @@ -418,11 +424,12 @@ const ( AssistantMessageErrorNameProviderAuthError AssistantMessageErrorName = "ProviderAuthError" AssistantMessageErrorNameUnknownError AssistantMessageErrorName = "UnknownError" AssistantMessageErrorNameMessageOutputLengthError AssistantMessageErrorName = "MessageOutputLengthError" + AssistantMessageErrorNameMessageAbortedError AssistantMessageErrorName = "MessageAbortedError" ) func (r AssistantMessageErrorName) IsKnown() bool { switch r { - case AssistantMessageErrorNameProviderAuthError, AssistantMessageErrorNameUnknownError, AssistantMessageErrorNameMessageOutputLengthError: + case AssistantMessageErrorNameProviderAuthError, AssistantMessageErrorNameUnknownError, AssistantMessageErrorNameMessageOutputLengthError, AssistantMessageErrorNameMessageAbortedError: return true } return false @@ -889,7 +896,7 @@ func (r ToolPart) implementsAssistantMessagePart() {} type ToolPartState struct { Status ToolPartStateStatus `json:"status,required"` Error string `json:"error"` - // This field can have the runtime type of [interface{}]. + // This field can have the runtime type of [interface{}], [map[string]interface{}]. Input interface{} `json:"input"` // This field can have the runtime type of [map[string]interface{}]. Metadata interface{} `json:"metadata"` @@ -1002,24 +1009,24 @@ func (r ToolPartType) IsKnown() bool { } type ToolStateCompleted struct { + Input map[string]interface{} `json:"input,required"` Metadata map[string]interface{} `json:"metadata,required"` Output string `json:"output,required"` Status ToolStateCompletedStatus `json:"status,required"` Time ToolStateCompletedTime `json:"time,required"` Title string `json:"title,required"` - Input interface{} `json:"input"` JSON toolStateCompletedJSON `json:"-"` } // toolStateCompletedJSON contains the JSON metadata for the struct // [ToolStateCompleted] type toolStateCompletedJSON struct { + Input apijson.Field Metadata apijson.Field Output apijson.Field Status apijson.Field Time apijson.Field Title apijson.Field - Input apijson.Field raw string ExtraFields map[string]apijson.Field } @@ -1072,19 +1079,19 @@ func (r toolStateCompletedTimeJSON) RawJSON() string { } type ToolStateError struct { - Error string `json:"error,required"` - Status ToolStateErrorStatus `json:"status,required"` - Time ToolStateErrorTime `json:"time,required"` - Input interface{} `json:"input"` - JSON toolStateErrorJSON `json:"-"` + Error string `json:"error,required"` + Input map[string]interface{} `json:"input,required"` + Status ToolStateErrorStatus `json:"status,required"` + Time ToolStateErrorTime `json:"time,required"` + JSON toolStateErrorJSON `json:"-"` } // toolStateErrorJSON contains the JSON metadata for the struct [ToolStateError] type toolStateErrorJSON struct { Error apijson.Field + Input apijson.Field Status apijson.Field Time apijson.Field - Input apijson.Field raw string ExtraFields map[string]apijson.Field } diff --git a/packages/tui/sdk/shared/shared.go b/packages/tui/sdk/shared/shared.go index 1ab53b6f..58baf3d9 100644 --- a/packages/tui/sdk/shared/shared.go +++ b/packages/tui/sdk/shared/shared.go @@ -6,6 +6,47 @@ import ( "github.com/sst/opencode-sdk-go/internal/apijson" ) +type MessageAbortedError struct { + Data interface{} `json:"data,required"` + Name MessageAbortedErrorName `json:"name,required"` + JSON messageAbortedErrorJSON `json:"-"` +} + +// messageAbortedErrorJSON contains the JSON metadata for the struct +// [MessageAbortedError] +type messageAbortedErrorJSON struct { + Data apijson.Field + Name apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *MessageAbortedError) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r messageAbortedErrorJSON) RawJSON() string { + return r.raw +} + +func (r MessageAbortedError) ImplementsEventListResponseEventSessionErrorPropertiesError() {} + +func (r MessageAbortedError) ImplementsAssistantMessageError() {} + +type MessageAbortedErrorName string + +const ( + MessageAbortedErrorNameMessageAbortedError MessageAbortedErrorName = "MessageAbortedError" +) + +func (r MessageAbortedErrorName) IsKnown() bool { + switch r { + case MessageAbortedErrorNameMessageAbortedError: + return true + } + return false +} + type ProviderAuthError struct { Data ProviderAuthErrorData `json:"data,required"` Name ProviderAuthErrorName `json:"name,required"` diff --git a/stainless.yml b/stainless.yml index 941c4f38..2e262b74 100644 --- a/stainless.yml +++ b/stainless.yml @@ -34,6 +34,7 @@ resources: models: unknownError: UnknownError providerAuthError: ProviderAuthError + messageAbortedError: MessageAbortedError event: methods: From 6100a77b853917292a024ccdd818ff43e0acc0f1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 7 Jul 2025 21:04:29 -0400 Subject: [PATCH 44/52] start file watcher only for tui --- packages/opencode/src/cli/bootstrap.ts | 2 -- packages/opencode/src/cli/cmd/tui.ts | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index a03392a5..4419773b 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,6 +1,5 @@ import { App } from "../app/app" import { ConfigHooks } from "../config/hooks" -import { FileWatcher } from "../file/watch" import { Format } from "../format" import { LSP } from "../lsp" import { Share } from "../share/share" @@ -11,7 +10,6 @@ export async function bootstrap(input: App.Input, cb: (app: App.Info) => Prom Format.init() ConfigHooks.init() LSP.init() - FileWatcher.init() return cb(app) }) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 886863a1..e584a96c 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -10,6 +10,7 @@ import { Installation } from "../../installation" import { Config } from "../../config/config" import { Bus } from "../../bus" import { Log } from "../../util/log" +import { FileWatcher } from "../../file/watch" export const TuiCommand = cmd({ command: "$0 [project]", @@ -40,6 +41,7 @@ export const TuiCommand = cmd({ return } const result = await bootstrap({ cwd }, async (app) => { + FileWatcher.init() const providers = await Provider.list() if (Object.keys(providers).length === 0) { return "needs_provider" From ea96ead346d48f35bcffe829a2b5b667305910c3 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Tue, 8 Jul 2025 05:50:10 -0500 Subject: [PATCH 45/52] feat(tui): handle --model and --prompt flags --- packages/tui/cmd/opencode/main.go | 7 +- packages/tui/go.mod | 2 +- packages/tui/internal/app/app.go | 166 +++++++++++------- .../tui/internal/components/chat/editor.go | 28 ++- 4 files changed, 117 insertions(+), 86 deletions(-) diff --git a/packages/tui/cmd/opencode/main.go b/packages/tui/cmd/opencode/main.go index a9283525..e456a3ed 100644 --- a/packages/tui/cmd/opencode/main.go +++ b/packages/tui/cmd/opencode/main.go @@ -9,6 +9,7 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea/v2" + flag "github.com/spf13/pflag" "github.com/sst/opencode-sdk-go" "github.com/sst/opencode-sdk-go/option" "github.com/sst/opencode/internal/app" @@ -23,6 +24,10 @@ func main() { version = "v" + Version } + var model *string = flag.String("model", "", "model to begin with") + var prompt *string = flag.String("prompt", "", "prompt to begin with") + flag.Parse() + url := os.Getenv("OPENCODE_SERVER") appInfoStr := os.Getenv("OPENCODE_APP_INFO") @@ -65,7 +70,7 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - app_, err := app.New(ctx, version, appInfo, httpClient) + app_, err := app.New(ctx, version, appInfo, httpClient, model, prompt) if err != nil { panic(err) } diff --git a/packages/tui/go.mod b/packages/tui/go.mod index 90d2af4b..a32dd46b 100644 --- a/packages/tui/go.mod +++ b/packages/tui/go.mod @@ -17,7 +17,6 @@ require ( 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.8 - github.com/tidwall/gjson v1.14.4 rsc.io/qr v0.2.0 ) @@ -50,6 +49,7 @@ require ( github.com/sosodev/duration v1.3.1 // indirect github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect github.com/spf13/cobra v1.9.1 // indirect + github.com/tidwall/gjson v1.14.4 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index cffd0739..08a999f1 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -21,17 +21,19 @@ import ( ) type App struct { - Info opencode.App - Version string - StatePath string - Config *opencode.Config - Client *opencode.Client - State *config.State - Provider *opencode.Provider - Model *opencode.Model - Session *opencode.Session - Messages []opencode.MessageUnion - Commands commands.CommandRegistry + Info opencode.App + Version string + StatePath string + Config *opencode.Config + Client *opencode.Client + State *config.State + Provider *opencode.Provider + Model *opencode.Model + Session *opencode.Session + Messages []opencode.MessageUnion + Commands commands.CommandRegistry + InitialModel *string + InitialPrompt *string } type SessionSelectedMsg = *opencode.Session @@ -58,6 +60,8 @@ func New( version string, appInfo opencode.App, httpClient *opencode.Client, + model *string, + prompt *string, ) (*App, error) { util.RootPath = appInfo.Path.Root util.CwdPath = appInfo.Path.Cwd @@ -109,15 +113,17 @@ func New( slog.Debug("Loaded config", "config", configInfo) app := &App{ - Info: appInfo, - Version: version, - StatePath: appStatePath, - Config: configInfo, - State: appState, - Client: httpClient, - Session: &opencode.Session{}, - Messages: []opencode.MessageUnion{}, - Commands: commands.LoadFromConfig(configInfo), + Info: appInfo, + Version: version, + StatePath: appStatePath, + Config: configInfo, + State: appState, + Client: httpClient, + Session: &opencode.Session{}, + Messages: []opencode.MessageUnion{}, + Commands: commands.LoadFromConfig(configInfo), + InitialModel: model, + InitialPrompt: prompt, } return app, nil @@ -141,65 +147,89 @@ func (a *App) Key(commandName commands.CommandName) string { } func (a *App) InitializeProvider() tea.Cmd { - return func() tea.Msg { - providersResponse, err := a.Client.Config.Providers(context.Background()) - if err != nil { - slog.Error("Failed to list providers", "error", err) - // TODO: notify user - return nil - } - providers := providersResponse.Providers - var defaultProvider *opencode.Provider - var defaultModel *opencode.Model + providersResponse, err := a.Client.Config.Providers(context.Background()) + if err != nil { + slog.Error("Failed to list providers", "error", err) + // TODO: notify user + return nil + } + providers := providersResponse.Providers + var defaultProvider *opencode.Provider + var defaultModel *opencode.Model - var anthropic *opencode.Provider - for _, provider := range providers { - if provider.ID == "anthropic" { - anthropic = &provider + var anthropic *opencode.Provider + for _, provider := range providers { + if provider.ID == "anthropic" { + anthropic = &provider + } + } + + // default to anthropic if available + if anthropic != nil { + defaultProvider = anthropic + defaultModel = getDefaultModel(providersResponse, *anthropic) + } + + for _, provider := range providers { + if defaultProvider == nil || defaultModel == nil { + defaultProvider = &provider + defaultModel = getDefaultModel(providersResponse, provider) + } + providers = append(providers, provider) + } + if len(providers) == 0 { + slog.Error("No providers configured") + return nil + } + + var currentProvider *opencode.Provider + var currentModel *opencode.Model + for _, provider := range providers { + if provider.ID == a.State.Provider { + currentProvider = &provider + + for _, model := range provider.Models { + if model.ID == a.State.Model { + currentModel = &model + } } } + } + if currentProvider == nil || currentModel == nil { + currentProvider = defaultProvider + currentModel = defaultModel + } - // default to anthropic if available - if anthropic != nil { - defaultProvider = anthropic - defaultModel = getDefaultModel(providersResponse, *anthropic) - } - + var initialProvider *opencode.Provider + var initialModel *opencode.Model + if a.InitialModel != nil && *a.InitialModel != "" { + splits := strings.Split(*a.InitialModel, "/") for _, provider := range providers { - if defaultProvider == nil || defaultModel == nil { - defaultProvider = &provider - defaultModel = getDefaultModel(providersResponse, provider) - } - providers = append(providers, provider) - } - if len(providers) == 0 { - slog.Error("No providers configured") - return nil - } - - var currentProvider *opencode.Provider - var currentModel *opencode.Model - for _, provider := range providers { - if provider.ID == a.State.Provider { - currentProvider = &provider - + if provider.ID == splits[0] { + initialProvider = &provider for _, model := range provider.Models { - if model.ID == a.State.Model { - currentModel = &model + if model.ID == splits[1] { + initialModel = &model } } } } - if currentProvider == nil || currentModel == nil { - currentProvider = defaultProvider - currentModel = defaultModel - } - - return ModelSelectedMsg{ - Provider: *currentProvider, - Model: *currentModel, - } } + + if initialProvider != nil && initialModel != nil { + currentProvider = initialProvider + currentModel = initialModel + } + + var cmds []tea.Cmd + cmds = append(cmds, util.CmdHandler(ModelSelectedMsg{ + Provider: *currentProvider, + Model: *currentModel, + })) + if a.InitialPrompt != nil && *a.InitialPrompt != "" { + cmds = append(cmds, util.CmdHandler(SendMsg{Text: *a.InitialPrompt})) + } + return tea.Sequence(cmds...) } func getDefaultModel( diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 5aa05bd1..f32e5c32 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -64,7 +64,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } case dialog.ThemeSelectedMsg: - m.textarea = createTextArea(&m.textarea) + m.textarea = m.resetTextareaStyles() m.spinner = createSpinner() return m, tea.Batch(m.spinner.Tick, m.textarea.Focus()) case dialog.CompletionSelectedMsg: @@ -306,13 +306,13 @@ func (m *editorComponent) getSubmitKeyText() string { return m.app.Commands[commands.InputSubmitCommand].Keys()[0] } -func createTextArea(existing *textarea.Model) textarea.Model { +func (m *editorComponent) resetTextareaStyles() textarea.Model { t := theme.CurrentTheme() bgColor := t.BackgroundElement() textColor := t.Text() textMutedColor := t.TextMuted() - ta := textarea.New() + ta := m.textarea ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss() @@ -337,17 +337,6 @@ func createTextArea(existing *textarea.Model) textarea.Model { Background(t.Secondary()). Lipgloss() ta.Styles.Cursor.Color = t.Primary() - - ta.Prompt = " " - ta.ShowLineNumbers = false - ta.CharLimit = -1 - - if existing != nil { - ta.SetValue(existing.Value()) - // ta.SetWidth(existing.Width()) - ta.SetHeight(existing.Height()) - } - return ta } @@ -367,12 +356,19 @@ func createSpinner() spinner.Model { func NewEditorComponent(app *app.App) EditorComponent { s := createSpinner() - ta := createTextArea(nil) - return &editorComponent{ + ta := textarea.New() + ta.Prompt = " " + ta.ShowLineNumbers = false + ta.CharLimit = -1 + + m := &editorComponent{ app: app, textarea: ta, spinner: s, interruptKeyInDebounce: false, } + m.resetTextareaStyles() + + return m } From 7a9fb3fa926f5cfddb63e7f5cf94dac24b824da8 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 8 Jul 2025 10:51:06 +0000 Subject: [PATCH 46/52] ignore: update download stats 2025-07-08 --- STATS.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/STATS.md b/STATS.md index e141b36f..e1e0d5e5 100644 --- a/STATS.md +++ b/STATS.md @@ -1,12 +1,13 @@ # Download Stats -| Date | GitHub Downloads | npm Downloads | Total | -| ---------- | ---------------- | --------------- | --------------- | -| 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) | -| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) | -| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) | +| Date | GitHub Downloads | npm Downloads | Total | +| ---------- | ---------------- | --------------- | ---------------- | +| 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) | +| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) | +| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) | +| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) | From 9efef03919f99750277a7f15722c24d7c7958224 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 8 Jul 2025 12:04:27 +0000 Subject: [PATCH 47/52] ignore: update download stats 2025-07-08 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index e1e0d5e5..3414a4e7 100644 --- a/STATS.md +++ b/STATS.md @@ -11,3 +11,4 @@ | 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) | | 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) | | 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) | +| 2025-07-08 | 38,170 (+118) | 64,468 (+0) | 102,638 (+118) | From 662d022a4859ee1c004133559ee42c5f7044dda7 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Tue, 8 Jul 2025 08:08:53 -0500 Subject: [PATCH 48/52] feat(tui): paste images and pdfs --- package.json | 2 +- packages/opencode/src/config/config.ts | 12 +- packages/tui/cmd/opencode/main.go | 8 + packages/tui/go.mod | 22 +- packages/tui/go.sum | 41 ++-- packages/tui/internal/app/app.go | 12 ++ packages/tui/internal/commands/command.go | 2 +- .../tui/internal/components/chat/editor.go | 93 +++++++-- .../internal/components/textarea/textarea.go | 26 +-- packages/tui/internal/image/clipboard_unix.go | 46 ----- .../tui/internal/image/clipboard_windows.go | 192 ------------------ packages/tui/internal/image/images.go | 86 -------- packages/tui/internal/tui/tui.go | 4 +- packages/tui/sdk/.stats.yml | 4 +- packages/tui/sdk/config.go | 36 +++- packages/tui/sdk/scripts/lint | 5 +- 16 files changed, 182 insertions(+), 409 deletions(-) delete mode 100644 packages/tui/internal/image/clipboard_unix.go delete mode 100644 packages/tui/internal/image/clipboard_windows.go delete mode 100644 packages/tui/internal/image/images.go diff --git a/package.json b/package.json index 2a0038e0..7a8c6b07 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "bun run packages/opencode/src/index.ts", "typecheck": "bun run --filter='*' typecheck", - "stainless": "bun run ./packages/opencode/src/index.ts serve ", + "stainless": "./scripts/stainless", "postinstall": "./scripts/hooks" }, "workspaces": { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c90c951d..c43a382a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -58,23 +58,26 @@ export namespace Config { export const Keybinds = z .object({ leader: z.string().optional().describe("Leader key for keybind combinations"), - help: z.string().optional().describe("Show help dialog"), + app_help: z.string().optional().describe("Show help dialog"), editor_open: z.string().optional().describe("Open external editor"), session_new: z.string().optional().describe("Create a new session"), session_list: z.string().optional().describe("List all sessions"), session_share: z.string().optional().describe("Share current session"), + session_unshare: z.string().optional().describe("Unshare current session"), session_interrupt: z.string().optional().describe("Interrupt current session"), session_compact: z.string().optional().describe("Toggle compact mode for session"), tool_details: z.string().optional().describe("Show tool details"), model_list: z.string().optional().describe("List available models"), theme_list: z.string().optional().describe("List available themes"), + file_list: z.string().optional().describe("List files"), + file_close: z.string().optional().describe("Close file"), + file_search: z.string().optional().describe("Search file"), + file_diff_toggle: z.string().optional().describe("Toggle split/unified diff"), project_init: z.string().optional().describe("Initialize project configuration"), input_clear: z.string().optional().describe("Clear input field"), input_paste: z.string().optional().describe("Paste from clipboard"), input_submit: z.string().optional().describe("Submit input"), input_newline: z.string().optional().describe("Insert newline in input"), - history_previous: z.string().optional().describe("Navigate to previous history item"), - history_next: z.string().optional().describe("Navigate to next history item"), messages_page_up: z.string().optional().describe("Scroll messages up by one page"), messages_page_down: z.string().optional().describe("Scroll messages down by one page"), messages_half_page_up: z.string().optional().describe("Scroll messages up by half page"), @@ -83,6 +86,9 @@ export namespace Config { messages_next: z.string().optional().describe("Navigate to next message"), messages_first: z.string().optional().describe("Navigate to first message"), messages_last: z.string().optional().describe("Navigate to last message"), + messages_layout_toggle: z.string().optional().describe("Toggle layout"), + messages_copy: z.string().optional().describe("Copy message"), + messages_revert: z.string().optional().describe("Revert message"), app_exit: z.string().optional().describe("Exit the application"), }) .strict() diff --git a/packages/tui/cmd/opencode/main.go b/packages/tui/cmd/opencode/main.go index e456a3ed..ada1880d 100644 --- a/packages/tui/cmd/opencode/main.go +++ b/packages/tui/cmd/opencode/main.go @@ -14,6 +14,7 @@ import ( "github.com/sst/opencode-sdk-go/option" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/tui" + "golang.design/x/clipboard" ) var Version = "dev" @@ -66,6 +67,13 @@ func main() { os.Exit(1) } + go func() { + err = clipboard.Init() + if err != nil { + slog.Error("Failed to initialize clipboard", "error", err) + } + }() + // Create main context for the application ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/packages/tui/go.mod b/packages/tui/go.mod index a32dd46b..10f6e7e4 100644 --- a/packages/tui/go.mod +++ b/packages/tui/go.mod @@ -17,6 +17,7 @@ require ( 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.8 + golang.design/x/clipboard v0.7.1 rsc.io/qr v0.2.0 ) @@ -54,8 +55,10 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/tools v0.31.0 // indirect + golang.org/x/exp/shiny v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/tools v0.34.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) @@ -66,7 +69,6 @@ require ( github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/disintegration/imaging v1.6.2 github.com/dlclark/regexp2 v1.11.5 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/gorilla/css v1.0.1 // indirect @@ -78,16 +80,16 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/pflag v1.0.6 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect - golang.org/x/image v0.26.0 - golang.org/x/net v0.39.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/term v0.31.0 // indirect - golang.org/x/text v0.24.0 + golang.org/x/image v0.28.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.26.0 gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/packages/tui/go.sum b/packages/tui/go.sum index fdc5bbb0..f3541711 100644 --- a/packages/tui/go.sum +++ b/packages/tui/go.sum @@ -54,8 +54,6 @@ github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= -github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= @@ -212,20 +210,25 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c= +golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= -golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= +golang.org/x/exp/shiny v0.0.0-20250620022241-b7579e27df2b h1:zELBzk+7ERc6m8BxhzU2VYjp03wlEvi+cIgYQR5H3CI= +golang.org/x/exp/shiny v0.0.0-20250620022241-b7579e27df2b/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= +golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= +golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= +golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8= +golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -236,15 +239,15 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -263,28 +266,28 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= -golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 08a999f1..237ee0fe 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -18,6 +18,7 @@ import ( "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" + "golang.design/x/clipboard" ) type App struct { @@ -146,6 +147,17 @@ func (a *App) Key(commandName commands.CommandName) string { return base(key) + muted(" "+command.Description) } +func (a *App) SetClipboard(text string) tea.Cmd { + var cmds []tea.Cmd + cmds = append(cmds, func() tea.Msg { + clipboard.Write(clipboard.FmtText, []byte(text)) + return nil + }) + // try to set the clipboard using OSC52 for terminals that support it + cmds = append(cmds, tea.SetClipboard(text)) + return tea.Sequence(cmds...) +} + func (a *App) InitializeProvider() tea.Cmd { providersResponse, err := a.Client.Config.Providers(context.Background()) if err != nil { diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index 9c4da12e..10b0d7e2 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -231,7 +231,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { { Name: InputPasteCommand, Description: "paste content", - Keybindings: parseBindings("ctrl+v"), + Keybindings: parseBindings("ctrl+v", "super+v"), }, { Name: InputSubmitCommand, diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index f32e5c32..cc31fbef 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -1,9 +1,12 @@ package chat import ( + "encoding/base64" "fmt" "log/slog" + "os" "path/filepath" + "strconv" "strings" "github.com/charmbracelet/bubbles/v2/spinner" @@ -15,10 +18,10 @@ import ( "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/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" + "golang.design/x/clipboard" ) type EditorComponent interface { @@ -63,6 +66,57 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } + case tea.PasteMsg: + text := string(msg) + text = strings.ReplaceAll(text, "\\", "") + text, err := strconv.Unquote(`"` + text + `"`) + if err != nil { + slog.Error("Failed to unquote text", "error", err) + m.textarea.InsertRunesFromUserInput([]rune(msg)) + return m, nil + } + if _, err := os.Stat(text); err != nil { + slog.Error("Failed to paste file", "error", err) + m.textarea.InsertRunesFromUserInput([]rune(msg)) + return m, nil + } + + filePath := text + ext := strings.ToLower(filepath.Ext(filePath)) + + mediaType := "" + switch ext { + case ".jpg": + mediaType = "image/jpeg" + case ".png", ".jpeg", ".gif", ".webp": + mediaType = "image/" + ext[1:] + case ".pdf": + mediaType = "application/pdf" + default: + mediaType = "text/plain" + } + + fileBytes, err := os.ReadFile(filePath) + if err != nil { + slog.Error("Failed to read file", "error", err) + m.textarea.InsertRunesFromUserInput([]rune(msg)) + return m, nil + } + base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes) + url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile) + + attachment := &textarea.Attachment{ + ID: uuid.NewString(), + Display: fmt.Sprintf("<%s>", filePath), + URL: url, + Filename: filePath, + MediaType: mediaType, + } + m.textarea.InsertAttachment(attachment) + m.textarea.InsertString(" ") + case tea.ClipboardMsg: + text := string(msg) + m.textarea.InsertRunesFromUserInput([]rune(text)) case dialog.ThemeSelectedMsg: m.textarea = m.resetTextareaStyles() m.spinner = createSpinner() @@ -269,24 +323,29 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) { } func (m *editorComponent) Paste() (tea.Model, tea.Cmd) { - _, text, err := image.GetImageFromClipboard() - if err != nil { - slog.Error(err.Error()) + imageBytes := clipboard.Read(clipboard.FmtImage) + if imageBytes != nil { + base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes) + attachment := &textarea.Attachment{ + ID: uuid.NewString(), + Display: "", + Filename: "clipboard-image", + MediaType: "image/png", + URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile), + } + m.textarea.InsertAttachment(attachment) + m.textarea.InsertString(" ") 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.InsertString(text) - // } - return m, nil + + textBytes := clipboard.Read(clipboard.FmtText) + if textBytes != nil { + m.textarea.InsertRunesFromUserInput([]rune(string(textBytes))) + return m, nil + } + + // fallback to reading the clipboard using OSC52 + return m, tea.ReadClipboard } func (m *editorComponent) Newline() (tea.Model, tea.Cmd) { diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index 5ff936f1..41a4a3d9 100644 --- a/packages/tui/internal/components/textarea/textarea.go +++ b/packages/tui/internal/components/textarea/textarea.go @@ -11,7 +11,6 @@ import ( "slices" - "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/v2/cursor" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" @@ -653,12 +652,12 @@ func (m *Model) SetValue(s string) { // InsertString inserts a string at the cursor position. func (m *Model) InsertString(s string) { - m.insertRunesFromUserInput([]rune(s)) + m.InsertRunesFromUserInput([]rune(s)) } // InsertRune inserts a rune at the cursor position. func (m *Model) InsertRune(r rune) { - m.insertRunesFromUserInput([]rune{r}) + m.InsertRunesFromUserInput([]rune{r}) } // InsertAttachment inserts an attachment at the cursor position. @@ -730,8 +729,8 @@ func (m Model) GetAttachments() []*Attachment { return attachments } -// insertRunesFromUserInput inserts runes at the current cursor position. -func (m *Model) insertRunesFromUserInput(runes []rune) { +// 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 // clipboard. This avoids bugs due to e.g. tab characters and // whatnot. @@ -1429,8 +1428,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } switch msg := msg.(type) { - case tea.PasteMsg: - m.insertRunesFromUserInput([]rune(msg)) case tea.KeyPressMsg: switch { case key.Matches(msg, m.KeyMap.DeleteAfterCursor): @@ -1490,8 +1487,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.CursorDown() case key.Matches(msg, m.KeyMap.WordForward): m.wordRight() - case key.Matches(msg, m.KeyMap.Paste): - return m, Paste case key.Matches(msg, m.KeyMap.CharacterBackward): m.characterLeft(false /* insideLine */) case key.Matches(msg, m.KeyMap.LinePrevious): @@ -1512,11 +1507,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.transposeLeft() default: - m.insertRunesFromUserInput([]rune(msg.Text)) + m.InsertRunesFromUserInput([]rune(msg.Text)) } case pasteMsg: - m.insertRunesFromUserInput([]rune(msg)) + m.InsertRunesFromUserInput([]rune(msg)) case pasteErrMsg: m.Err = msg @@ -1908,15 +1903,6 @@ func (m *Model) splitLine(row, col int) { m.row++ } -// Paste is a command for pasting from the clipboard into the text input. -func Paste() tea.Msg { - str, err := clipboard.ReadAll() - if err != nil { - return pasteErrMsg{err} - } - return pasteMsg(str) -} - func wrapInterfaces(content []any, width int) [][]any { if width <= 0 { return [][]any{content} diff --git a/packages/tui/internal/image/clipboard_unix.go b/packages/tui/internal/image/clipboard_unix.go deleted file mode 100644 index 2653d8ca..00000000 --- a/packages/tui/internal/image/clipboard_unix.go +++ /dev/null @@ -1,46 +0,0 @@ -//go:build !windows - -package image - -import ( - "bytes" - "fmt" - "github.com/atotto/clipboard" - "image" -) - -func GetImageFromClipboard() ([]byte, string, error) { - text, err := clipboard.ReadAll() - if err != nil { - return nil, "", fmt.Errorf("Error reading clipboard") - } - - if text == "" { - return nil, "", nil - } - - binaryData := []byte(text) - imageBytes, err := binaryToImage(binaryData) - if err != nil { - return nil, text, nil - } - return imageBytes, "", nil - -} - -func binaryToImage(data []byte) ([]byte, error) { - reader := bytes.NewReader(data) - img, _, err := image.Decode(reader) - if err != nil { - return nil, fmt.Errorf("Unable to covert bytes to image") - } - - return ImageToBytes(img) -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/packages/tui/internal/image/clipboard_windows.go b/packages/tui/internal/image/clipboard_windows.go deleted file mode 100644 index 6431ce3d..00000000 --- a/packages/tui/internal/image/clipboard_windows.go +++ /dev/null @@ -1,192 +0,0 @@ -//go:build windows - -package image - -import ( - "bytes" - "fmt" - "image" - "image/color" - "log/slog" - "syscall" - "unsafe" -) - -var ( - user32 = syscall.NewLazyDLL("user32.dll") - kernel32 = syscall.NewLazyDLL("kernel32.dll") - openClipboard = user32.NewProc("OpenClipboard") - closeClipboard = user32.NewProc("CloseClipboard") - getClipboardData = user32.NewProc("GetClipboardData") - isClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable") - globalLock = kernel32.NewProc("GlobalLock") - globalUnlock = kernel32.NewProc("GlobalUnlock") - globalSize = kernel32.NewProc("GlobalSize") -) - -const ( - CF_TEXT = 1 - CF_UNICODETEXT = 13 - CF_DIB = 8 -) - -type BITMAPINFOHEADER struct { - BiSize uint32 - BiWidth int32 - BiHeight int32 - BiPlanes uint16 - BiBitCount uint16 - BiCompression uint32 - BiSizeImage uint32 - BiXPelsPerMeter int32 - BiYPelsPerMeter int32 - BiClrUsed uint32 - BiClrImportant uint32 -} - -func GetImageFromClipboard() ([]byte, string, error) { - ret, _, _ := openClipboard.Call(0) - if ret == 0 { - return nil, "", fmt.Errorf("failed to open clipboard") - } - defer func(closeClipboard *syscall.LazyProc, a ...uintptr) { - _, _, err := closeClipboard.Call(a...) - if err != nil { - slog.Error("close clipboard failed") - return - } - }(closeClipboard) - isTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_TEXT)) - isUnicodeTextAvailable, _, _ := isClipboardFormatAvailable.Call(uintptr(CF_UNICODETEXT)) - - if isTextAvailable != 0 || isUnicodeTextAvailable != 0 { - // Get text from clipboard - var formatToUse uintptr = CF_TEXT - if isUnicodeTextAvailable != 0 { - formatToUse = CF_UNICODETEXT - } - - hClipboardText, _, _ := getClipboardData.Call(formatToUse) - if hClipboardText != 0 { - textPtr, _, _ := globalLock.Call(hClipboardText) - if textPtr != 0 { - defer func(globalUnlock *syscall.LazyProc, a ...uintptr) { - _, _, err := globalUnlock.Call(a...) - if err != nil { - slog.Error("Global unlock failed") - return - } - }(globalUnlock, hClipboardText) - - // Get clipboard text - var clipboardText string - if formatToUse == CF_UNICODETEXT { - // Convert wide string to Go string - clipboardText = syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(textPtr))[:]) - } else { - // Get size of ANSI text - size, _, _ := globalSize.Call(hClipboardText) - if size > 0 { - // Convert ANSI string to Go string - textBytes := make([]byte, size) - copy(textBytes, (*[1 << 20]byte)(unsafe.Pointer(textPtr))[:size:size]) - clipboardText = bytesToString(textBytes) - } - } - - // Check if the text is not empty - if clipboardText != "" { - return nil, clipboardText, nil - } - } - } - } - hClipboardData, _, _ := getClipboardData.Call(uintptr(CF_DIB)) - if hClipboardData == 0 { - return nil, "", fmt.Errorf("failed to get clipboard data") - } - - dataPtr, _, _ := globalLock.Call(hClipboardData) - if dataPtr == 0 { - return nil, "", fmt.Errorf("failed to lock clipboard data") - } - defer func(globalUnlock *syscall.LazyProc, a ...uintptr) { - _, _, err := globalUnlock.Call(a...) - if err != nil { - slog.Error("Global unlock failed") - return - } - }(globalUnlock, hClipboardData) - - bmiHeader := (*BITMAPINFOHEADER)(unsafe.Pointer(dataPtr)) - - width := int(bmiHeader.BiWidth) - height := int(bmiHeader.BiHeight) - if height < 0 { - height = -height - } - bitsPerPixel := int(bmiHeader.BiBitCount) - - img := image.NewRGBA(image.Rect(0, 0, width, height)) - - var bitsOffset uintptr - if bitsPerPixel <= 8 { - numColors := uint32(1) << bitsPerPixel - if bmiHeader.BiClrUsed > 0 { - numColors = bmiHeader.BiClrUsed - } - bitsOffset = unsafe.Sizeof(*bmiHeader) + uintptr(numColors*4) - } else { - bitsOffset = unsafe.Sizeof(*bmiHeader) - } - - for y := range height { - for x := range width { - - srcY := height - y - 1 - if bmiHeader.BiHeight < 0 { - srcY = y - } - - var pixelPointer unsafe.Pointer - var r, g, b, a uint8 - - switch bitsPerPixel { - case 24: - stride := (width*3 + 3) &^ 3 - pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*stride+x*3)) - b = *(*byte)(pixelPointer) - g = *(*byte)(unsafe.Add(pixelPointer, 1)) - r = *(*byte)(unsafe.Add(pixelPointer, 2)) - a = 255 - case 32: - pixelPointer = unsafe.Pointer(dataPtr + bitsOffset + uintptr(srcY*width*4+x*4)) - b = *(*byte)(pixelPointer) - g = *(*byte)(unsafe.Add(pixelPointer, 1)) - r = *(*byte)(unsafe.Add(pixelPointer, 2)) - a = *(*byte)(unsafe.Add(pixelPointer, 3)) - if a == 0 { - a = 255 - } - default: - return nil, "", fmt.Errorf("unsupported bit count: %d", bitsPerPixel) - } - - img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: a}) - } - } - - imageBytes, err := ImageToBytes(img) - if err != nil { - return nil, "", err - } - return imageBytes, "", nil -} - -func bytesToString(b []byte) string { - i := bytes.IndexByte(b, 0) - if i == -1 { - return string(b) - } - return string(b[:i]) -} diff --git a/packages/tui/internal/image/images.go b/packages/tui/internal/image/images.go deleted file mode 100644 index 742eb30a..00000000 --- a/packages/tui/internal/image/images.go +++ /dev/null @@ -1,86 +0,0 @@ -package image - -import ( - "bytes" - "fmt" - "image" - "image/color" - "image/png" - "os" - "strings" - - "github.com/charmbracelet/lipgloss/v2" - "github.com/disintegration/imaging" - "github.com/lucasb-eyer/go-colorful" - _ "golang.org/x/image/webp" -) - -func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) { - fileInfo, err := os.Stat(filePath) - if err != nil { - return false, fmt.Errorf("error getting file info: %w", err) - } - - if fileInfo.Size() > sizeLimit { - return true, nil - } - - return false, nil -} - -func ToString(width int, img image.Image) string { - img = imaging.Resize(img, width, 0, imaging.Lanczos) - b := img.Bounds() - imageWidth := b.Max.X - h := b.Max.Y - str := strings.Builder{} - - for heightCounter := 0; heightCounter < h; heightCounter += 2 { - for x := range imageWidth { - c1, _ := colorful.MakeColor(img.At(x, heightCounter)) - color1 := lipgloss.Color(c1.Hex()) - - var color2 color.Color - if heightCounter+1 < h { - c2, _ := colorful.MakeColor(img.At(x, heightCounter+1)) - color2 = lipgloss.Color(c2.Hex()) - } else { - color2 = color1 - } - - str.WriteString(lipgloss.NewStyle().Foreground(color1). - Background(color2).Render("▀")) - } - - str.WriteString("\n") - } - - return str.String() -} - -func ImagePreview(width int, filename string) (string, error) { - imageContent, err := os.Open(filename) - if err != nil { - return "", err - } - defer imageContent.Close() - - img, _, err := image.Decode(imageContent) - if err != nil { - return "", err - } - - imageString := ToString(width, img) - - return imageString, nil -} - -func ImageToBytes(image image.Image) ([]byte, error) { - buf := new(bytes.Buffer) - err := png.Encode(buf, image) - if err != nil { - return nil, err - } - - return buf.Bytes(), nil -} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index e32575bd..d25fc139 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -841,7 +841,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) return a, toast.NewErrorToast("Failed to share session") } shareUrl := response.Share.URL - cmds = append(cmds, tea.SetClipboard(shareUrl)) + cmds = append(cmds, a.app.SetClipboard(shareUrl)) cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!")) case commands.SessionUnshareCommand: if a.app.Session.ID == "" { @@ -975,7 +975,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) case commands.MessagesCopyCommand: selected := a.messages.Selected() if selected != "" { - cmd = tea.SetClipboard(selected) + cmd = a.app.SetClipboard(selected) cmds = append(cmds, cmd) cmd = toast.NewSuccessToast("Message copied to clipboard") cmds = append(cmds, cmd) diff --git a/packages/tui/sdk/.stats.yml b/packages/tui/sdk/.stats.yml index c8411903..4b404ade 100644 --- a/packages/tui/sdk/.stats.yml +++ b/packages/tui/sdk/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 20 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-4955370de3d0a21bb41c4e51257210b3284deb5bc3dbace6e7572de0d1635c9e.yml -openapi_spec_hash: b7591d636977423cd7455aa02caa718f +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-c06a9b8d8284683e8350fdd3eceff0b5756877f7b67e974acd565409b67d32a0.yml +openapi_spec_hash: 5933bca0c79177065374ac724a6bc986 config_hash: de53ecf98e1038f2cc2fd273b582f082 diff --git a/packages/tui/sdk/config.go b/packages/tui/sdk/config.go index 39da2f94..503c17bd 100644 --- a/packages/tui/sdk/config.go +++ b/packages/tui/sdk/config.go @@ -397,14 +397,18 @@ func (r configProviderModelsLimitJSON) RawJSON() string { type Keybinds struct { // Exit the application AppExit string `json:"app_exit"` + // Show help dialog + AppHelp string `json:"app_help"` // 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"` + // Close file + FileClose string `json:"file_close"` + // Toggle split/unified diff + FileDiffToggle string `json:"file_diff_toggle"` + // List files + FileList string `json:"file_list"` + // Search file + FileSearch string `json:"file_search"` // Clear input field InputClear string `json:"input_clear"` // Insert newline in input @@ -415,6 +419,8 @@ type Keybinds struct { InputSubmit string `json:"input_submit"` // Leader key for keybind combinations Leader string `json:"leader"` + // Copy message + MessagesCopy string `json:"messages_copy"` // Navigate to first message MessagesFirst string `json:"messages_first"` // Scroll messages down by half page @@ -423,6 +429,8 @@ type Keybinds struct { MessagesHalfPageUp string `json:"messages_half_page_up"` // Navigate to last message MessagesLast string `json:"messages_last"` + // Toggle layout + MessagesLayoutToggle string `json:"messages_layout_toggle"` // Navigate to next message MessagesNext string `json:"messages_next"` // Scroll messages down by one page @@ -431,6 +439,8 @@ type Keybinds struct { MessagesPageUp string `json:"messages_page_up"` // Navigate to previous message MessagesPrevious string `json:"messages_previous"` + // Revert message + MessagesRevert string `json:"messages_revert"` // List available models ModelList string `json:"model_list"` // Initialize project configuration @@ -445,6 +455,8 @@ type Keybinds struct { SessionNew string `json:"session_new"` // Share current session SessionShare string `json:"session_share"` + // Unshare current session + SessionUnshare string `json:"session_unshare"` // List available themes ThemeList string `json:"theme_list"` // Show tool details @@ -455,23 +467,28 @@ type Keybinds struct { // keybindsJSON contains the JSON metadata for the struct [Keybinds] type keybindsJSON struct { AppExit apijson.Field + AppHelp apijson.Field EditorOpen apijson.Field - Help apijson.Field - HistoryNext apijson.Field - HistoryPrevious apijson.Field + FileClose apijson.Field + FileDiffToggle apijson.Field + FileList apijson.Field + FileSearch apijson.Field InputClear apijson.Field InputNewline apijson.Field InputPaste apijson.Field InputSubmit apijson.Field Leader apijson.Field + MessagesCopy apijson.Field MessagesFirst apijson.Field MessagesHalfPageDown apijson.Field MessagesHalfPageUp apijson.Field MessagesLast apijson.Field + MessagesLayoutToggle apijson.Field MessagesNext apijson.Field MessagesPageDown apijson.Field MessagesPageUp apijson.Field MessagesPrevious apijson.Field + MessagesRevert apijson.Field ModelList apijson.Field ProjectInit apijson.Field SessionCompact apijson.Field @@ -479,6 +496,7 @@ type keybindsJSON struct { SessionList apijson.Field SessionNew apijson.Field SessionShare apijson.Field + SessionUnshare apijson.Field ThemeList apijson.Field ToolDetails apijson.Field raw string diff --git a/packages/tui/sdk/scripts/lint b/packages/tui/sdk/scripts/lint index fa7ba1f6..c10fa02f 100755 --- a/packages/tui/sdk/scripts/lint +++ b/packages/tui/sdk/scripts/lint @@ -5,4 +5,7 @@ set -e cd "$(dirname "$0")/.." echo "==> Running Go build" -go build ./... +go build . + +# Compile the tests but don't run them +go test -c . From 0da83ae67eac6fa3703a40c81b11c49cb6a30ca0 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Tue, 8 Jul 2025 08:20:55 -0500 Subject: [PATCH 49/52] feat(tui): command aliases --- packages/tui/internal/commands/command.go | 43 +++++++++++++------ packages/tui/internal/completions/commands.go | 21 +++++---- .../internal/components/commands/commands.go | 8 ++-- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index 10b0d7e2..791f7475 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -29,7 +29,7 @@ type Command struct { Name CommandName Description string Keybindings []Keybinding - Trigger string + Trigger []string } func (c Command) Keys() []string { @@ -40,6 +40,21 @@ func (c Command) Keys() []string { return keys } +func (c Command) HasTrigger() bool { + return len(c.Trigger) > 0 +} + +func (c Command) PrimaryTrigger() string { + if len(c.Trigger) > 0 { + return c.Trigger[0] + } + return "" +} + +func (c Command) MatchesTrigger(trigger string) bool { + return slices.Contains(c.Trigger, trigger) +} + type CommandRegistry map[CommandName]Command func (r CommandRegistry) Sorted() []Command { @@ -135,37 +150,37 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Name: AppHelpCommand, Description: "show help", Keybindings: parseBindings("h"), - Trigger: "help", + Trigger: []string{"help"}, }, { Name: EditorOpenCommand, Description: "open editor", Keybindings: parseBindings("e"), - Trigger: "editor", + Trigger: []string{"editor"}, }, { Name: SessionNewCommand, Description: "new session", Keybindings: parseBindings("n"), - Trigger: "new", + Trigger: []string{"new", "clear"}, }, { Name: SessionListCommand, Description: "list sessions", Keybindings: parseBindings("l"), - Trigger: "sessions", + Trigger: []string{"sessions", "resume", "continue"}, }, { Name: SessionShareCommand, Description: "share session", Keybindings: parseBindings("s"), - Trigger: "share", + Trigger: []string{"share"}, }, { Name: SessionUnshareCommand, Description: "unshare session", Keybindings: parseBindings("u"), - Trigger: "unshare", + Trigger: []string{"unshare"}, }, { Name: SessionInterruptCommand, @@ -176,31 +191,31 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Name: SessionCompactCommand, Description: "compact the session", Keybindings: parseBindings("c"), - Trigger: "compact", + Trigger: []string{"compact", "summarize"}, }, { Name: ToolDetailsCommand, Description: "toggle tool details", Keybindings: parseBindings("d"), - Trigger: "details", + Trigger: []string{"details"}, }, { Name: ModelListCommand, Description: "list models", Keybindings: parseBindings("m"), - Trigger: "models", + Trigger: []string{"models"}, }, { Name: ThemeListCommand, Description: "list themes", Keybindings: parseBindings("t"), - Trigger: "themes", + Trigger: []string{"themes"}, }, { Name: FileListCommand, Description: "list files", Keybindings: parseBindings("f"), - Trigger: "files", + Trigger: []string{"files"}, }, { Name: FileCloseCommand, @@ -221,7 +236,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Name: ProjectInitCommand, Description: "create/update AGENTS.md", Keybindings: parseBindings("i"), - Trigger: "init", + Trigger: []string{"init"}, }, { Name: InputClearCommand, @@ -302,7 +317,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Name: AppExitCommand, Description: "exit the app", Keybindings: parseBindings("ctrl+c", "q"), - Trigger: "exit", + Trigger: []string{"exit", "quit"}, }, } registry := make(CommandRegistry) diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go index c73923e8..3a5dc3bb 100644 --- a/packages/tui/internal/completions/commands.go +++ b/packages/tui/internal/completions/commands.go @@ -31,7 +31,7 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string { func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI { spacer := strings.Repeat(" ", space) - title := " /" + cmd.Trigger + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description) + title := " /" + cmd.PrimaryTrigger() + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description) value := string(cmd.Name) return dialog.NewCompletionItem(dialog.CompletionItem{ Title: title, @@ -45,8 +45,8 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp space := 1 for _, cmd := range c.app.Commands { - if lipgloss.Width(cmd.Trigger) > space { - space = lipgloss.Width(cmd.Trigger) + if cmd.HasTrigger() && lipgloss.Width(cmd.PrimaryTrigger()) > space { + space = lipgloss.Width(cmd.PrimaryTrigger()) } } space += 2 @@ -56,10 +56,10 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp // If no query, return all commands items := []dialog.CompletionItemI{} for _, cmd := range sorted { - if cmd.Trigger == "" { + if !cmd.HasTrigger() { continue } - space := space - lipgloss.Width(cmd.Trigger) + space := space - lipgloss.Width(cmd.PrimaryTrigger()) items = append(items, getCommandCompletionItem(cmd, space, t)) } return items, nil @@ -70,12 +70,15 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp commandMap := make(map[string]dialog.CompletionItemI) for _, cmd := range sorted { - if cmd.Trigger == "" { + if !cmd.HasTrigger() { continue } - space := space - lipgloss.Width(cmd.Trigger) - commandNames = append(commandNames, cmd.Trigger) - commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, space, t) + space := space - lipgloss.Width(cmd.PrimaryTrigger()) + // Add all triggers as searchable options + for _, trigger := range cmd.Trigger { + commandNames = append(commandNames, trigger) + commandMap[trigger] = getCommandCompletionItem(cmd, space, t) + } } // Find fuzzy matches diff --git a/packages/tui/internal/components/commands/commands.go b/packages/tui/internal/components/commands/commands.go index f3080b38..7f293230 100644 --- a/packages/tui/internal/components/commands/commands.go +++ b/packages/tui/internal/components/commands/commands.go @@ -56,8 +56,8 @@ func (c *commandsComponent) View() string { var untriggeredCommands []commands.Command for _, cmd := range c.app.Commands.Sorted() { - if c.showAll || cmd.Trigger != "" { - if cmd.Trigger != "" { + if c.showAll || cmd.HasTrigger() { + if cmd.HasTrigger() { triggeredCommands = append(triggeredCommands, cmd) } else if c.showAll { untriggeredCommands = append(untriggeredCommands, cmd) @@ -97,8 +97,8 @@ func (c *commandsComponent) View() string { for _, cmd := range commandsToShow { trigger := "" - if cmd.Trigger != "" { - trigger = "/" + cmd.Trigger + if cmd.HasTrigger() { + trigger = "/" + cmd.PrimaryTrigger() } else { trigger = string(cmd.Name) } From a57ce8365dceae79d386379447c683906695e36a Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 8 Jul 2025 10:30:02 -0400 Subject: [PATCH 50/52] Update STATS.md --- STATS.md | 1 - 1 file changed, 1 deletion(-) diff --git a/STATS.md b/STATS.md index 3414a4e7..e1e0d5e5 100644 --- a/STATS.md +++ b/STATS.md @@ -11,4 +11,3 @@ | 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) | | 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) | | 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) | -| 2025-07-08 | 38,170 (+118) | 64,468 (+0) | 102,638 (+118) | From 562bdb95e2e2503a0b1ff73a3fa565d705a8e412 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:02:06 -0500 Subject: [PATCH 51/52] fix: include symlinks in ripgrep searches --- packages/opencode/src/file/ripgrep.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index a784a251..379dc4ad 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -187,7 +187,7 @@ export namespace Ripgrep { export async function files(input: { cwd: string; query?: string; glob?: string; limit?: number }) { const commands = [ - `${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`, + `${await filepath()} --files --follow --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`, ] if (input.query) commands.push(`${await Fzf.filepath()} --filter=${input.query}`) if (input.limit) commands.push(`head -n ${input.limit}`) From 8322f18e0339fa015346c6700b4e71d2b193b402 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:38:11 -0500 Subject: [PATCH 52/52] fix: display errors when using `opencode run ...` (#751) --- packages/opencode/src/cli/cmd/run.ts | 15 +++++++++++++++ packages/opencode/src/session/index.ts | 2 ++ 2 files changed, 17 insertions(+) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 453b273d..218e1ea8 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -125,6 +125,20 @@ export const RunCommand = cmd({ } }) + let errorMsg: string | undefined + Bus.subscribe(Session.Event.Error, async (evt) => { + const { sessionID, error } = evt.properties + if (sessionID !== session.id || !error) return + let err = String(error.name) + + if ("data" in error && error.data && "message" in error.data) { + err = error.data.message + } + errorMsg = errorMsg ? errorMsg + "\n" + err : err + + UI.error(err) + }) + const result = await Session.chat({ sessionID: session.id, providerID, @@ -140,6 +154,7 @@ export const RunCommand = cmd({ if (isPiped) { const match = result.parts.findLast((x) => x.type === "text") if (match) process.stdout.write(match.text) + if (errorMsg) process.stdout.write(errorMsg) } UI.empty() }) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 614e7e5a..41454307 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -99,6 +99,7 @@ export namespace Session { Error: Bus.event( "session.error", z.object({ + sessionID: z.string().optional(), error: MessageV2.Assistant.shape.error, }), ), @@ -727,6 +728,7 @@ export namespace Session { next.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }) } Bus.publish(Event.Error, { + sessionID: next.sessionID, error: next.error, }) }