Compare commits

...

35 commits

Author SHA1 Message Date
Dominik Engelhardt
d87922c0eb
Fix Elixir LSP startup (#726)
Some checks are pending
deploy / deploy (push) Waiting to run
publish / publish (push) Waiting to run
2025-07-06 23:37:46 -04:00
Liang-Shih Lin
2446483df5
fix: Skip opencode upgrade if same version (#720) 2025-07-06 23:36:59 -04:00
GitHub Action
f4c453155d Update download stats 2025-07-06
Some checks are pending
deploy / deploy (push) Waiting to run
publish / publish (push) Waiting to run
2025-07-06 12:03:56 +00:00
Dax Raad
969ad80ed2 fix openrouter caching with anthropic, should be a lot cheaper
Some checks are pending
deploy / deploy (push) Waiting to run
publish / publish (push) Waiting to run
2025-07-05 11:39:54 -04:00
GitHub Action
af064b41d7 Update download stats 2025-07-05
Some checks are pending
deploy / deploy (push) Waiting to run
publish / publish (push) Waiting to run
2025-07-05 12:03:56 +00:00
Dax Raad
ea6bfef21a use full filepath
Some checks are pending
deploy / deploy (push) Waiting to run
publish / publish (push) Waiting to run
2025-07-04 17:58:03 -04:00
Jay V
107363b1d9 docs: fix show more in share page
Some checks failed
deploy / deploy (push) Has been cancelled
2025-07-04 17:57:12 -04:00
Dax Raad
85214d7c59 fix input bar not rendering capital letters 2025-07-04 17:21:51 -04:00
Timo Clasen
997cb2d945
fix(tui): optimistic rendering (#692) 2025-07-04 16:06:57 -05:00
Dax Raad
45b139390c make file attachments work good like 2025-07-04 16:21:26 -04:00
Jay V
994368de15 docs: share fix scrolling again 2025-07-04 13:53:25 -04:00
Jay V
143fd8e076 docs: share improve markdown rendering of ai responses 2025-07-04 13:53:25 -04:00
Dax Raad
06dba28bd6 wip: fix media type 2025-07-04 12:50:52 -04:00
adamdottv
b8d276a049
fix(tui): full paths for attachments 2025-07-04 11:42:22 -05:00
Dax Raad
ee01f01271 file attachments 2025-07-04 12:24:01 -04:00
adamdottv
32d5db4f0a
fix(tui): markdown wrapping off sometimes 2025-07-04 11:16:38 -05:00
adamdottv
f6108b7be8
fix(tui): handle pdf and image @ files 2025-07-04 11:13:09 -05:00
adamdottv
94ef341c9d
feat(tui): render attachments 2025-07-04 10:55:02 -05:00
adamdottv
f9abc7c84f
feat(tui): file attachments 2025-07-04 10:55:02 -05:00
adamdottv
891ed6ebc0
fix(tui): slower startup due to file.status 2025-07-04 10:55:01 -05:00
Dax Raad
163e23a68b removed banned command concept 2025-07-04 11:32:12 -04:00
Vladimir
f13b0af491
docs: Fix invalid json in the mcp example config (#645) 2025-07-04 11:24:13 -04:00
Aiden Cline
4a0be45d3d
chore: document instructions configuration option (#670) 2025-07-04 11:22:45 -04:00
Dax Raad
23788674c8 disable snapshots temporarily
Some checks are pending
deploy / deploy (push) Waiting to run
publish / publish (push) Waiting to run
2025-07-04 08:45:18 -04:00
GitHub Action
121eb24e73 Update download stats 2025-07-04 2025-07-04 12:26:16 +00:00
Dax Raad
571d60182a improve snapshotting speed further
Some checks are pending
deploy / deploy (push) Waiting to run
publish / publish (push) Waiting to run
2025-07-03 21:36:09 -04:00
Jay V
167a9dcaf3 docs: share fix scroll to anchor
Some checks are pending
deploy / deploy (push) Waiting to run
2025-07-03 20:30:21 -04:00
Dax Raad
37327259cb ci: ignore 2025-07-03 20:30:02 -04:00
Dax Raad
cdb25656d5 improve snapshot speed 2025-07-03 20:16:25 -04:00
Jay V
25c876caa2 docs: share fix last message not expandable 2025-07-03 19:33:55 -04:00
Dax Raad
cf83e31f23 add elixir lsp support 2025-07-03 19:29:51 -04:00
Dax Raad
3bc238b58b wip: logs 2025-07-03 19:29:51 -04:00
Jay V
b8de69dced docs: fix share page scroll performance 2025-07-03 19:15:38 -04:00
Jay V
e7fcb692a4 docs: tweak page title
Some checks are pending
deploy / deploy (push) Waiting to run
publish / publish (push) Waiting to run
2025-07-03 16:23:08 -04:00
Timo Clasen
dae38574ab
chore: add dev script (#666) 2025-07-03 14:43:25 -05:00
41 changed files with 1418 additions and 712 deletions

View file

@ -7,3 +7,6 @@
| 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) |

View file

@ -5,7 +5,7 @@
"name": "opencode",
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.6",
"sst": "3.17.8",
},
},
"packages/function": {
@ -78,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",
@ -462,7 +463,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
@ -492,6 +493,8 @@
"@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="],
"@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="],
@ -602,7 +605,7 @@
"buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
"bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@ -1050,6 +1053,8 @@
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"marked-shiki": ["marked-shiki@1.2.0", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-N924hp8veE6Mc91g5/kCNVoTU7TkeJfB2G2XEWb+k1fVA0Bck2T0rVt93d39BlOYH6ohP4Q9BFlPk+UkblhXbg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="],
@ -1476,23 +1481,23 @@
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
"sst": ["sst@3.17.6", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.6", "sst-darwin-x64": "3.17.6", "sst-linux-arm64": "3.17.6", "sst-linux-x64": "3.17.6", "sst-linux-x86": "3.17.6", "sst-win32-arm64": "3.17.6", "sst-win32-x64": "3.17.6", "sst-win32-x86": "3.17.6" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-p+AcqwfYQUdkxeRjCikQoTMviPCBiGoU7M0vcV6GDVmVis8hzhVw4EFfHTafZC+aWfy1Ke2UQi66vZlEVWuEqA=="],
"sst": ["sst@3.17.8", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.8", "sst-darwin-x64": "3.17.8", "sst-linux-arm64": "3.17.8", "sst-linux-x64": "3.17.8", "sst-linux-x86": "3.17.8", "sst-win32-arm64": "3.17.8", "sst-win32-x64": "3.17.8", "sst-win32-x86": "3.17.8" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-P/a9/ZsjtQRrTBerBMO1ODaVa5HVTmNLrQNJiYvu2Bgd0ov+vefQeHv6oima8HLlPwpDIPS2gxJk8BZrTZMfCA=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6tb7KlcPR7PTi3ofQv8dX/n6Jf7pNP9VfrnYL4HBWnWrcYaZeJ5MWobILfIJ/y2jHgoqmg9e5C3266Eds0JQyw=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-50P6YRMnZVItZUfB0+NzqMww2mmm4vB3zhTVtWUtGoXeiw78g1AEnVlmS28gYXPHM1P987jTvR7EON9u9ig/Dg=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-lFakq6/EgTuBSjbl8Kry4pfgAPEIyn6o7ZkyRz3hz5331wUaX88yfjs3tL9JQ8Ey6jBUYxwhP/Q1n7fzIG046g=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-P0pnMHCmpkpcsxkWpilmeoD79LkbkoIcv6H0aeM9ArT/71/JBhvqH+HjMHSJCzni/9uR6er+nH5F+qol0UO6Bw=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-SdTxXMbTEdiwOqp37w31kXv97vHqSx3oK9h/76lKg7V9k5JxPJ6JMefPLhoKWwK0Zh6AndY2zo2oRoEv4SIaDw=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-vun54YA/UzprCu9p8BC4rMwFU5Cj9xrHAHYLYUp/yq4H0pfmBIiQM62nsfIKizRThe/TkBFy60EEi9myf6raYA=="],
"sst-linux-x64": ["sst-linux-x64@3.17.6", "", { "os": "linux", "cpu": "x64" }, "sha512-qneh7uWDiTUYx8X1Y3h2YVw3SJ0ybBBlRrVybIvCM09JqQ8+qq/XjKXGzA/3/EF0Jr7Ug8cARSn9CwxhdQGN7Q=="],
"sst-linux-x64": ["sst-linux-x64@3.17.8", "", { "os": "linux", "cpu": "x64" }, "sha512-HqByCaLE2gEJbM20P1QRd+GqDMAiieuU53FaZA1F+AGxQi+kR82NWjrPqFcMj4dMYg8w/TWXuV+G5+PwoUmpDw=="],
"sst-linux-x86": ["sst-linux-x86@3.17.6", "", { "os": "linux", "cpu": "none" }, "sha512-pU3D5OeqnmfxGqN31DxuwWnc1OayxhkErnITHhZ39D0MTiwbIgCapH26FuLW8B08/uxJWG8djUlOboCRhSBvWA=="],
"sst-linux-x86": ["sst-linux-x86@3.17.8", "", { "os": "linux", "cpu": "none" }, "sha512-bCd6QM3MejfSmdvg8I/k+aUJQIZEQJg023qmN78fv00vwlAtfECvY7tjT9E2m3LDp33pXrcRYbFOQzPu+tWFfA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rr3RTYWAsH9sM9CbM/sAZCk7dB1OsSAljjJuuHMvdSAYW3RDpXEza0PBJGxnBID2eOrpswEchzMPL2d8LtL7oA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-pilx0n8gm4aHJae/vNiqIwZkWF3tdwWzD/ON7hkytw+CVSZ0FXtyFW/yO/+2u3Yw0Kj0lSWPnUqYgm/eHPLwQA=="],
"sst-win32-x64": ["sst-win32-x64@3.17.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yZ3roxwI0Wve9PFzdrrF1kfzCmIMFCCoa8qKeXY7LxCJ4QQIqHbCOccLK1Wv/MIU/mcZHWXTQVCLHw77uaa0GQ=="],
"sst-win32-x64": ["sst-win32-x64@3.17.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Jb0FVRyiOtESudF1V8ucW65PuHrx/iOHUamIO0JnbujWNHZBTRPB2QHN1dbewgkueYDaCmyS8lvuIImLwYJnzQ=="],
"sst-win32-x86": ["sst-win32-x86@3.17.6", "", { "os": "win32", "cpu": "none" }, "sha512-zV7TJWPJN9PmIXr15iXFSs0tbGsa52oBR3+xiKrUj2qj9XsZe7HBFwskRnHyiFq0durZY9kk9ZtoVlpuUuzr1g=="],
"sst-win32-x86": ["sst-win32-x86@3.17.8", "", { "os": "win32", "cpu": "none" }, "sha512-oVmFa/PoElQmfnGJlB0w6rPXiYuldiagO6AbrLMT/6oAnWerLQ8Uhv9tJWfMh3xtPLImQLTjxDo1v0AIzEv9QA=="],
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],

View file

@ -5,6 +5,7 @@
"type": "module",
"packageManager": "bun@1.2.14",
"scripts": {
"dev": "bun run packages/opencode/src/index.ts",
"typecheck": "bun run --filter='*' typecheck",
"stainless": "bun run ./packages/opencode/src/index.ts serve ",
"postinstall": "./scripts/hooks"
@ -22,7 +23,7 @@
},
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.6"
"sst": "3.17.8"
},
"repository": {
"type": "git",

View file

@ -1,4 +1,3 @@
node_modules
research
dist
gen

View file

@ -9,6 +9,7 @@ import fs from "fs/promises"
import { Installation } from "../../installation"
import { Config } from "../../config/config"
import { Bus } from "../../bus"
import { Log } from "../../util/log"
export const TuiCommand = cmd({
command: "$0 [project]",
@ -57,6 +58,9 @@ export const TuiCommand = cmd({
cwd = process.cwd()
cmd = [binary]
}
Log.Default.info("tui", {
cmd,
})
const proc = Bun.spawn({
cmd: [...cmd, ...process.argv.slice(2)],
cwd,

View file

@ -35,6 +35,15 @@ export const UpgradeCommand = {
}
prompts.log.info("Using method: " + method)
const target = args.target ?? (await Installation.latest())
if (Installation.VERSION === target) {
prompts.log.warn(
`opencode upgrade skipped: ${target} is already installed`,
)
prompts.outro("Done")
return
}
prompts.log.info(`From ${Installation.VERSION}${target}`)
const spinner = prompts.spinner()
spinner.start("Upgrading...")

View file

@ -1,6 +1,8 @@
import { App } from "../app/app"
import { Log } from "../util/log"
export namespace FileTime {
const log = Log.create({ service: "file.time" })
export const state = App.state("tool.filetimes", () => {
const read: {
[sessionID: string]: {
@ -13,6 +15,7 @@ export namespace FileTime {
})
export function read(sessionID: string, file: string) {
log.info("read", { sessionID, file })
const { read } = state()
read[sessionID] = read[sessionID] || {}
read[sessionID][file] = new Date()

View file

@ -66,6 +66,7 @@ export namespace LSPClient {
log.info("sending initialize", { id: serverID })
await withTimeout(
connection.sendRequest("initialize", {
rootUri: "file://" + app.path.cwd,
processId: server.process.pid,
workspaceFolders: [
{
@ -92,11 +93,20 @@ export namespace LSPClient {
},
}),
5_000,
).catch(() => {
throw new InitializeError({ serverID })
).catch((err) => {
log.error("initialize error", { error: err })
throw new InitializeError(
{ serverID },
{
cause: err,
},
)
})
await connection.sendNotification("initialized", {})
log.info("initialized")
log.info("initialized", {
serverID,
})
const files: {
[path: string]: number
@ -174,7 +184,6 @@ export namespace LSPClient {
log.info("shutting down", { serverID })
connection.end()
connection.dispose()
server.process.kill("SIGTERM")
log.info("shutdown", { serverID })
},
}

View file

@ -47,7 +47,7 @@ export namespace LSP {
const handle = await server.spawn(App.info())
if (!handle) break
const client = await LSPClient.create(server.id, handle).catch(
() => {},
(err) => log.error("", { error: err }),
)
if (!client) break
clients.set(server.id, client)

View file

@ -4,6 +4,8 @@ import path from "path"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $ } from "bun"
import fs from "fs/promises"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@ -144,4 +146,60 @@ export namespace LSPServer {
}
},
}
export const ElixirLS: Info = {
id: "elixir-ls",
extensions: [".ex", ".exs"],
async spawn() {
let binary = Bun.which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
binary = path.join(
Global.Path.bin,
"elixir-ls-master",
"release",
process.platform === "win32"
? "language_server.bar"
: "language_server.sh",
)
if (!(await Bun.file(binary).exists())) {
const elixir = Bun.which("elixir")
if (!elixir) {
log.error("elixir is required to run elixir-ls")
return
}
log.info("downloading elixir-ls from GitHub releases")
const response = await fetch(
"https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip",
)
if (!response.ok) return
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
await Bun.file(zipPath).write(response)
await $`unzip -o -q ${zipPath}`.cwd(Global.Path.bin).nothrow()
await fs.rm(zipPath, {
force: true,
recursive: true,
})
await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release`
.quiet()
.cwd(path.join(Global.Path.bin, "elixir-ls-master"))
.env({ MIX_ENV: "prod", ...process.env })
log.info(`installed elixir-ls`, {
path: elixirLsPath,
})
}
}
return {
process: spawn(binary),
}
},
}
}

View file

@ -17,6 +17,9 @@ export namespace ProviderTransform {
anthropic: {
cacheControl: { type: "ephemeral" },
},
openaiCompatible: {
cache_control: { type: "ephemeral" },
},
}
}
}

View file

@ -1,40 +1,42 @@
import path from "path"
import { App } from "../app/app"
import { Identifier } from "../id/id"
import { Storage } from "../storage/storage"
import { Log } from "../util/log"
import path from "node:path"
import { Decimal } from "decimal.js"
import { z, ZodSchema } from "zod"
import {
generateText,
LoadAPIKeyError,
convertToCoreMessages,
streamText,
tool,
wrapLanguageModel,
type Tool as AITool,
type LanguageModelUsage,
type CoreMessage,
type UIMessage,
type ProviderMetadata,
wrapLanguageModel,
type Attachment,
} from "ai"
import { z, ZodSchema } from "zod"
import { Decimal } from "decimal.js"
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
import { Share } from "../share/share"
import { Message } from "./message"
import { App } from "../app/app"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { MCP } from "../mcp"
import { NamedError } from "../util/error"
import type { Tool } from "../tool/tool"
import { SystemPrompt } from "./system"
import { Flag } from "../flag/flag"
import type { ModelsDev } from "../provider/models"
import { Installation } from "../installation"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
import { MCP } from "../mcp"
import { Provider } from "../provider/provider"
import { ProviderTransform } from "../provider/transform"
import type { ModelsDev } from "../provider/models"
import { Share } from "../share/share"
import { Snapshot } from "../snapshot"
import { Storage } from "../storage/storage"
import type { Tool } from "../tool/tool"
import { Log } from "../util/log"
import { NamedError } from "../util/error"
import { Message } from "./message"
import { SystemPrompt } from "./system"
import { FileTime } from "../file/time"
export namespace Session {
const log = Log.create({ service: "session" })
@ -187,7 +189,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 +362,60 @@ 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): Promise<Message.MessagePart[]> => {
if (part.type === "file") {
const url = new URL(part.url)
switch (url.protocol) {
case "file:":
const filepath = path.join(app.path.cwd, url.pathname)
let file = Bun.file(filepath)
if (part.mediaType === "text/plain") {
let text = await file.text()
const range = {
start: url.searchParams.get("start"),
end: url.searchParams.get("end"),
}
if (range.start != null && part.mediaType === "text/plain") {
const lines = text.split("\n")
const start = parseInt(range.start)
const end = range.end ? parseInt(range.end) : lines.length
text = lines.slice(start, end).join("\n")
}
FileTime.read(input.sessionID, filepath)
return [
{
type: "text",
text: [
"Called the Read tool on " + url.pathname,
"<results>",
text,
"</results>",
].join("\n"),
},
]
}
return [
{
type: "text",
text: ["Called the Read tool on " + url.pathname].join("\n"),
},
{
type: "file",
url:
`data:${part.mediaType};base64,` +
Buffer.from(await file.bytes()).toString("base64url"),
mediaType: part.mediaType,
filename: part.filename!,
},
]
}
}
return [part]
}),
).then((x) => x.flat())
if (msgs.length === 0 && !session.parentID) {
generateText({
maxTokens: input.providerID === "google" ? 1024 : 20,
@ -376,7 +431,7 @@ export namespace Session {
{
role: "user",
content: "",
parts: toParts(input.parts),
parts: toParts(input.parts).parts,
},
]),
],
@ -1028,7 +1083,7 @@ function toUIMessage(msg: Message.Info): UIMessage {
id: msg.id,
role: "assistant",
content: "",
parts: toParts(msg.parts),
...toParts(msg.parts),
}
}
@ -1037,35 +1092,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

View file

@ -1,14 +1,7 @@
import { App } from "../app/app"
import {
add,
commit,
init,
checkout,
statusMatrix,
remove,
} from "isomorphic-git"
import { $ } from "bun"
import path from "path"
import fs from "fs"
import fs from "fs/promises"
import { Ripgrep } from "../file/ripgrep"
import { Log } from "../util/log"
@ -16,66 +9,55 @@ export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
export async function create(sessionID: string) {
return
log.info("creating snapshot")
const app = App.info()
const git = gitdir(sessionID)
const files = await Ripgrep.files({
cwd: app.path.cwd,
limit: app.git ? undefined : 1000,
})
// not a git repo and too big to snapshot
if (!app.git && files.length === 1000) return
await init({
dir: app.path.cwd,
gitdir: git,
fs,
})
const status = await statusMatrix({
fs,
gitdir: git,
dir: app.path.cwd,
})
await add({
fs,
gitdir: git,
parallel: true,
dir: app.path.cwd,
filepath: files,
})
for (const [file, _head, workdir, stage] of status) {
if (workdir === 0 && stage === 1) {
log.info("remove", { file })
await remove({
fs,
gitdir: git,
dir: app.path.cwd,
filepath: file,
})
}
// not a git repo, check if too big to snapshot
if (!app.git) {
const files = await Ripgrep.files({
cwd: app.path.cwd,
limit: 1000,
})
log.info("found files", { count: files.length })
if (files.length > 1000) return
}
const result = await commit({
fs,
gitdir: git,
dir: app.path.cwd,
message: "snapshot",
author: {
name: "opencode",
email: "mail@opencode.ai",
},
})
log.info("commit", { result })
return result
if (await fs.mkdir(git, { recursive: true })) {
await $`git init`
.env({
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: app.path.root,
})
.quiet()
.nothrow()
log.info("initialized")
}
await $`git --git-dir ${git} add .`.quiet().cwd(app.path.cwd).nothrow()
log.info("added files")
const result =
await $`git --git-dir ${git} commit --allow-empty -m "snapshot" --author="opencode <mail@opencode.ai>"`
.quiet()
.cwd(app.path.cwd)
.nothrow()
log.info("commit")
const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/)
if (!match) return
return match![1]
}
export async function restore(sessionID: string, commit: string) {
log.info("restore", { commit })
const app = App.info()
await checkout({
fs,
gitdir: gitdir(sessionID),
dir: app.path.cwd,
ref: commit,
force: true,
})
const git = gitdir(sessionID)
await $`git --git-dir=${git} checkout ${commit} --force`
.quiet()
.cwd(app.path.root)
}
function gitdir(sessionID: string) {

View file

@ -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],

View file

@ -489,10 +489,10 @@ export function replace(
BlockAnchorReplacer,
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
EscapeNormalizedReplacer,
TrimmedBoundaryReplacer,
ContextAwareReplacer,
MultiOccurrenceReplacer,
// EscapeNormalizedReplacer,
// TrimmedBoundaryReplacer,
// ContextAwareReplacer,
// MultiOccurrenceReplacer,
]) {
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)

View file

@ -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

View file

@ -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=

View file

@ -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 {

View file

@ -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
}

View file

@ -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,54 @@ 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
extension := filepath.Ext(filePath)
mediaType := ""
switch extension {
case ".jpg":
mediaType = "image/jpeg"
case ".png", ".jpeg", ".gif", ".webp":
mediaType = "image/" + extension[1:]
case ".pdf":
mediaType = "application/pdf"
default:
mediaType = "text/plain"
}
attachment := &textarea.Attachment{
ID: uuid.NewString(),
Display: "@" + filePath,
URL: fmt.Sprintf("file://./%s", filePath),
Filename: filePath,
MediaType: mediaType,
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
default:
existingValue := m.textarea.Value()
lastSpaceIndex := strings.LastIndex(existingValue, " ")
if lastSpaceIndex == -1 {
m.textarea.SetValue(msg.CompletionValue + " ")
@ -128,7 +167,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")
}
@ -190,19 +237,29 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
}
if len(value) > 0 && value[len(value)-1] == '\\' {
// If the last character is a backslash, remove it and add a newline
m.textarea.SetValue(value[:len(value)-1] + "\n")
m.textarea.ReplaceRange(len(value)-1, len(value), "")
m.textarea.InsertString("\n")
return m, nil
}
var cmds []tea.Cmd
attachments := m.textarea.GetAttachments()
fileParts := make([]opencode.FilePartParam, 0)
for _, attachment := range attachments {
fileParts = append(fileParts, opencode.FilePartParam{
Type: opencode.F(opencode.FilePartTypeFile),
MediaType: opencode.F(attachment.MediaType),
URL: opencode.F(attachment.URL),
Filename: opencode.F(attachment.Filename),
})
}
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
attachments := m.attachments
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 +269,23 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
}
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
imageBytes, text, err := image.GetImageFromClipboard()
_, text, err := image.GetImageFromClipboard()
if err != nil {
slog.Error(err.Error())
return m, nil
}
if len(imageBytes) != 0 {
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
m.attachments = append(m.attachments, attachment)
} else {
m.textarea.SetValue(m.textarea.Value() + text)
}
// if len(imageBytes) != 0 {
// attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
// attachment := app.Attachment{
// FilePath: attachmentName,
// FileName: attachmentName,
// Content: imageBytes,
// MimeType: "image/png",
// }
// m.attachments = append(m.attachments, attachment)
// } else {
m.textarea.InsertString(text)
// }
return m, nil
}
@ -254,12 +316,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 = " "

View file

@ -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:

View file

@ -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"
@ -67,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
@ -133,10 +132,50 @@ func (m *messagesComponent) renderView(width int) {
switch message.Role {
case opencode.MessageRoleUser:
for _, part := range message.Parts {
userLoop:
for partIndex, part := range message.Parts {
switch part := part.AsUnion().(type) {
case opencode.TextPart:
key := m.cache.GenerateKey(message.ID, part.Text, 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"
mediaTypeStyle = mediaTypeStyle.Background(t.Accent())
case "application/pdf":
mediaType = "pdf"
mediaTypeStyle = mediaTypeStyle.Background(t.Primary())
}
flexItems = append(flexItems, layout.FlexItem{
View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename),
})
}
}
bgColor := t.BackgroundPanel()
files := layout.Render(
layout.FlexOptions{
Background: &bgColor,
Width: width - 6,
Direction: layout.Column,
},
flexItems...,
)
key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount, files)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
@ -147,6 +186,7 @@ func (m *messagesComponent) renderView(width int) {
m.showToolDetails,
m.partCount == m.selectedPart,
width,
files,
)
m.cache.Set(key, content)
}
@ -154,6 +194,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
}
}
@ -206,6 +248,7 @@ func (m *messagesComponent) renderView(width int) {
m.showToolDetails,
m.partCount == m.selectedPart,
width,
"",
toolCallParts...,
)
m.cache.Set(key, content)
@ -219,6 +262,7 @@ func (m *messagesComponent) renderView(width int) {
m.showToolDetails,
m.partCount == m.selectedPart,
width,
"",
toolCallParts...,
)
}

View file

@ -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(),
)

View file

@ -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()

View file

@ -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)
}

View file

@ -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)

View file

@ -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()),
)
}

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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),

View file

@ -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")

View file

@ -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",

View file

@ -36,6 +36,10 @@ if (isDocs) {
}
---
{ slug === "" && (
<title>{title} | AI coding agent built for the terminal</title>
)}
<Default {...Astro.props}><slot /></Default>
{ (isDocs || !slug.startsWith("s")) && (

View file

@ -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<HTMLDivElement> {
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 (
<div innerHTML={html()} class={styles["markdown-body"]} {...rest} />
const [html] = createResource(
() => local.markdown,
async (markdown) => {
return markedWithShiki.parse(markdown)
},
)
return <div innerHTML={html()} class={styles["markdown-body"]} {...rest} />
}
export default MarkdownView

View file

@ -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 (
@ -294,49 +332,21 @@ function ResultsButton(props: ResultsButtonProps) {
interface TextPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
text: string
expand?: boolean
invert?: boolean
highlight?: boolean
}
function TextPart(props: TextPartProps) {
const [local, rest] = splitProps(props, [
"text",
"expand",
"invert",
"highlight",
])
const [expanded, setExpanded] = createSignal(false)
const [overflowed, setOverflowed] = createSignal(false)
let preEl: HTMLPreElement | undefined
function checkOverflow() {
if (preEl && !local.expand) {
setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1)
}
}
onMount(() => {
checkOverflow()
window.addEventListener("resize", checkOverflow)
})
createEffect(() => {
local.text
setTimeout(checkOverflow, 0)
})
onCleanup(() => {
window.removeEventListener("resize", checkOverflow)
})
const [local, rest] = splitProps(props, ["text", "expand"])
const [expanded, setExpanded] = createSignal(false)
const overflowed = checkOverflow(() => preEl, () => local.expand)
return (
<div
class={styles["message-text"]}
data-invert={local.invert}
data-highlight={local.highlight}
data-expanded={expanded() || local.expand === true}
{...rest}
>
<pre ref={(el) => (preEl = el)}>{local.text}</pre>
<pre ref={preEl}>{local.text}</pre>
{((!local.expand && overflowed()) || expanded()) && (
<button
type="button"
@ -354,30 +364,11 @@ interface ErrorPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
expand?: boolean
}
function ErrorPart(props: ErrorPartProps) {
let preEl: HTMLDivElement | undefined
const [local, rest] = splitProps(props, ["expand", "children"])
const [expanded, setExpanded] = createSignal(false)
const [overflowed, setOverflowed] = createSignal(false)
let preEl: HTMLElement | undefined
function checkOverflow() {
if (preEl && !local.expand) {
setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1)
}
}
onMount(() => {
checkOverflow()
window.addEventListener("resize", checkOverflow)
})
createEffect(() => {
local.children
setTimeout(checkOverflow, 0)
})
onCleanup(() => {
window.removeEventListener("resize", checkOverflow)
})
const overflowed = checkOverflow(() => preEl, () => local.expand)
return (
<div
@ -385,7 +376,7 @@ function ErrorPart(props: ErrorPartProps) {
data-expanded={expanded() || local.expand === true}
{...rest}
>
<div data-section="content" ref={(el) => (preEl = el)}>
<div data-section="content" ref={preEl}>
{local.children}
</div>
{((!local.expand && overflowed()) || expanded()) && (
@ -407,30 +398,11 @@ interface MarkdownPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
highlight?: boolean
}
function MarkdownPart(props: MarkdownPartProps) {
const [local, rest] = splitProps(props, ["text", "expand", "highlight"])
const [expanded, setExpanded] = createSignal(false)
const [overflowed, setOverflowed] = createSignal(false)
let divEl: HTMLDivElement | undefined
function checkOverflow() {
if (divEl && !local.expand) {
setOverflowed(divEl.scrollHeight > divEl.clientHeight + 1)
}
}
onMount(() => {
checkOverflow()
window.addEventListener("resize", checkOverflow)
})
createEffect(() => {
local.text
setTimeout(checkOverflow, 0)
})
onCleanup(() => {
window.removeEventListener("resize", checkOverflow)
})
const [local, rest] = splitProps(props, ["text", "expand", "highlight"])
const [expanded, setExpanded] = createSignal(false)
const overflowed = checkOverflow(() => divEl, () => local.expand)
return (
<div
@ -472,28 +444,16 @@ function TerminalPart(props: TerminalPartProps) {
"desc",
"expand",
])
let preEl: HTMLDivElement | undefined
const [expanded, setExpanded] = createSignal(false)
const [overflowed, setOverflowed] = createSignal(false)
let preEl: HTMLElement | undefined
function checkOverflow() {
if (!preEl) return
const code = preEl.getElementsByTagName("code")[0]
if (code && !local.expand) {
setOverflowed(preEl.clientHeight < code.offsetHeight)
}
}
onMount(() => {
checkOverflow()
window.addEventListener("resize", checkOverflow)
})
onCleanup(() => {
window.removeEventListener("resize", checkOverflow)
})
const overflowed = checkOverflow(
() => {
if (!preEl) return
return preEl.getElementsByTagName("pre")[0]
},
() => local.expand
)
return (
<div
@ -510,16 +470,16 @@ function TerminalPart(props: TerminalPartProps) {
<Switch>
<Match when={local.error}>
<CodeBlock
data-section="error"
ref={preEl}
lang="text"
ref={(el) => (preEl = el)}
data-section="error"
code={local.error || ""}
/>
</Match>
<Match when={local.result}>
<CodeBlock
ref={preEl}
lang="console"
ref={(el) => (preEl = el)}
code={local.result || ""}
/>
</Match>
@ -596,7 +556,10 @@ export default function Share(props: {
messages: Record<string, Message.Info>
}) {
let lastScrollY = 0
let hasScrolledToAnchor = false
let scrollTimeout: number | undefined
let scrollSentinel: HTMLElement | undefined
let scrollObserver: IntersectionObserver | undefined
const id = props.id
const params = new URLSearchParams(window.location.search)
@ -604,6 +567,7 @@ export default function Share(props: {
const [showScrollButton, setShowScrollButton] = createSignal(false)
const [isButtonHovered, setIsButtonHovered] = createSignal(false)
const [isNearBottom, setIsNearBottom] = createSignal(false)
const [store, setStore] = createStore<{
info?: Session.Info
@ -713,10 +677,9 @@ export default function Share(props: {
const currentScrollY = window.scrollY
const isScrollingDown = currentScrollY > lastScrollY
const scrolled = currentScrollY > 200 // Show after scrolling 200px
const isNearBottom = window.innerHeight + currentScrollY >= document.body.scrollHeight - 100
// Only show when scrolling down, scrolled enough, and not near bottom
const shouldShow = isScrollingDown && scrolled && !isNearBottom
const shouldShow = isScrollingDown && scrolled && !isNearBottom()
// Update last scroll position
lastScrollY = currentScrollY
@ -732,7 +695,7 @@ export default function Share(props: {
if (!isButtonHovered()) {
setShowScrollButton(false)
}
}, 3000)
}, 1500)
} else if (!isButtonHovered()) {
// Only hide if not hovered (to prevent disappearing while user is about to click)
setShowScrollButton(false)
@ -744,6 +707,26 @@ export default function Share(props: {
onMount(() => {
lastScrollY = window.scrollY // Initialize scroll position
// Create sentinel element
const sentinel = document.createElement("div")
sentinel.style.height = "1px"
sentinel.style.position = "absolute"
sentinel.style.bottom = "100px"
sentinel.style.width = "100%"
sentinel.style.pointerEvents = "none"
document.body.appendChild(sentinel)
// Create intersection observer
const observer = new IntersectionObserver((entries) => {
setIsNearBottom(entries[0].isIntersecting)
})
observer.observe(sentinel)
// Store references for cleanup
scrollSentinel = sentinel
scrollObserver = observer
checkScrollNeed()
window.addEventListener("scroll", checkScrollNeed)
window.addEventListener("resize", checkScrollNeed)
@ -752,6 +735,15 @@ export default function Share(props: {
onCleanup(() => {
window.removeEventListener("scroll", checkScrollNeed)
window.removeEventListener("resize", checkScrollNeed)
// Clean up observer and sentinel
if (scrollObserver) {
scrollObserver.disconnect()
}
if (scrollSentinel) {
document.body.removeChild(scrollSentinel)
}
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
@ -855,7 +847,6 @@ export default function Share(props: {
</span>
)}
</div>
</div>
</div>
@ -865,7 +856,7 @@ export default function Share(props: {
fallback={<p>Waiting for messages...</p>}
>
<div class={styles.parts}>
<SuspenseList>
<SuspenseList revealOrder="forwards">
<For each={data().messages}>
{(msg, msgIndex) => (
<Suspense>
@ -880,8 +871,11 @@ export default function Share(props: {
)
return null
const anchor = createMemo(() => `${msg.id}-${partIndex()}`)
const [showResults, setShowResults] = createSignal(false)
const anchor = createMemo(
() => `${msg.id}-${partIndex()}`,
)
const [showResults, setShowResults] =
createSignal(false)
const isLastPart = createMemo(
() =>
data().messages.length === msgIndex() + 1 &&
@ -903,7 +897,9 @@ export default function Share(props: {
const duration = DateTime.fromMillis(
metadata?.time.end || 0,
)
.diff(DateTime.fromMillis(metadata?.time.start || 0))
.diff(
DateTime.fromMillis(metadata?.time.start || 0),
)
.toMillis()
return { metadata, args, result, duration }
@ -911,7 +907,14 @@ 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 !== ""
&& !hasScrolledToAnchor
&& msg.parts.length === partIndex() + 1
&& data().messages.length === msgIndex() + 1
) {
hasScrolledToAnchor = true
scrollToAnchor(hash)
}
})
@ -921,7 +924,9 @@ export default function Share(props: {
{/* User text */}
<Match
when={
msg.role === "user" && part.type === "text" && part
msg.role === "user" &&
part.type === "text" &&
part
}
>
{(part) => (
@ -938,9 +943,9 @@ export default function Share(props: {
</div>
<div data-section="content">
<TextPart
invert
text={part().text}
expand={isLastPart()}
data-background="blue"
/>
</div>
</div>
@ -968,11 +973,12 @@ export default function Share(props: {
</div>
<div data-section="content">
<MarkdownPart
highlight
expand={isLastPart()}
text={stripEnclosingTag(part().text)}
/>
<Show when={isLastPart() && data().completed}>
<Show
when={isLastPart() && data().completed}
>
<span
data-part-footer
title={DateTime.fromMillis(
@ -1041,7 +1047,8 @@ export default function Share(props: {
}
>
{(_part) => {
const matches = () => toolData()?.metadata?.matches
const matches = () =>
toolData()?.metadata?.matches
const splitArgs = () => {
const { pattern, ...rest } = toolData()?.args
return { pattern, rest }
@ -1066,11 +1073,14 @@ export default function Share(props: {
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>Grep</span>
<b>&ldquo;{splitArgs().pattern}&rdquo;</b>
<b>
&ldquo;{splitArgs().pattern}&rdquo;
</b>
</div>
<Show
when={
Object.keys(splitArgs().rest).length > 0
Object.keys(splitArgs().rest)
.length > 0
}
>
<div data-part-tool-args>
@ -1299,8 +1309,10 @@ export default function Share(props: {
data().rootDir,
),
)
const hasError = () => toolData()?.metadata?.error
const preview = () => toolData()?.metadata?.preview
const hasError = () =>
toolData()?.metadata?.error
const preview = () =>
toolData()?.metadata?.preview
return (
<div
@ -1333,7 +1345,9 @@ export default function Share(props: {
</div>
</Match>
{/* Always try to show CodeBlock if preview is available (even if empty string) */}
<Match when={typeof preview() === 'string'}>
<Match
when={typeof preview() === "string"}
>
<div data-part-tool-result>
<ResultsButton
showCopy="Show preview"
@ -1346,7 +1360,9 @@ export default function Share(props: {
<Show when={showResults()}>
<div data-part-tool-code>
<CodeBlock
lang={getShikiLang(filePath())}
lang={getShikiLang(
filePath(),
)}
code={preview()}
/>
</div>
@ -1354,7 +1370,12 @@ export default function Share(props: {
</div>
</Match>
{/* Fallback to TextPart if preview is not a string (e.g. undefined) AND result exists */}
<Match when={typeof preview() !== 'string' && toolData()?.result}>
<Match
when={
typeof preview() !== "string" &&
toolData()?.result
}
>
<div data-part-tool-result>
<ResultsButton
results={showResults()}
@ -1398,7 +1419,8 @@ export default function Share(props: {
data().rootDir,
),
)
const hasError = () => toolData()?.metadata?.error
const hasError = () =>
toolData()?.metadata?.error
const content = () => toolData()?.args?.content
const diagnostics = createMemo(() =>
getDiagnostics(
@ -1415,7 +1437,10 @@ export default function Share(props: {
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconDocumentPlus width={18} height={18} />
<IconDocumentPlus
width={18}
height={18}
/>
</AnchorIcon>
<div></div>
</div>
@ -1435,7 +1460,7 @@ export default function Share(props: {
<div data-part-tool-result>
<ErrorPart>
{formatErrorString(
toolData()?.result
toolData()?.result,
)}
</ErrorPart>
</div>
@ -1453,8 +1478,12 @@ export default function Share(props: {
<Show when={showResults()}>
<div data-part-tool-code>
<CodeBlock
lang={getShikiLang(filePath())}
code={toolData()?.args?.content}
lang={getShikiLang(
filePath(),
)}
code={
toolData()?.args?.content
}
/>
</div>
</Show>
@ -1481,8 +1510,10 @@ export default function Share(props: {
>
{(_part) => {
const diff = () => toolData()?.metadata?.diff
const message = () => toolData()?.metadata?.message
const hasError = () => toolData()?.metadata?.error
const message = () =>
toolData()?.metadata?.message
const hasError = () =>
toolData()?.metadata?.error
const filePath = createMemo(() =>
stripWorkingDirectory(
toolData()?.args.filePath,
@ -1504,7 +1535,10 @@ export default function Share(props: {
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconPencilSquare width={18} height={18} />
<IconPencilSquare
width={18}
height={18}
/>
</AnchorIcon>
<div></div>
</div>
@ -1527,7 +1561,9 @@ export default function Share(props: {
<Match when={diff()}>
<div data-part-tool-edit>
<DiffView
class={styles["diff-code-block"]}
class={
styles["diff-code-block"]
}
diff={diff()}
lang={getShikiLang(filePath())}
/>
@ -1556,9 +1592,12 @@ export default function Share(props: {
}
>
{(_part) => {
const command = () => toolData()?.metadata?.title
const desc = () => toolData()?.metadata?.description
const result = () => toolData()?.metadata?.stdout
const command = () =>
toolData()?.metadata?.title
const desc = () =>
toolData()?.metadata?.description
const result = () =>
toolData()?.metadata?.stdout
const error = () => toolData()?.metadata?.stderr
return (
@ -1569,7 +1608,10 @@ export default function Share(props: {
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconCommandLine width={18} height={18} />
<IconCommandLine
width={18}
height={18}
/>
</AnchorIcon>
<div></div>
</div>
@ -1604,7 +1646,9 @@ export default function Share(props: {
>
{(_part) => {
const todos = createMemo(() =>
sortTodosByStatus(toolData()?.args?.todos ?? []),
sortTodosByStatus(
toolData()?.args?.todos ?? [],
),
)
const starting = () =>
todos().every((t) => t.status === "pending")
@ -1670,7 +1714,8 @@ export default function Share(props: {
{(_part) => {
const url = () => toolData()?.args.url
const format = () => toolData()?.args.format
const hasError = () => toolData()?.metadata?.error
const hasError = () =>
toolData()?.metadata?.error
return (
<div
@ -1793,7 +1838,8 @@ export default function Share(props: {
</Match>
<Match
when={
part().toolInvocation.state === "call"
part().toolInvocation.state ===
"call"
}
>
<TextPart
@ -1839,7 +1885,10 @@ export default function Share(props: {
</Match>
<Match when={msg.role === "user"}>
<IconUserCircle width={18} height={18} />
<IconUserCircle
width={18}
height={18}
/>
</Match>
</Switch>
</AnchorIcon>
@ -1848,7 +1897,9 @@ export default function Share(props: {
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>{part.type}</span>
<span data-element-label>
{part.type}
</span>
</div>
<TextPart
text={JSON.stringify(part, null, 2)}

View file

@ -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;
}
}

View file

@ -253,7 +253,7 @@
line-height: 18px;
font-size: 0.875rem;
color: var(--sl-color-text-secondary);
max-width: var(--sm-tool-width);
max-width: var(--md-tool-width);
display: flex;
align-items: flex-start;
@ -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;

View file

@ -30,6 +30,7 @@ Add a local MCP servers under `mcp.localmcp`.
"enabled": true,
"environment": {
"MY_ENV_VAR": "my_env_var_value"
}
}
}
}

View file

@ -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.