Compare commits

..
29 Commits
Author SHA1 Message Date
opencode fa2e88f49b release: v1.0.11 2025-11-02 08:18:59 +00:00
Dax Raad 28e765ef0a fix dialog 2025-11-02 02:53:55 -05:00
Dax Raad bfbcb5f200 tui: prevent default Enter key behavior when selecting dialog options to avoid conflicts 2025-11-02 01:19:30 -05:00
Aiden Cline 89492b3002 ci: fix regex 2025-11-01 20:23:10 -05:00
opencode-agent[bot]GitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
2663415d47 github action: truncate PR titles to 256 chars to avoid GH api errors (#3727)
publish-github-action / publish (push) Has been cancelled
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-11-01 20:09:35 -05:00
Aiden Cline 51be67cc14 ci: stop auto assigning 2025-11-01 19:57:09 -05:00
Sebastian Herrlinger 92a1943771 upgrade to opentui 0.1.32, activates kitty keyboard 2025-11-02 01:45:38 +01:00
opencode 1e15fc273a release: v1.0.10 2025-11-01 18:06:28 +00:00
DaxandGitHub 104a895a71 Light mode (#3709) 2025-11-01 13:54:01 -04:00
Dax Raad f98e730405 docs update 2025-11-01 13:23:03 -04:00
Dax Raad b12bef05d3 docs: update keybinds documentation with current defaults and remove deprecated bindings 2025-11-01 12:32:22 -04:00
opencode 2f1d001cc5 release: v1.0.9 2025-11-01 16:22:49 +00:00
Dax Raad 65d0b3ed6d sync 2025-11-01 12:14:15 -04:00
Haris GušićandGitHub 22a34d7958 feat: tui: Port /exit command and all command aliases (#3665) 2025-11-01 12:13:10 -04:00
Aiden Cline cb4401ec92 ignore: update contributing md 2025-11-01 11:08:07 -05:00
opencode febf467b03 release: v1.0.8 2025-11-01 15:58:23 +00:00
Dax Raad d55a2fd56c tui: change delete keybind to ctrl+d in session list dialog 2025-11-01 11:53:46 -04:00
Dax Raad 40f577e5e7 fix modified files being empty 2025-11-01 11:48:47 -04:00
Dax Raad 9e49870118 remember sidebar position 2025-11-01 11:40:33 -04:00
fe38e3ab02 docs: add Hugging Face Inference Providers documentation (#3505)
Co-authored-by: célina <hanouticelina@gmail.com>
2025-11-01 10:33:17 -05:00
Haris GušićandGitHub 0170577743 feat: tui: Add --prompt option (#3668) 2025-11-01 11:18:31 -04:00
Giuseppe RotaandGitHub 7de6ea5922 fix: fix typo in commit message guidelines (#3702) 2025-11-01 10:14:53 -05:00
Yuku KotaniandGitHub 2fe7d13e69 Add formatter status display to TUI status dialog (#3701) 2025-11-01 11:14:39 -04:00
Dax Raad 1bc3c98ae7 ensure wl-copy is available 2025-11-01 11:10:39 -04:00
Haris GušićandGitHub 55787f2caa fix: tui: Handle Clipboard.copy errors properly (#3685) 2025-11-01 15:34:21 +01:00
Haris GušićandGitHub 7df61a74a0 fix: tui: add toast for /share url copy (#3686) 2025-11-01 08:06:56 -05:00
GitHub Action 4f23110880 ignore: update download stats 2025-11-01 2025-11-01 12:04:18 +00:00
Aiden ClineandGitHub 041353f4ff make /init a default slash command on server side (#3677) 2025-11-01 01:14:09 -05:00
Haris Gušićandopencode c72f8b17c6 fix: tui: Fix /editor command (#3663) 2025-11-01 00:16:06 +00:00
55 changed files with 1201 additions and 781 deletions
+1 -8
View File
@@ -21,7 +21,7 @@ jobs:
const description = issue.body || '';
// Check for version patterns like v1.0.x or 1.0.x
const versionPattern = /\b[v]?1\.0\.[x\d]\b/i;
const versionPattern = /[v]?1\.0\./i;
if (versionPattern.test(title) || versionPattern.test(description)) {
await github.rest.issues.addLabels({
@@ -30,11 +30,4 @@ jobs:
issue_number: issue.number,
labels: ['opentui']
});
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
assignees: ['thdxr']
});
}
+1 -1
View File
@@ -19,5 +19,5 @@ For anything in the packages/app use the ignore: prefix.
prefer to explain WHY something was done from an end user perspective instead of
WHAT was done.
do not do generic messages like "improvied agent experience" be very specific
do not do generic messages like "improved agent experience" be very specific
about what user facing changes were made
+3 -3
View File
@@ -26,7 +26,7 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
## Developing OpenCode
- Requirements: Bun 1.3+, Go 1.24.x.
- Requirements: Bun 1.3+
- Install dependencies and start the dev server from the repo root:
```bash
@@ -36,11 +36,11 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
- Core pieces:
- `packages/opencode`: OpenCode core business logic & server.
- `packages/tui`: The TUI code, written in Go (will be removed soon in favor of [opentui](https://github.com/sst/opentui))
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
- `packages/plugin`: Source for `@opencode-ai/plugin`
> [!NOTE]
> After touching `packages/opencode/src/server/server.ts`, the OpenCode team must regenerate the Stainless SDK before any client updates merge.
> After touching `packages/opencode/src/server/server.ts`, run "./packages/sdk/js/script/build.ts" to regenerate the JS sdk.
## Pull Request Expectations
+1
View File
@@ -126,3 +126,4 @@
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
+21 -21
View File
@@ -39,7 +39,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.7",
"version": "1.0.11",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -66,7 +66,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.7",
"version": "1.0.11",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -90,7 +90,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.7",
"version": "1.0.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -111,7 +111,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.7",
"version": "1.0.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -150,7 +150,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.7",
"version": "1.0.11",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -166,7 +166,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.7",
"version": "1.0.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -184,8 +184,8 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.0.0-20251031-fc297165",
"@opentui/solid": "0.0.0-20251031-fc297165",
"@opentui/core": "0.1.32",
"@opentui/solid": "0.1.32",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -243,7 +243,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.7",
"version": "1.0.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -263,7 +263,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.7",
"version": "1.0.11",
"devDependencies": {
"@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:",
@@ -274,7 +274,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.7",
"version": "1.0.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -287,7 +287,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.7",
"version": "1.0.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -317,7 +317,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.7",
"version": "1.0.11",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -961,21 +961,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.0.0-20251031-fc297165", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.0.0-20251031-fc297165", "@opentui/core-darwin-x64": "0.0.0-20251031-fc297165", "@opentui/core-linux-arm64": "0.0.0-20251031-fc297165", "@opentui/core-linux-x64": "0.0.0-20251031-fc297165", "@opentui/core-win32-arm64": "0.0.0-20251031-fc297165", "@opentui/core-win32-x64": "0.0.0-20251031-fc297165", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-xtUF/uJF04d1wl4f7vsRNsDN8P9uK9Mcx1SAcm79wAN90VPNB4j2G0s7qlt8SD4zB0iWPjXICqJidjRzrQ3QVg=="],
"@opentui/core": ["@opentui/core@0.1.32", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.32", "@opentui/core-darwin-x64": "0.1.32", "@opentui/core-linux-arm64": "0.1.32", "@opentui/core-linux-x64": "0.1.32", "@opentui/core-win32-arm64": "0.1.32", "@opentui/core-win32-x64": "0.1.32", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6Ms1Gybyvl3Rt4k8OdA2c/5YlhobICcXjF5mn4k7tWujFnrBTm441G8k02pdIUffy7fD7dsouq12gfAVmSBmvA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20251031-fc297165", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SD5AiofTfOT+JBx7tcBcd6BdD9sc+RPkHbhIJeqkw5V/GJ4OjyUW3m2kyR9iTs1nLMbKD5o9gyVXpLig4KmFiQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.32", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5yvhJEsXnZGS/q2jIfz97eA4wHOJyF/zTvJL6ykvqjlXwW+bOQ8S7WcpBShR2gf+49Exak3cO+XB16yMWmCyEw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20251031-fc297165", "", { "os": "darwin", "cpu": "x64" }, "sha512-uhzxSvmfeK7vv8uNdhl8Mn2yMnjOVqdjZTOIV2aI8H9SCp8cmnzuLA8FXFO+BW6kgxsg6LbVdp4d4jDCgwtKLQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.32", "", { "os": "darwin", "cpu": "x64" }, "sha512-DKEA3kYvFuj5C4i1N1ck+VEqYH1iCc1O958iGrc5r++jGcP0osKKEA5qSWbSlEy+iflr7Oydr350Aqyyt7J/pA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20251031-fc297165", "", { "os": "linux", "cpu": "arm64" }, "sha512-qGjjk/QTrAyqwzPC+6NhqiQZ31k3GxufbtccF8Yqan0GLuA6GrKcU72IcPwVA5t/6VIXaLkJZyFfub7CoO1D/g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.32", "", { "os": "linux", "cpu": "arm64" }, "sha512-02rgp+Rq21hg3MhQIo8guvHNGuerJgdGSWqPsUs7HwsHLL1yD8ndMxdZxOvyHNsEjzrzklQqPishHZ97QbAVYQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20251031-fc297165", "", { "os": "linux", "cpu": "x64" }, "sha512-gre61Sxc9yX8lrqGNXz5fyE7xJHfkgDi8smGPE2OVP8HmXh0Rn1tXMzFywweEs9MELP3kdQ0VhimYJWkp8FyWg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.32", "", { "os": "linux", "cpu": "x64" }, "sha512-+ek+EYyJKC9xxVeqD14XlBYaaN7Xm45PGv7pviuqZMwJn+G6v6JxwfUNASOr/KT0N1BZdcDGp9EGToYGXwjsQg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20251031-fc297165", "", { "os": "win32", "cpu": "arm64" }, "sha512-44jsq/Ea+jIjZDXyt0w23/DkvwniQFPRB1tocGp6VrOHyHKa0IPHAQ+iuM0felbnmdMUFYyTyh1iOfAcuZyaaA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.32", "", { "os": "win32", "cpu": "arm64" }, "sha512-k9vt+jBrrWAKOWmOC02G4S8V+1iftsq6a+8+Lt9Vc6GdXNTWIKCooB5FPAoLaQeb9TXKjK1WJFgibEXo3Q9XXQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20251031-fc297165", "", { "os": "win32", "cpu": "x64" }, "sha512-L20tCPrLFMCuX4lC2JTcixiCGFNM5RTHQwKLRcxcsSdKBr6a/7ztOG2a/2RNWkrrlbwTrUREVXH4Ivk3EOuStw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.32", "", { "os": "win32", "cpu": "x64" }, "sha512-aLUYKZVMyyyN2A0d5ETbt4ktTb6GCp4PXYHijhOXy7QPvg779H8fy7dpGUHZYEJdwAdGrIvi/y6SKn9FmuXisA=="],
"@opentui/solid": ["@opentui/solid@0.0.0-20251031-fc297165", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20251031-fc297165", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-9u7ULKztDG1SvvU/wNCTFL7JYNPkG+pevcEU3JA7M2uUTIWrvKf/rD13lxtfVe7/yfxcY69SMRlaJGWpfxud5w=="],
"@opentui/solid": ["@opentui/solid@0.1.32", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.32", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JYQ7DpC1oF1jCO4I27mxYGz8M5AmBzsm7xbcG3VANA9cpCY/Tp3YXxanw2Gx1G/xSTY6QMBPhaTEuiFdIQ6FRw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+45 -15
View File
@@ -168,7 +168,9 @@ try {
const summary = await summarize(response)
await pushToLocalBranch(summary)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`))
const hasShared = prData.comments.nodes.some((c) =>
c.body.includes(`${useShareUrl()}/s/${shareId}`),
)
await updateComment(`${response}${footer({ image: !hasShared })}`)
}
// Fork PR
@@ -180,7 +182,9 @@ try {
const summary = await summarize(response)
await pushToForkBranch(summary, prData)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`))
const hasShared = prData.comments.nodes.some((c) =>
c.body.includes(`${useShareUrl()}/s/${shareId}`),
)
await updateComment(`${response}${footer({ image: !hasShared })}`)
}
}
@@ -361,7 +365,9 @@ async function getAccessToken() {
if (!response.ok) {
const responseJson = (await response.json()) as { error?: string }
throw new Error(`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`)
throw new Error(
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`,
)
}
const responseJson = (await response.json()) as { token: string }
@@ -402,8 +408,12 @@ async function getUserPrompt() {
// ie. <img alt="Image" src="https://github.com/user-attachments/assets/xxxx" />
// ie. [api.json](https://github.com/user-attachments/files/21433810/api.json)
// ie. ![Image](https://github.com/user-attachments/assets/xxxx)
const mdMatches = prompt.matchAll(/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi)
const tagMatches = prompt.matchAll(/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi)
const mdMatches = prompt.matchAll(
/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi,
)
const tagMatches = prompt.matchAll(
/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi,
)
const matches = [...mdMatches, ...tagMatches].sort((a, b) => a.index - b.index)
console.log("Images", JSON.stringify(matches, null, 2))
@@ -430,7 +440,8 @@ async function getUserPrompt() {
// Replace img tag with file path, ie. @image.png
const replacement = `@${filename}`
prompt = prompt.slice(0, start + offset) + replacement + prompt.slice(start + offset + tag.length)
prompt =
prompt.slice(0, start + offset) + replacement + prompt.slice(start + offset + tag.length)
offset += replacement.length - tag.length
const contentType = res.headers.get("content-type")
@@ -498,7 +509,12 @@ async function subscribeSessionEvents() {
? JSON.stringify(part.state.input)
: "Unknown"
console.log()
console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title)
console.log(
color + `|`,
"\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`,
"",
"\x1b[0m" + title,
)
}
if (part.type === "text") {
@@ -710,7 +726,8 @@ async function assertPermissions() {
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
}
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
if (!["admin", "write"].includes(permission))
throw new Error(`User ${actor} does not have write permissions`)
}
async function updateComment(body: string) {
@@ -730,12 +747,13 @@ async function updateComment(body: string) {
async function createPR(base: string, branch: string, title: string, body: string) {
console.log("Creating pull request...")
const { repo } = useContext()
const truncatedTitle = title.length > 256 ? title.slice(0, 253) + "..." : title
const pr = await octoRest.rest.pulls.create({
owner: repo.owner,
repo: repo.repo,
head: branch,
base,
title,
title: truncatedTitle,
body,
})
return pr.data.number
@@ -753,7 +771,9 @@ function footer(opts?: { image?: boolean }) {
return `<a href="${useShareUrl()}/s/${shareId}"><img width="200" alt="${titleAlt}" src="https://social-cards.sst.dev/opencode-share/${title64}.png?model=${providerID}/${modelID}&version=${session.version}&id=${shareId}" /></a>\n`
})()
const shareUrl = shareId ? `[opencode session](${useShareUrl()}/s/${shareId})&nbsp;&nbsp;|&nbsp;&nbsp;` : ""
const shareUrl = shareId
? `[opencode session](${useShareUrl()}/s/${shareId})&nbsp;&nbsp;|&nbsp;&nbsp;`
: ""
return `\n\n${image}${shareUrl}[github run](${useEnvRunUrl()})`
}
@@ -936,9 +956,13 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
})
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
const files = (pr.files.nodes || []).map((f) => `- ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`)
const files = (pr.files.nodes || []).map(
(f) => `- ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`,
)
const reviewData = (pr.reviews.nodes || []).map((r) => {
const comments = (r.comments.nodes || []).map((c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`)
const comments = (r.comments.nodes || []).map(
(c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`,
)
return [
`- ${r.author.login} at ${r.submittedAt}:`,
` - Review body: ${r.body}`,
@@ -960,9 +984,15 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
`Deletions: ${pr.deletions}`,
`Total Commits: ${pr.commits.totalCount}`,
`Changed Files: ${pr.files.nodes.length} files`,
...(comments.length > 0 ? ["<pull_request_comments>", ...comments, "</pull_request_comments>"] : []),
...(files.length > 0 ? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"] : []),
...(reviewData.length > 0 ? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"] : []),
...(comments.length > 0
? ["<pull_request_comments>", ...comments, "</pull_request_comments>"]
: []),
...(files.length > 0
? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"]
: []),
...(reviewData.length > 0
? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"]
: []),
"</pull_request>",
].join("\n")
}
+1 -1
View File
@@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "1.0.7"
"version": "1.0.11"
},
"dependencies": {
"@ibm/plex": "6.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.7",
"version": "1.0.11",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.7",
"version": "1.0.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.7",
"version": "1.0.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.7",
"version": "1.0.11",
"description": "",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.7",
"version": "1.0.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.7",
"version": "1.0.11",
"name": "opencode",
"type": "module",
"private": true,
@@ -54,8 +54,8 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.0.0-20251031-fc297165",
"@opentui/solid": "0.0.0-20251031-fc297165",
"@opentui/core": "0.1.32",
"@opentui/solid": "0.1.32",
"@parcel/watcher": "2.5.1",
"@solid-primitives/event-bus": "1.1.2",
"@pierre/precision-diffs": "catalog:",
+18 -22
View File
@@ -28,8 +28,6 @@ import { Storage } from "@/storage/storage"
import { Command } from "@/command"
import { Agent as Agents } from "@/agent/agent"
import { Permission } from "@/permission"
import { Session } from "@/session"
import { Identifier } from "@/id/id"
import { SessionCompaction } from "@/session/compaction"
import type { Config } from "@/config/config"
import { MCP } from "@/mcp"
@@ -89,7 +87,11 @@ export namespace ACP {
})
if (!res) return
if (res.outcome.outcome !== "selected") {
Permission.respond({ sessionID: permission.sessionID, permissionID: permission.id, response: "reject" })
Permission.respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "reject",
})
return
}
Permission.respond({
@@ -111,9 +113,11 @@ export namespace ACP {
const acpSession = this.sessionManager.get(part.sessionID)
if (!acpSession) return
const message = await Storage.read<MessageV2.Info>(["message", part.sessionID, part.messageID]).catch(
() => undefined,
)
const message = await Storage.read<MessageV2.Info>([
"message",
part.sessionID,
part.messageID,
]).catch(() => undefined)
if (!message || message.role !== "assistant") return
if (part.type === "tool") {
@@ -192,7 +196,9 @@ export namespace ACP {
sessionUpdate: "plan",
entries: parsedTodos.data.map((todo) => {
const status: PlanEntry["status"] =
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
todo.status === "cancelled"
? "completed"
: (todo.status as PlanEntry["status"])
return {
priority: "medium",
status,
@@ -375,11 +381,6 @@ export namespace ACP {
description: command.description ?? "",
}))
const names = new Set(availableCommands.map((c) => c.name))
if (!names.has("init"))
availableCommands.push({
name: "init",
description: "create/update a AGENTS.md",
})
if (!names.has("compact"))
availableCommands.push({
name: "compact",
@@ -404,7 +405,8 @@ export namespace ACP {
description: agent.description,
}))
const currentModeId = availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
const currentModeId =
availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
const mcpServers: Record<string, Config.Mcp> = {}
for (const server of params.mcpServers) {
@@ -585,14 +587,6 @@ export namespace ACP {
}
switch (cmd.name) {
case "init":
await Session.initialize({
sessionID,
messageID: Identifier.ascending("message"),
providerID: model.providerID,
modelID: model.modelID,
})
break
case "compact":
await SessionCompaction.run({
sessionID,
@@ -665,7 +659,9 @@ export namespace ACP {
function parseUri(
uri: string,
): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
):
| { type: "file"; url: string; filename: string; mime: string }
| { type: "text"; text: string } {
try {
if (uri.startsWith("file://")) {
const path = uri.slice(7)
+91 -8
View File
@@ -23,21 +23,81 @@ import { Session } from "@tui/routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
import { DialogAlert } from "./ui/dialog-alert"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider } from "./context/exit"
import { ExitProvider, useExit } from "./context/exit"
import type { SessionRoute } from "./context/route"
import { Session as SessionApi } from "@/session"
import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const str = data.toString()
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (match) {
cleanup()
const color = match[1]
// Parse RGB values from color string
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
let r = 0,
g = 0,
b = 0
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
} else if (color.startsWith("#")) {
r = parseInt(color.substring(1, 3), 16)
g = parseInt(color.substring(3, 5), 16)
b = parseInt(color.substring(5, 7), 16)
} else if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
r = parseInt(parts[0])
g = parseInt(parts[1])
b = parseInt(parts[2])
}
// Calculate luminance using relative luminance formula
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
// Determine if dark or light based on luminance threshold
resolve(luminance > 0.5 ? "light" : "dark")
}
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
}
export function tui(input: {
url: string
sessionID?: string
model?: string
agent?: string
prompt?: string
onExit?: () => Promise<void>
}) {
// promise to prevent immediate exit
return new Promise<void>((resolve) => {
return new Promise<void>(async (resolve) => {
const mode = await getTerminalBackgroundColor()
const routeData: Route | undefined = input.sessionID
? {
type: "session",
@@ -64,8 +124,12 @@ export function tui(input: {
<RouteProvider data={routeData}>
<SDKProvider url={input.url}>
<SyncProvider>
<ThemeProvider>
<LocalProvider initialModel={input.model} initialAgent={input.agent}>
<ThemeProvider mode={mode}>
<LocalProvider
initialModel={input.model}
initialAgent={input.agent}
initialPrompt={input.prompt}
>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
@@ -90,6 +154,7 @@ export function tui(input: {
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: true,
},
)
})
@@ -108,7 +173,8 @@ function App() {
const sync = useSync()
const toast = useToast()
const [sessionExists, setSessionExists] = createSignal(false)
const { theme } = useTheme()
const { theme, mode, setMode } = useTheme()
const exit = useExit()
useKeyboard(async (evt) => {
if (evt.meta && evt.name === "t") {
@@ -236,6 +302,14 @@ function App() {
},
category: "System",
},
{
title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
value: "theme.switch_mode",
onSelect: () => {
setMode(mode() === "dark" ? "light" : "dark")
},
category: "System",
},
{
title: "Help",
value: "help.show",
@@ -244,11 +318,17 @@ function App() {
},
category: "System",
},
{
title: "Exit the app",
value: "app.exit",
onSelect: exit,
category: "System",
},
])
createEffect(() => {
const providerID = local.model.current().providerID
if (providerID === "openrouter" && !kv.data.openrouter_warning) {
if (providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
untrack(() => {
DialogAlert.show(
dialog,
@@ -296,8 +376,9 @@ function App() {
/* @ts-expect-error */
renderer.writeOut(finalOsc52)
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
toast.show({ message: "Copied to clipboard", variant: "info" })
}
}}
>
@@ -326,7 +407,9 @@ function App() {
paddingRight={1}
>
<text fg={theme.textMuted}>open</text>
<text attributes={TextAttributes.BOLD}>code </text>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
code{" "}
</text>
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box>
<box paddingLeft={1} paddingRight={1}>
@@ -41,10 +41,10 @@ function init() {
})
const result = {
trigger(name: string) {
trigger(name: string, source?: "prompt") {
for (const option of options()) {
if (option.value === name) {
option.onSelect?.(dialog)
option.onSelect?.(dialog, source)
return
}
}
@@ -60,7 +60,7 @@ export function DialogSessionList() {
}}
keybind={[
{
keybind: Keybind.parse("delete")[0],
keybind: Keybind.parse("ctrl+d")[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
@@ -76,7 +76,7 @@ export function DialogSessionList() {
},
},
{
keybind: Keybind.parse("r")[0],
keybind: Keybind.parse("ctrl+r")[0],
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
@@ -1,7 +1,7 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useSync } from "@tui/context/sync"
import { For, Match, Switch, Show } from "solid-js"
import { For, Match, Switch, Show, createMemo } from "solid-js"
export type DialogStatusProps = {}
@@ -9,15 +9,19 @@ export function DialogStatus() {
const sync = useSync()
const { theme } = useTheme()
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>Status</text>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Status
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text>No MCP Servers</text>}>
<box>
<text>{Object.keys(sync.data.mcp).length} MCP Servers</text>
<text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
<For each={Object.entries(sync.data.mcp)}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
@@ -33,7 +37,7 @@ export function DialogStatus() {
>
</text>
<text wrapMode="word">
<text fg={theme.text} wrapMode="word">
<b>{key}</b>{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
@@ -50,7 +54,7 @@ export function DialogStatus() {
</Show>
{sync.data.lsp.length > 0 && (
<box>
<text>{sync.data.lsp.length} LSP Servers</text>
<text fg={theme.text}>{sync.data.lsp.length} LSP Servers</text>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
@@ -65,7 +69,7 @@ export function DialogStatus() {
>
</text>
<text wrapMode="word">
<text fg={theme.text} wrapMode="word">
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
</text>
</box>
@@ -73,6 +77,31 @@ export function DialogStatus() {
</For>
</box>
)}
<Show
when={enabledFormatters().length > 0}
fallback={<text fg={theme.text}>No Formatters</text>}
>
<box>
<text fg={theme.text}>{enabledFormatters().length} Formatters</text>
<For each={enabledFormatters()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: theme.success,
}}
>
</text>
<text wrapMode="word" fg={theme.text}>
<b>{item.name}</b>
</text>
</box>
)}
</For>
</box>
</Show>
</box>
)
}
@@ -18,6 +18,7 @@ export type AutocompleteRef = {
export type AutocompleteOption = {
display: string
aliases?: string[]
disabled?: boolean
description?: string
onSelect?: () => void
@@ -207,6 +208,7 @@ export function Autocomplete(props: {
},
{
display: "/compact",
aliases: ["/summarize"],
description: "compact the session",
onSelect: () => command.trigger("session.compact"),
},
@@ -232,6 +234,7 @@ export function Autocomplete(props: {
results.push(
{
display: "/new",
aliases: ["/clear"],
description: "create a new session",
onSelect: () => command.trigger("session.new"),
},
@@ -247,6 +250,7 @@ export function Autocomplete(props: {
},
{
display: "/session",
aliases: ["/resume", "/continue"],
description: "list sessions",
onSelect: () => command.trigger("session.list"),
},
@@ -263,7 +267,7 @@ export function Autocomplete(props: {
{
display: "/editor",
description: "open editor",
onSelect: () => command.trigger("prompt.editor"),
onSelect: () => command.trigger("prompt.editor", "prompt"),
},
{
display: "/help",
@@ -275,6 +279,12 @@ export function Autocomplete(props: {
description: "show all commands",
onSelect: () => command.show(),
},
{
display: "/exit",
aliases: ["/quit", "/q"],
description: "exit the app",
onSelect: () => command.trigger("app.exit"),
},
)
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
if (!max) return results
@@ -293,7 +303,7 @@ export function Autocomplete(props: {
const currentFilter = filter()
if (!currentFilter) return mixed.slice(0, 10)
const result = fuzzysort.go(currentFilter, mixed, {
keys: ["display", "description"],
keys: ["display", "description", (obj) => obj.aliases?.join(" ") ?? ""],
limit: 10,
})
return result.map((arr) => arr.obj)
@@ -9,9 +9,9 @@ import {
dim,
fg,
} from "@opentui/core"
import { createEffect, createMemo, Match, Switch, type JSX, onMount } from "solid-js"
import { createEffect, createMemo, Match, Switch, type JSX, onMount, batch } from "solid-js"
import { useLocal } from "@tui/context/local"
import { SyntaxTheme, useTheme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
@@ -60,7 +60,7 @@ export function Prompt(props: PromptProps) {
const history = usePromptHistory()
const command = useCommandDialog()
const renderer = useRenderer()
const { theme } = useTheme()
const { theme, syntax } = useTheme()
const textareaKeybindings = createMemo(() => {
const newlineBindings = keybind.all.input_newline || []
@@ -86,9 +86,9 @@ export function Prompt(props: PromptProps) {
]
})
const fileStyleId = SyntaxTheme.getStyleId("extmark.file")!
const agentStyleId = SyntaxTheme.getStyleId("extmark.agent")!
const pasteStyleId = SyntaxTheme.getStyleId("extmark.paste")!
const fileStyleId = syntax().getStyleId("extmark.file")!
const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId: number
command.register(() => {
@@ -98,14 +98,9 @@ export function Prompt(props: PromptProps) {
category: "Session",
keybind: "editor_open",
value: "prompt.editor",
onSelect: async (dialog) => {
onSelect: async (dialog, trigger) => {
dialog.clear()
const value = input.plainText
input.clear()
setStore("prompt", {
input: "",
parts: [],
})
const value = trigger === "prompt" ? "" : input.plainText
const content = await Editor.open({ value, renderer })
if (content) {
input.setText(content, { history: false })
@@ -194,6 +189,16 @@ export function Prompt(props: PromptProps) {
input.focus()
})
local.setInitialPrompt.listen((initialPrompt) => {
batch(() => {
setStore("prompt", {
input: initialPrompt,
parts: [],
})
input.insertText(initialPrompt)
})
})
onMount(() => {
promptPartTypeId = input.extmarks.registerType("prompt-part")
})
@@ -675,7 +680,7 @@ export function Prompt(props: PromptProps) {
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.primary}
syntaxStyle={SyntaxTheme}
syntaxStyle={syntax()}
/>
</box>
<box
@@ -686,7 +691,7 @@ export function Prompt(props: PromptProps) {
></box>
</box>
<box flexDirection="row" justifyContent="space-between">
<text flexShrink={0} wrapMode="none">
<text flexShrink={0} wrapMode="none" fg={theme.text}>
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
<span style={{ bold: true }}>{local.model.parsed().model}</span>
</text>
@@ -696,14 +701,14 @@ export function Prompt(props: PromptProps) {
</Match>
<Match when={status() === "working"}>
<box flexDirection="row" gap={1}>
<text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
</text>
</box>
</Match>
<Match when={props.hint}>{props.hint!}</Match>
<Match when={true}>
<text>
<text fg={theme.text}>
ctrl+p <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
@@ -1,5 +1,5 @@
import { Global } from "@/global"
import { createSignal } from "solid-js"
import { createSignal, type Setter } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import path from "path"
@@ -8,10 +8,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
name: "KV",
init: () => {
const [ready, setReady] = createSignal(false)
const [kvStore, setKvStore] = createStore({
openrouter_warning: false,
theme: "opencode",
})
const [kvStore, setKvStore] = createStore<Record<string, any>>()
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
file
@@ -24,22 +21,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
setReady(true)
})
return {
get data() {
return kvStore
},
const result = {
get ready() {
return ready()
},
signal<T>(name: string, defaultValue: T) {
if (!kvStore[name]) setKvStore(name, defaultValue)
return [
function () {
return result.get(name)
},
function setter(next: Setter<T>) {
result.set(name, next)
},
] as const
},
get(key: string, defaultValue?: any) {
return kvStore[key] ?? defaultValue
},
set(key: string, value: any) {
setKvStore(key as any, value)
Bun.write(
file,
JSON.stringify({
[key]: value,
}),
)
setKvStore(key, value)
Bun.write(file, JSON.stringify(kvStore, null, 2))
},
}
return result
},
})
@@ -8,10 +8,11 @@ import { Global } from "@/global"
import { iife } from "@/util/iife"
import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast"
import { createEventBus } from "@solid-primitives/event-bus"
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: (props: { initialModel?: string; initialAgent?: string }) => {
init: (props: { initialModel?: string; initialAgent?: string; initialPrompt?: string }) => {
const sync = useSync()
const toast = useToast()
@@ -239,9 +240,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
})
const setInitialPrompt = createEventBus<string>()
onMount(() => {
if (props.initialPrompt)
setInitialPrompt.emit(props.initialPrompt)
})
const result = {
model,
agent,
get setInitialPrompt() {
return setInitialPrompt
},
}
return result
},
@@ -10,6 +10,7 @@ import type {
Permission,
LspStatus,
McpStatus,
FormatterStatus,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
@@ -42,6 +43,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: {
[key: string]: McpStatus
}
formatter: FormatterStatus[]
}>({
config: {},
ready: false,
@@ -55,6 +57,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
part: {},
lsp: [],
mcp: {},
formatter: [],
})
const sdk = useSDK()
@@ -220,6 +223,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
])
const result = {
File diff suppressed because it is too large Load Diff
@@ -27,7 +27,7 @@ export function Home() {
const Hint = (
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box flexShrink={0} flexDirection="row" gap={1}>
<text>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}></span> mcp errors{" "}
@@ -76,7 +76,7 @@ function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
const { theme } = useTheme()
return (
<box flexDirection="row" justifyContent="space-between" width="100%">
<text>{props.children}</text>
<text fg={theme.text}>{props.children}</text>
<text fg={theme.primary}>{keybind.print(props.keybind)}</text>
</box>
)
@@ -51,7 +51,7 @@ export function Header() {
borderColor={theme.backgroundElement}
flexShrink={0}
>
<text>
<text fg={theme.text}>
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{session().title}</span>
</text>
@@ -64,7 +64,7 @@ export function Header() {
</text>
</Match>
<Match when={true}>
<text wrapMode="word">
<text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
@@ -15,7 +15,7 @@ import path from "path"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border"
import { SyntaxTheme, useTheme } from "@tui/context/theme"
import { useTheme } from "@tui/context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers } from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type {
@@ -62,8 +62,10 @@ import { DialogTimeline } from "./dialog-timeline"
import { Sidebar } from "./sidebar"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import parsers from "../../../../../../parsers-config.ts"
import { Toast } from "../../ui/toast"
import { Clipboard } from "../../util/clipboard"
import { Toast, useToast } from "../../ui/toast"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { useKV } from "../../context/kv.tsx"
addDefaultParsers(parsers.parsers)
@@ -81,6 +83,7 @@ function use() {
export function Session() {
const route = useRouteData("session")
const sync = useSync()
const kv = useKV()
const { theme } = useTheme()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
@@ -91,7 +94,7 @@ export function Session() {
})
const dimensions = useTerminalDimensions()
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">("auto")
const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto"))
const [conceal, setConceal] = createSignal(true)
const wide = createMemo(() => dimensions().width > 120)
@@ -100,6 +103,8 @@ export function Session() {
createEffect(() => sync.session.sync(route.sessionID))
const toast = useToast()
const sdk = useSDK()
let scroll: ScrollBoxRenderable
@@ -196,12 +201,20 @@ export function Session() {
keybind: "session_share",
disabled: !!session()?.share?.url,
category: "Session",
onSelect: (dialog) => {
sdk.client.session.share({
path: {
id: route.sessionID,
},
})
onSelect: async (dialog) => {
await sdk.client.session
.share({
path: {
id: route.sessionID,
},
})
.then((res) =>
Clipboard.copy(res.data!.share!.url).catch(() =>
toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
),
)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
dialog.clear()
},
},
@@ -294,6 +307,8 @@ export function Session() {
if (prev === "show") return "hide"
return "show"
})
if (sidebar() === "show") kv.set("sidebar", "auto")
if (sidebar() === "hide") kv.set("sidebar", "hide")
dialog.clear()
},
},
@@ -626,7 +641,7 @@ function UserMessage(props: {
borderColor={color()}
flexShrink={0}
>
<text>{text()?.text}</text>
<text fg={theme.text}>{text()?.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
@@ -637,7 +652,7 @@ function UserMessage(props: {
return theme.secondary
})
return (
<text>
<text fg={theme.text}>
<span style={{ bg: bg(), fg: theme.background }}>
{" "}
{MIME_BADGE[file.mime] ?? file.mime}{" "}
@@ -652,7 +667,7 @@ function UserMessage(props: {
</For>
</box>
</Show>
<text>
<text fg={theme.text}>
{sync.data.config.username ?? "You"}{" "}
<Show
when={queued()}
@@ -767,7 +782,7 @@ function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }
paddingLeft={2}
backgroundColor={theme.backgroundPanel}
>
<text>{props.part.text.trim()}</text>
<text fg={theme.text}>{props.part.text.trim()}</text>
</box>
</box>
</Show>
@@ -776,13 +791,14 @@ function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }
function TextPart(props: { part: TextPart; message: AssistantMessage }) {
const ctx = use()
const { syntax } = useTheme()
return (
<Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<code
filetype="markdown"
drawUnstyledText={false}
syntaxStyle={SyntaxTheme}
syntaxStyle={syntax()}
content={props.part.text.trim()}
conceal={ctx.conceal()}
/>
@@ -982,7 +998,7 @@ ToolRegistry.register<typeof WriteTool>({
name: "write",
container: "block",
render(props) {
const { theme } = useTheme()
const { theme, syntax } = useTheme()
const lines = createMemo(() => {
return props.input.content?.split("\n") ?? []
})
@@ -1013,7 +1029,7 @@ ToolRegistry.register<typeof WriteTool>({
<box paddingLeft={1} flexGrow={1}>
<code
filetype={filetype(props.input.filePath!)}
syntaxStyle={SyntaxTheme}
syntaxStyle={syntax()}
content={code()}
/>
</box>
@@ -1116,6 +1132,7 @@ ToolRegistry.register<typeof EditTool>({
container: "block",
render(props) {
const ctx = use()
const { theme, syntax } = useTheme()
const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked"))
@@ -1195,21 +1212,21 @@ ToolRegistry.register<typeof EditTool>({
</ToolTitle>
<Switch>
<Match when={props.permission["diff"]}>
<text>{props.permission["diff"]?.trim()}</text>
<text fg={theme.text}>{props.permission["diff"]?.trim()}</text>
</Match>
<Match when={diff() && style() === "split"}>
<box paddingLeft={1} flexDirection="row" gap={2}>
<box flexGrow={1} flexBasis={0}>
<code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.oldContent} />
<code filetype={ft()} syntaxStyle={syntax()} content={diff()!.oldContent} />
</box>
<box flexGrow={1} flexBasis={0}>
<code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.newContent} />
<code filetype={ft()} syntaxStyle={syntax()} content={diff()!.newContent} />
</box>
</box>
</Match>
<Match when={code()}>
<box paddingLeft={1}>
<code filetype={ft()} syntaxStyle={SyntaxTheme} content={code()} />
<code filetype={ft()} syntaxStyle={syntax()} content={code()} />
</box>
</Match>
</Switch>
@@ -1222,6 +1239,7 @@ ToolRegistry.register<typeof PatchTool>({
name: "patch",
container: "block",
render(props) {
const { theme } = useTheme()
return (
<>
<ToolTitle icon="%" fallback="Preparing patch..." when={true}>
@@ -1229,7 +1247,7 @@ ToolRegistry.register<typeof PatchTool>({
</ToolTitle>
<Show when={props.output}>
<box>
<text>{props.output?.trim()}</text>
<text fg={theme.text}>{props.output?.trim()}</text>
</box>
</Show>
</>
@@ -42,7 +42,7 @@ export function Sidebar(props: { sessionID: string }) {
<Show when={session()}>
<box flexShrink={0} gap={1} width={40}>
<box>
<text>
<text fg={theme.text}>
<b>{session().title}</b>
</text>
<Show when={session().share?.url}>
@@ -50,7 +50,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
</box>
<box>
<text>
<text fg={theme.text}>
<b>Context</b>
</text>
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
@@ -59,7 +59,7 @@ export function Sidebar(props: { sessionID: string }) {
</box>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box>
<text>
<text fg={theme.text}>
<b>MCP</b>
</text>
<For each={Object.entries(sync.data.mcp)}>
@@ -77,7 +77,7 @@ export function Sidebar(props: { sessionID: string }) {
>
</text>
<text wrapMode="word">
<text fg={theme.text} wrapMode="word">
{key}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch>
@@ -96,7 +96,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<Show when={sync.data.lsp.length > 0}>
<box>
<text>
<text fg={theme.text}>
<b>LSP</b>
</text>
<For each={sync.data.lsp}>
@@ -123,7 +123,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<Show when={session().summary?.diffs}>
<box>
<text>
<text fg={theme.text}>
<b>Modified Files</b>
</text>
<For each={session().summary?.diffs || []}>
@@ -155,7 +155,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<Show when={todo().length > 0}>
<box>
<text>
<text fg={theme.text}>
<b>Todo</b>
</text>
<For each={todo()}>
@@ -32,6 +32,11 @@ export const TuiThreadCommand = cmd({
describe: "session id to continue",
type: "string",
})
.option("prompt", {
alias: ["p"],
type: "string",
describe: "prompt to use",
})
.option("agent", {
type: "string",
describe: "agent to use",
@@ -95,6 +100,7 @@ export const TuiThreadCommand = cmd({
sessionID,
model: args.model,
agent: args.agent,
prompt: args.prompt,
onExit: async () => {
await client.call("shutdown", undefined)
},
@@ -35,7 +35,7 @@ export interface DialogSelectOption<T = any> {
category?: string
disabled?: boolean
bg?: RGBA
onSelect?: (ctx: DialogContext) => void
onSelect?: (ctx: DialogContext, trigger?: "prompt") => void
}
export type DialogSelectRef<T> = {
@@ -130,6 +130,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (evt.name === "return") {
const option = selected()
if (option) {
evt.preventDefault()
if (option.onSelect) option.onSelect(dialog)
props.onSelect?.(option)
}
@@ -161,7 +162,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box gap={1}>
<box paddingLeft={3} paddingRight={2}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{props.title}
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingTop={1} paddingBottom={1}>
@@ -177,7 +180,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
focusedTextColor={theme.textMuted}
ref={(r) => {
input = r
input.focus()
setTimeout(() => input.focus(), 1)
}}
placeholder="Enter search term"
/>
@@ -242,7 +245,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
)}
</For>
</scrollbox>
<box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1}>
<box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1} gap={1}>
<For each={props.keybind ?? []}>
{(item) => (
<text>
+10 -17
View File
@@ -1,23 +1,18 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, Show, useContext, type JSX, type ParentProps } from "solid-js"
import {
batch,
createContext,
createEffect,
Show,
useContext,
type JSX,
type ParentProps,
} from "solid-js"
import { useTheme } from "@tui/context/theme"
import { Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { createEventBus } from "@solid-primitives/event-bus"
const Border = {
topLeft: "┃",
topRight: "┃",
bottomLeft: "┃",
bottomRight: "┃",
horizontal: "",
vertical: "┃",
topT: "+",
bottomT: "+",
leftT: "+",
rightT: "+",
cross: "+",
}
export function Dialog(
props: ParentProps<{
size?: "medium" | "large"
@@ -45,11 +40,9 @@ export function Dialog(
onMouseUp={async (e) => {
e.stopPropagation()
}}
customBorderChars={Border}
width={props.size === "large" ? 80 : 60}
maxWidth={dimensions().width - 2}
backgroundColor={theme.backgroundPanel}
borderColor={theme.border}
paddingTop={1}
>
{props.children}
@@ -138,7 +131,7 @@ function init() {
},
get allClosedEvent() {
return allClosedEvent
}
},
}
}
+13 -1
View File
@@ -49,7 +49,7 @@ function init() {
let timeoutHandle: NodeJS.Timeout | null = null
return {
const toast = {
show(options: ToastOptions) {
const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
const { duration, ...currentToast } = parsedOptions
@@ -59,10 +59,22 @@ function init() {
setStore("currentToast", null)
}, duration).unref()
},
error: (err: any) => {
if (err instanceof Error)
return toast.show({
variant: "error",
message: err.message,
})
toast.show({
variant: "error",
message: "An unknown error has occurred",
})
},
get currentToast(): ToastOptions | null {
return store.currentToast
},
}
return toast
}
export type ToastContext = ReturnType<typeof init>
@@ -61,7 +61,7 @@ export namespace Clipboard {
const getCopyMethod = lazy(() => {
const os = platform()
if (os === "darwin") {
if (os === "darwin" && Bun.which("oascript")) {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
@@ -70,7 +70,7 @@ export namespace Clipboard {
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"]) {
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
@@ -24,6 +24,7 @@ export namespace Editor {
})
await proc.exited
const content = await Bun.file(filepath).text()
opts.renderer.currentRenderBuffer.clear()
opts.renderer.resume()
opts.renderer.requestRender()
return content || undefined
+27
View File
@@ -1,8 +1,27 @@
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import { Bus } from "../bus"
import { Identifier } from "../id/id"
export namespace Command {
export const Default = {
INIT: "init",
} as const
export const Event = {
Executed: Bus.event(
"command.executed",
z.object({
name: z.string(),
sessionID: Identifier.schema("session"),
arguments: z.string(),
messageID: Identifier.schema("message"),
}),
),
}
export const Info = z
.object({
name: z.string(),
@@ -33,6 +52,14 @@ export namespace Command {
}
}
if (result[Default.INIT] === undefined) {
result[Default.INIT] = {
name: Default.INIT,
description: "create/update AGENTS.md",
template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
}
}
return result
})
@@ -6,3 +6,5 @@ The file you create will be given to agentic coding agents (such as yourself) th
If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
If there's already an AGENTS.md, improve it if it's located in ${path}
$ARGUMENTS
+2 -2
View File
@@ -466,11 +466,11 @@ export namespace Config {
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_forward_delete: z.string().optional().default("ctrl+d").describe("Forward delete"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
input_submit: z.string().optional().default("enter").describe("Submit input"),
input_submit: z.string().optional().default("return").describe("Submit input"),
input_newline: z
.string()
.optional()
.default("shift+enter,ctrl+j")
.default("shift+return,ctrl+j")
.describe("Insert newline in input"),
history_previous: z.string().optional().default("up").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Previous history item"),
+26
View File
@@ -2,6 +2,7 @@ import { Bus } from "../bus"
import { File } from "../file"
import { Log } from "../util/log"
import path from "path"
import z from "zod"
import * as Formatter from "./formatter"
import { Config } from "../config/config"
@@ -11,6 +12,17 @@ import { Instance } from "../project/instance"
export namespace Format {
const log = Log.create({ service: "format" })
export const Status = z
.object({
name: z.string(),
extensions: z.string().array(),
enabled: z.boolean(),
})
.meta({
ref: "FormatterStatus",
})
export type Status = z.infer<typeof Status>
const state = Instance.state(async () => {
const enabled: Record<string, boolean> = {}
const cfg = await Config.get()
@@ -62,6 +74,20 @@ export namespace Format {
return result
}
export async function status() {
const s = await state()
const result: Status[] = []
for (const formatter of Object.values(s.formatters)) {
const enabled = await isEnabled(formatter)
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled,
})
}
return result
}
export function init() {
log.info("init")
Bus.subscribe(File.Event.Edited, async (payload) => {
@@ -5,6 +5,10 @@ import { LSP } from "../lsp"
import { FileWatcher } from "../file/watcher"
import { File } from "../file"
import { Flag } from "../flag/flag"
import { Project } from "./project"
import { Bus } from "../bus"
import { Command } from "../command"
import { Instance } from "./instance"
export async function InstanceBootstrap() {
if (Flag.OPENCODE_EXPERIMENTAL_NO_BOOTSTRAP) return
@@ -14,4 +18,10 @@ export async function InstanceBootstrap() {
await LSP.init()
FileWatcher.init()
File.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
await Project.setInitialized(Instance.project.id)
}
})
}
+21
View File
@@ -20,6 +20,7 @@ import { Ripgrep } from "../file/ripgrep"
import { Config } from "../config/config"
import { File } from "../file"
import { LSP } from "../lsp"
import { Format } from "../format"
import { MessageV2 } from "../session/message-v2"
import { TuiRoute } from "./tui"
import { Permission } from "../permission"
@@ -1336,6 +1337,26 @@ export namespace Server {
return c.json(await LSP.status())
},
)
.get(
"/formatter",
describeRoute({
description: "Get formatter status",
operationId: "formatter.status",
responses: {
200: {
description: "Formatter status",
content: {
"application/json": {
schema: resolver(Format.Status.array()),
},
},
},
},
}),
async (c) => {
return c.json(await Format.status())
},
)
.post(
"/tui/append-prompt",
describeRoute({
+14 -18
View File
@@ -2,8 +2,6 @@ import { Decimal } from "decimal.js"
import z from "zod"
import { type LanguageModelUsage, type ProviderMetadata } from "ai"
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
import { Bus } from "../bus"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
@@ -14,11 +12,11 @@ import { Share } from "../share/share"
import { Storage } from "../storage/storage"
import { Log } from "../util/log"
import { MessageV2 } from "./message-v2"
import { Project } from "../project/project"
import { Instance } from "../project/instance"
import { SessionPrompt } from "./prompt"
import { fn } from "@/util/fn"
import { Snapshot } from "@/snapshot"
import { Command } from "../command"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -164,7 +162,12 @@ export namespace Session {
})
})
export async function createNext(input: { id?: string; title?: string; parentID?: string; directory: string }) {
export async function createNext(input: {
id?: string
title?: string
parentID?: string
directory: string
}) {
const result: Info = {
id: Identifier.descending("session", input.id),
version: Installation.VERSION,
@@ -402,7 +405,9 @@ export namespace Session {
.add(new Decimal(tokens.input).mul(input.model.cost?.input ?? 0).div(1_000_000))
.add(new Decimal(tokens.output).mul(input.model.cost?.output ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.read).mul(input.model.cost?.cache_read ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.write).mul(input.model.cost?.cache_write ?? 0).div(1_000_000))
.add(
new Decimal(tokens.cache.write).mul(input.model.cost?.cache_write ?? 0).div(1_000_000),
)
.toNumber(),
tokens,
}
@@ -423,22 +428,13 @@ export namespace Session {
messageID: Identifier.schema("message"),
}),
async (input) => {
await SessionPrompt.prompt({
await SessionPrompt.command({
sessionID: input.sessionID,
messageID: input.messageID,
model: {
providerID: input.providerID,
modelID: input.modelID,
},
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
},
],
model: input.providerID + "/" + input.modelID,
command: Command.Default.INIT,
arguments: "",
})
await Project.setInitialized(Instance.project.id)
},
)
}
+21 -9
View File
@@ -1593,6 +1593,7 @@ export namespace SessionPrompt {
let index = 0
template = template.replace(bashRegex, () => results[index++])
}
template = template.trim()
const parts = [
{
@@ -1657,6 +1658,8 @@ export namespace SessionPrompt {
})()
const agent = await Agent.get(agentName)
let result: MessageV2.WithParts
if ((agent.mode === "subagent" && command.subtask !== false) || command.subtask === true) {
using abort = lock(input.sessionID)
@@ -1732,7 +1735,7 @@ export namespace SessionPrompt {
}
await Session.updatePart(toolPart)
const result = await TaskTool.init().then((t) =>
const taskResult = await TaskTool.init().then((t) =>
t.execute(args, {
sessionID: input.sessionID,
abort: abort.signal,
@@ -1760,22 +1763,31 @@ export namespace SessionPrompt {
},
input: toolPart.state.input,
title: "",
metadata: result.metadata,
output: result.output,
metadata: taskResult.metadata,
output: taskResult.output,
}
await Session.updatePart(toolPart)
}
return { info: assistantMsg, parts: [toolPart] }
result = { info: assistantMsg, parts: [toolPart] }
} else {
result = await prompt({
sessionID: input.sessionID,
messageID: input.messageID,
model,
agent: agentName,
parts,
})
}
return prompt({
Bus.publish(Command.Event.Executed, {
name: input.command,
sessionID: input.sessionID,
messageID: input.messageID,
model,
agent: agentName,
parts,
arguments: input.arguments,
messageID: result.info.id,
})
return result
}
async function ensureTitle(input: {
+7 -3
View File
@@ -6,10 +6,11 @@ import { generateText, type ModelMessage } from "ai"
import { MessageV2 } from "./message-v2"
import { Identifier } from "@/id/id"
import { Snapshot } from "@/snapshot"
import { ProviderTransform } from "@/provider/transform"
import { SystemPrompt } from "./system"
import { Log } from "@/util/log"
import path from "path"
import { Instance } from "@/project/instance"
export namespace SessionSummary {
const log = Log.create({ service: "session.summary" })
@@ -33,10 +34,13 @@ export namespace SessionSummary {
input.messages
.flatMap((x) => x.parts)
.filter((x) => x.type === "patch")
.flatMap((x) => x.files),
.flatMap((x) => x.files)
.map((x) => path.relative(Instance.worktree, x)),
)
const diffs = await computeDiff({ messages: input.messages }).then((x) =>
x.filter((x) => files.has(x.file)),
x.filter((x) => {
return files.has(x.file)
}),
)
await Session.update(input.sessionID, (draft) => {
draft.summary = {
+18 -6
View File
@@ -28,8 +28,14 @@ describe("Keybind.toString", () => {
})
test("should convert shift modifier to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: true, leader: false, name: "enter" }
expect(Keybind.toString(info)).toBe("shift+enter")
const info: Keybind.Info = {
ctrl: false,
meta: false,
shift: true,
leader: false,
name: "return",
}
expect(Keybind.toString(info)).toBe("shift+return")
})
test("should convert function key to string", () => {
@@ -38,7 +44,13 @@ describe("Keybind.toString", () => {
})
test("should convert special key to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "pgup" }
const info: Keybind.Info = {
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "pgup",
}
expect(Keybind.toString(info)).toBe("pgup")
})
@@ -220,15 +232,15 @@ describe("Keybind.parse", () => {
])
})
test("should parse shift+enter combination", () => {
const result = Keybind.parse("shift+enter")
test("should parse shift+return combination", () => {
const result = Keybind.parse("shift+return")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: true,
leader: false,
name: "enter",
name: "return",
},
])
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.7",
"version": "1.0.11",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.7",
"version": "1.0.11",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+17
View File
@@ -107,6 +107,8 @@ import type {
McpStatusResponses,
LspStatusData,
LspStatusResponses,
FormatterStatusData,
FormatterStatusResponses,
TuiAppendPromptData,
TuiAppendPromptResponses,
TuiAppendPromptErrors,
@@ -773,6 +775,20 @@ class Lsp extends _HeyApiClient {
}
}
class Formatter extends _HeyApiClient {
/**
* Get formatter status
*/
public status<ThrowOnError extends boolean = false>(
options?: Options<FormatterStatusData, ThrowOnError>,
) {
return (options?.client ?? this._client).get<FormatterStatusResponses, unknown, ThrowOnError>({
url: "/formatter",
...options,
})
}
}
class Control extends _HeyApiClient {
/**
* Get the next TUI request from the queue
@@ -1023,6 +1039,7 @@ export class OpencodeClient extends _HeyApiClient {
app = new App({ client: this._client })
mcp = new Mcp({ client: this._client })
lsp = new Lsp({ client: this._client })
formatter = new Formatter({ client: this._client })
tui = new Tui({ client: this._client })
auth = new Auth({ client: this._client })
event = new Event({ client: this._client })
+35
View File
@@ -1070,6 +1070,12 @@ export type LspStatus = {
status: "connected" | "error"
}
export type FormatterStatus = {
name: string
extensions: Array<string>
enabled: boolean
}
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
@@ -1248,6 +1254,16 @@ export type EventTodoUpdated = {
}
}
export type EventCommandExecuted = {
type: "command.executed"
properties: {
name: string
sessionID: string
arguments: string
messageID: string
}
}
export type EventSessionIdle = {
type: "session.idle"
properties: {
@@ -1310,6 +1326,7 @@ export type Event =
| EventFileEdited
| EventFileWatcherUpdated
| EventTodoUpdated
| EventCommandExecuted
| EventSessionIdle
| EventSessionCreated
| EventSessionUpdated
@@ -2511,6 +2528,24 @@ export type LspStatusResponses = {
export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses]
export type FormatterStatusData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/formatter"
}
export type FormatterStatusResponses = {
/**
* Formatter status
*/
200: Array<FormatterStatus>
}
export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]
export type TuiAppendPromptData = {
body?: {
text: string
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.7",
"version": "1.0.11",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.7",
"version": "1.0.11",
"type": "module",
"exports": {
".": "./src/components/index.ts",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.7",
"version": "1.0.11",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+7 -2
View File
@@ -13,8 +13,8 @@ The new TUI works like the old one since it connects to the same opencode server
## Upgrading
You will not be autoupgraded to 1.0 if you are currently using a previous
version.
You should not be autoupgraded to 1.0 if you are currently using a previous
version. However some older versions of OpenCode always grab latest.
To upgrade manually, run
@@ -22,6 +22,11 @@ To upgrade manually, run
$ opencode upgrade 1.0.0
```
To downgrade back to 0.x, run
```bash
$ opencode upgrade 0.15.31
```
---
## UX changes
+14 -13
View File
@@ -10,34 +10,33 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
"$schema": "https://opencode.ai/config.json",
"keybinds": {
"leader": "ctrl+x",
"app_help": "<leader>h",
"app_exit": "ctrl+c,ctrl+d,<leader>q",
"editor_open": "<leader>e",
"theme_list": "<leader>t",
"project_init": "<leader>i",
"tool_details": "<leader>d",
"thinking_blocks": "<leader>b",
"sidebar_toggle": "<leader>b",
"status_view": "<leader>s",
"session_export": "<leader>x",
"session_new": "<leader>n",
"session_list": "<leader>l",
"session_share": "<leader>s",
"session_timeline": "<leader>g",
"session_share": "none",
"session_unshare": "none",
"session_interrupt": "esc",
"session_interrupt": "escape",
"session_compact": "<leader>c",
"session_child_cycle": "ctrl+right",
"session_child_cycle_reverse": "ctrl+left",
"messages_page_up": "pgup",
"messages_page_down": "pgdown",
"messages_page_up": "pageup",
"messages_page_down": "pagedown",
"messages_half_page_up": "ctrl+alt+u",
"messages_half_page_down": "ctrl+alt+d",
"messages_first": "ctrl+g",
"messages_last": "ctrl+alt+g",
"messages_first": "ctrl+g,home",
"messages_last": "ctrl+alt+g,end",
"messages_copy": "<leader>y",
"messages_undo": "<leader>u",
"messages_redo": "<leader>r",
"messages_toggle_conceal": "<leader>h",
"model_list": "<leader>m",
"model_cycle_recent": "f2",
"model_cycle_recent_reverse": "shift+f2",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",
"agent_cycle_reverse": "shift+tab",
@@ -45,7 +44,9 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
"input_forward_delete": "ctrl+d",
"input_paste": "ctrl+v",
"input_submit": "enter",
"input_newline": "shift+enter,ctrl+j"
"input_newline": "shift+enter,ctrl+j",
"history_previous": "up",
"history_next": "down"
}
}
```
@@ -444,6 +444,45 @@ To use Google Vertex AI with OpenCode:
4. Run the `/models` command to select the one you want.
---
### Hugging Face
[Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers) provides access to open models supported by 17+ providers.
1. Head over to [Hugging Face settings](https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained) to create a token with permission to make calls to Inference Providers.
2. Run `opencode auth login` and select **Hugging Face**.
```bash
$ opencode auth login
┌ Add credential
◆ Select provider
│ ● Hugging Face
│ ...
```
3. Enter your Hugging Face token.
```bash
$ opencode auth login
┌ Add credential
◇ Select provider
│ Hugging Face
◇ Enter your API key
│ _
```
4. Run the `/models` command to select a model like _Kimi-K2-Instruct_ or _GLM-4.6_.
---
### LM Studio
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.7",
"version": "1.0.11",
"publisher": "sst-dev",
"repository": {
"type": "git",