Compare commits

...
73 Commits
Author SHA1 Message Date
opencode 5e86c9b791 release: v1.0.13 2025-11-02 23:31:25 +00:00
Dax Raad 94658c31c5 add back child session cycle 2025-11-02 18:26:38 -05:00
Dax Raad 9fd672a1cb undo 2025-11-02 16:31:32 -05:00
Dax Raad 10523c4372 move dialog select keybind to input 2025-11-02 15:47:04 -05:00
Dax Raad d1cd7d0344 ci: centralize Bun version to package.json to ensure consistent builds across CI and local development 2025-11-02 15:42:15 -05:00
Dax Raad 06ac1be226 upgrade to bun 1.3.1 2025-11-02 14:00:50 -05:00
Dax Raad 05489bc843 tui: fix file path handling when pasting images with spaces in filename
- Fixes issue where files with spaces in their names couldn't be pasted as images
- Prevents default paste behavior to avoid conflicts with image insertion
- Improves error handling for file reading operations
2025-11-02 13:45:44 -05:00
Dax Raad 3f02eecf22 tui: add /timeline command to quickly navigate to specific messages in session history 2025-11-02 18:27:42 +00:00
opencode f5ca78ed7b release: v1.0.12 2025-11-02 18:27:42 +00:00
Dax Raad 894cbaa51e fix duplicate plugin subscriptions 2025-11-02 13:22:58 -05:00
John Eismeier 8b70b89fde fix: typos (#3757)
Signed-off-by: John E <jeis4wpi@outlook.com>
2025-11-02 09:56:40 -06:00
Aditya Mathur f9dbc586dc chore: update hono-openapi to version 1.1.1 (#3738) 2025-11-02 09:21:55 -06:00
GitHub Action ffeef63ca1 ignore: update download stats 2025-11-02 2025-11-02 12:04:05 +00:00
kcrommett 4da58294d9 add nightowl theme back after opentui release (#3732) 2025-11-02 04:29:14 -05:00
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]opencode-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
Dax 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ć 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
Daniel van Strienandcélina 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ć 0170577743 feat: tui: Add --prompt option (#3668) 2025-11-01 11:18:31 -04:00
Giuseppe Rota 7de6ea5922 fix: fix typo in commit message guidelines (#3702) 2025-11-01 10:14:53 -05:00
Yuku Kotani 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ć 55787f2caa fix: tui: Handle Clipboard.copy errors properly (#3685) 2025-11-01 15:34:21 +01:00
Haris Gušić 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 Cline 041353f4ff make /init a default slash command on server side (#3677) 2025-11-01 01:14:09 -05:00
Haris Gušić c72f8b17c6 fix: tui: Fix /editor command (#3663) 2025-11-01 00:16:06 +00:00
opencode eb304f4115 release: v1.0.7 2025-11-01 00:16:05 +00:00
Dax Raad 5565f14ef5 tab to accept autocomplete 2025-10-31 20:10:01 -04:00
Dax Raad 10a4455c6f tui: fix prompt text aggregation to exclude synthetic content 2025-10-31 20:01:27 -04:00
Dax Raad 5ded6d6ad7 docs: sync 2025-10-31 23:58:57 +00:00
opencode 849a38c30c release: v1.0.6 2025-10-31 23:58:57 +00:00
Dax Raad 68050ab802 tui: prevent clipboard operations from throwing errors on process exit 2025-10-31 19:54:15 -04:00
opencode 91d01fd4cc release: v1.0.5 2025-10-31 23:51:36 +00:00
Dax Raad 9beb0f8512 tui: improve keyboard navigation and MCP server status display 2025-10-31 19:47:08 -04:00
Dax Raad d4cb47eadc tui: add keyboard shortcuts to cycle through recently used models
Users can now press F2 to cycle forward and Shift+F2 to cycle backward through their recently used models, making it faster to switch between commonly used AI models without opening the model selection dialog.
2025-10-31 19:42:41 -04:00
Dax Raad 261ff416a9 sync 2025-10-31 23:05:11 +00:00
opencode d0a70cb217 release: v1.0.4 2025-10-31 23:05:10 +00:00
Aiden Cline 20fc56d020 Revert "opentui: fix: Make worker.ts path independent from cwd (#3600)"
This reverts commit d473d4ffc8.
2025-10-31 17:57:56 -05:00
opencode a57ae3ec93 release: v1.0.3 2025-10-31 22:52:57 +00:00
Dax Raad 30f9fa12d9 tui: add session rename functionality with /rename command
- Add /rename command to autocomplete when a session is active
- Add rename dialog component for changing session names
- Add rename option to session list dialog with 'r' keybind
- Add session rename command to command registry
2025-10-31 18:44:33 -04:00
Haris Gušić d473d4ffc8 opentui: fix: Make worker.ts path independent from cwd (#3600) 2025-10-31 17:37:31 -05:00
Haris Gušić af50596529 fix: grep failing when pattern started with a dash 2025-10-31 17:20:22 -05:00
Dax Raad 3823d8d50e tui: simplify theme selection API by renaming setSelectedTheme to set 2025-10-31 18:11:36 -04:00
Dax Raad 7a926b32ce respect theme in config 2025-10-31 18:04:38 -04:00
Haris Gušić a5ede68241 fix: Remove conflicting "-h" aliases in TUI spawn and thread commands (#3651) 2025-10-31 16:59:59 -05:00
Aiden Cline 60dc38050d fix: unsupported option 2025-10-31 16:53:08 -05:00
Dax Raad 31d0caee38 tui: add /editor command to autocomplete for opening external editor 2025-10-31 17:47:08 -04:00
Dax Raad 2a7ab45605 add /theme 2025-10-31 17:44:41 -04:00
Aiden Cline 019054dd1e Revert "fix: ensure flags & docs match (#3638)"
This reverts commit a018a15f32.
2025-10-31 16:43:29 -05:00
opencode-agent[bot]opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
a018a15f32 fix: ensure flags & docs match (#3638)
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-10-31 16:33:46 -05:00
Nathan Thomas e630d680dd feat: allow ctrl+d to exit the app (#3636) 2025-10-31 16:27:41 -05:00
Haris Gušić 9e392f25a6 feat: Improve error boundary add button to easily create issue in github (#3639) 2025-10-31 16:20:32 -05:00
Aiden Cline 2cc4e6ad7c ci: change model 2025-10-31 16:06:22 -05:00
Adam 70d8d1ab1e wip: desktop work 2025-10-31 15:57:21 -05:00
Adam 342aa27e03 wip: desktop work 2025-10-31 15:37:50 -05:00
Adam e1aed0cd01 wip: desktop work 2025-10-31 15:37:50 -05:00
81 changed files with 2186 additions and 957 deletions
+2
View File
@@ -5,6 +5,8 @@ runs:
steps: steps:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
- name: Cache ~/.bun - name: Cache ~/.bun
id: cache-bun id: cache-bun
+1 -8
View File
@@ -21,7 +21,7 @@ jobs:
const description = issue.body || ''; const description = issue.body || '';
// Check for version patterns like v1.0.x or 1.0.x // 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)) { if (versionPattern.test(title) || versionPattern.test(description)) {
await github.rest.issues.addLabels({ await github.rest.issues.addLabels({
@@ -30,11 +30,4 @@ jobs:
issue_number: issue.number, issue_number: issue.number,
labels: ['opentui'] 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
@@ -26,4 +26,4 @@ jobs:
env: env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with: with:
model: opencode/kimi-k2 model: opencode/glm-4.6
+1
View File
@@ -5,6 +5,7 @@ node_modules
.env .env
.idea .idea
.vscode .vscode
*~
openapi.json openapi.json
playground playground
tmp tmp
+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 prefer to explain WHY something was done from an end user perspective instead of
WHAT was done. 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 about what user facing changes were made
+1 -1
View File
@@ -17,7 +17,7 @@
## Tool Calling ## Tool Calling
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environnement: - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environment:
json json
{ {
+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 ## 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: - Install dependencies and start the dev server from the repo root:
```bash ```bash
@@ -36,11 +36,11 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
- Core pieces: - Core pieces:
- `packages/opencode`: OpenCode core business logic & server. - `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` - `packages/plugin`: Source for `@opencode-ai/plugin`
> [!NOTE] > [!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 ## Pull Request Expectations
+2
View File
@@ -126,3 +126,5 @@
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) | | 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-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-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) |
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
+23 -23
View File
@@ -39,7 +39,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.0.2", "version": "1.0.13",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -66,7 +66,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.0.2", "version": "1.0.13",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -90,7 +90,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.0.2", "version": "1.0.13",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -111,7 +111,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.0.2", "version": "1.0.13",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -150,7 +150,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.0.2", "version": "1.0.13",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
@@ -166,7 +166,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.0.2", "version": "1.0.13",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -184,8 +184,8 @@
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.0.0-20251031-fc297165", "@opentui/core": "0.1.32",
"@opentui/solid": "0.0.0-20251031-fc297165", "@opentui/solid": "0.1.32",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:", "@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -199,7 +199,7 @@
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
"hono": "catalog:", "hono": "catalog:",
"hono-openapi": "1.0.7", "hono-openapi": "1.1.1",
"ignore": "7.0.5", "ignore": "7.0.5",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"minimatch": "10.0.3", "minimatch": "10.0.3",
@@ -243,7 +243,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.0.2", "version": "1.0.13",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -263,7 +263,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.0.2", "version": "1.0.13",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.81.0", "@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -274,7 +274,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.0.2", "version": "1.0.13",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -287,7 +287,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.0.2", "version": "1.0.13",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -317,7 +317,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.0.2", "version": "1.0.13",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -961,21 +961,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@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=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2221,7 +2221,7 @@
"hono": ["hono@4.7.10", "", {}, "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ=="], "hono": ["hono@4.7.10", "", {}, "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ=="],
"hono-openapi": ["hono-openapi@1.0.7", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.1", "@standard-community/standard-openapi": "^0.2.4", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-rMn+nn4/HMisyi549L3zT7WCmVvmpiKsyt790GcGfqvJf9mJfhq6txw09l0IhSBxpJpA0pXVKxFijcsnGfshUA=="], "hono-openapi": ["hono-openapi@1.1.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="],
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
+45 -15
View File
@@ -168,7 +168,9 @@ try {
const summary = await summarize(response) const summary = await summarize(response)
await pushToLocalBranch(summary) 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 })}`) await updateComment(`${response}${footer({ image: !hasShared })}`)
} }
// Fork PR // Fork PR
@@ -180,7 +182,9 @@ try {
const summary = await summarize(response) const summary = await summarize(response)
await pushToForkBranch(summary, prData) 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 })}`) await updateComment(`${response}${footer({ image: !hasShared })}`)
} }
} }
@@ -361,7 +365,9 @@ async function getAccessToken() {
if (!response.ok) { if (!response.ok) {
const responseJson = (await response.json()) as { error?: string } 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 } 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. <img alt="Image" src="https://github.com/user-attachments/assets/xxxx" />
// ie. [api.json](https://github.com/user-attachments/files/21433810/api.json) // ie. [api.json](https://github.com/user-attachments/files/21433810/api.json)
// ie. ![Image](https://github.com/user-attachments/assets/xxxx) // ie. ![Image](https://github.com/user-attachments/assets/xxxx)
const mdMatches = prompt.matchAll(/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi) const mdMatches = prompt.matchAll(
const tagMatches = prompt.matchAll(/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi) /!?\[.*?\]\((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) const matches = [...mdMatches, ...tagMatches].sort((a, b) => a.index - b.index)
console.log("Images", JSON.stringify(matches, null, 2)) console.log("Images", JSON.stringify(matches, null, 2))
@@ -430,7 +440,8 @@ async function getUserPrompt() {
// Replace img tag with file path, ie. @image.png // Replace img tag with file path, ie. @image.png
const replacement = `@${filename}` 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 offset += replacement.length - tag.length
const contentType = res.headers.get("content-type") const contentType = res.headers.get("content-type")
@@ -498,7 +509,12 @@ async function subscribeSessionEvents() {
? JSON.stringify(part.state.input) ? JSON.stringify(part.state.input)
: "Unknown" : "Unknown"
console.log() 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") { if (part.type === "text") {
@@ -710,7 +726,8 @@ async function assertPermissions() {
throw new Error(`Failed to check permissions for user ${actor}: ${error}`) 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) { 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) { async function createPR(base: string, branch: string, title: string, body: string) {
console.log("Creating pull request...") console.log("Creating pull request...")
const { repo } = useContext() const { repo } = useContext()
const truncatedTitle = title.length > 256 ? title.slice(0, 253) + "..." : title
const pr = await octoRest.rest.pulls.create({ const pr = await octoRest.rest.pulls.create({
owner: repo.owner, owner: repo.owner,
repo: repo.repo, repo: repo.repo,
head: branch, head: branch,
base, base,
title, title: truncatedTitle,
body, body,
}) })
return pr.data.number 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` 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()})` 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}`) .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 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 [ return [
`- ${r.author.login} at ${r.submittedAt}:`, `- ${r.author.login} at ${r.submittedAt}:`,
` - Review body: ${r.body}`, ` - Review body: ${r.body}`,
@@ -960,9 +984,15 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
`Deletions: ${pr.deletions}`, `Deletions: ${pr.deletions}`,
`Total Commits: ${pr.commits.totalCount}`, `Total Commits: ${pr.commits.totalCount}`,
`Changed Files: ${pr.files.nodes.length} files`, `Changed Files: ${pr.files.nodes.length} files`,
...(comments.length > 0 ? ["<pull_request_comments>", ...comments, "</pull_request_comments>"] : []), ...(comments.length > 0
...(files.length > 0 ? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"] : []), ? ["<pull_request_comments>", ...comments, "</pull_request_comments>"]
...(reviewData.length > 0 ? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"] : []), : []),
...(files.length > 0
? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"]
: []),
...(reviewData.length > 0
? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"]
: []),
"</pull_request>", "</pull_request>",
].join("\n") ].join("\n")
} }
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "AI-powered development tool", "description": "AI-powered development tool",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "bun@1.3.0", "packageManager": "bun@1.3.1",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
+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", "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", "build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start", "start": "vinxi start",
"version": "1.0.2" "version": "1.0.13"
}, },
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.0.2", "version": "1.0.13",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.0.2", "version": "1.0.13",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.0.2", "version": "1.0.13",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.0.2", "version": "1.0.13",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -1,34 +1,64 @@
import { For, Match, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { Part } from "@opencode-ai/ui" import { Markdown, Part } from "@opencode-ai/ui"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import type { AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk" import type { AssistantMessage as AssistantMessageType, Part as PartType, ToolPart } from "@opencode-ai/sdk"
import { Spinner } from "./spinner"
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[] }) { export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
const sync = useSync() const sync = useSync()
const items = createMemo(() => props.assistantMessages().flatMap((m) => sync.data.part[m.id])) const parts = createMemo(() => props.assistantMessages().flatMap((m) => sync.data.part[m.id]))
const done = createMemo(() => props.done ?? false)
const finishedItems = createMemo(() => [ const currentTask = createMemo(
"", () =>
"", parts().findLast(
"Loading...",
...items().filter(
(p) => (p) =>
p?.type === "text" || p &&
(p?.type === "reasoning" && p.time?.end) || p.type === "tool" &&
(p?.type === "tool" && p.state.status === "completed"), p.tool === "task" &&
), p.state &&
"", "metadata" in p.state &&
p.state.metadata &&
p.state.metadata.sessionId &&
p.state.status === "running",
) as ToolPart,
)
const resolvedParts = createMemo(() => {
let resolved = parts()
const task = currentTask()
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
const messages = sync.data.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant")
resolved = messages?.flatMap((m) => sync.data.part[m.id]) ?? parts()
}
return resolved
})
const currentText = createMemo(
() =>
resolvedParts().findLast((p) => p?.type === "text")?.text ||
resolvedParts().findLast((p) => p?.type === "reasoning")?.text,
)
const eligibleItems = createMemo(() => {
return resolvedParts().filter((p) => p?.type === "tool" && p.state.status === "completed")
})
const finishedItems = createMemo<(JSXElement | PartType)[]>(() => [
<div class="h-8 w-full" />,
<div class="h-8 w-full" />,
<div class="flex items-center gap-x-5 pl-3 text-text-base">
<Spinner /> <span class="text-12-medium">Thinking...</span>
</div>,
...eligibleItems(),
...(done() ? [<div class="h-8 w-full" />, <div class="h-8 w-full" />, <div class="h-8 w-full" />] : []),
]) ])
const MINIMUM_DELAY = 400 const delay = createMemo(() => (done() ? 220 : 400))
const [visibleCount, setVisibleCount] = createSignal(1) const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length)
createEffect(() => { createEffect(() => {
const total = finishedItems().length const total = finishedItems().length
if (total > visibleCount()) { if (total > visibleCount()) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setVisibleCount((prev) => prev + 1) setVisibleCount((prev) => prev + 1)
}, MINIMUM_DELAY) }, delay())
onCleanup(() => clearTimeout(timer)) onCleanup(() => clearTimeout(timer))
} else if (total < visibleCount()) { } else if (total < visibleCount()) {
setVisibleCount(total) setVisibleCount(total)
@@ -42,10 +72,11 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
}) })
return ( return (
<div class="flex flex-col gap-3">
<div <div
class="h-30 overflow-hidden pointer-events-none class="h-30 overflow-hidden pointer-events-none pb-1
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
mask-b-from-90% mask-b-from-background-base mask-b-to-transparent" mask-b-from-95% mask-b-from-background-base mask-b-to-transparent"
> >
<div <div
class="w-full flex flex-col items-start self-stretch gap-2 py-8 class="w-full flex flex-col items-start self-stretch gap-2 py-8
@@ -54,7 +85,7 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
> >
<For each={finishedItems()}> <For each={finishedItems()}>
{(part) => { {(part) => {
if (typeof part === "string") return <div class="h-8 flex items-center w-full">{part}</div> if (part && typeof part === "object" && "type" in part) {
const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID)) const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID))
return ( return (
<div class="h-8 flex items-center w-full"> <div class="h-8 flex items-center w-full">
@@ -70,13 +101,28 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
<Match when={part.type === "reasoning" && part}> <Match when={part.type === "reasoning" && part}>
{(p) => <Part message={message()!} part={p()} />} {(p) => <Part message={message()!} part={p()} />}
</Match> </Match>
<Match when={part.type === "tool" && part}>{(p) => <Part message={message()!} part={p()} />}</Match> <Match when={part.type === "tool" && part}>
{(p) => <Part message={message()!} part={p()} />}
</Match>
</Switch> </Switch>
</div> </div>
) )
}
return <div class="h-8 flex items-center w-full">{part}</div>
}} }}
</For> </For>
</div> </div>
</div> </div>
<Show when={currentText()}>
{(text) => (
<div
class="max-h-36 flex flex-col justify-end overflow-hidden py-3
mask-alpha mask-t-from-80% mask-t-from-background-base mask-t-to-transparent"
>
<Markdown text={text()} class="w-full shrink-0 overflow-visible" />
</div>
)}
</Show>
</div>
) )
} }
@@ -334,7 +334,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSubmit={handleSubmit} onSubmit={handleSubmit}
classList={{ classList={{
"bg-surface-raised-stronger-non-alpha border border-border-strong-base": true, "bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
"rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true, "rounded-2xl overflow-clip focus-within:border-transparent focus-within:shadow-xs-border-select": true,
[props.class ?? ""]: !!props.class, [props.class ?? ""]: !!props.class,
}} }}
> >
+22
View File
@@ -162,10 +162,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const recent = createMemo(() => store.recent.map(find).filter(Boolean)) const recent = createMemo(() => store.recent.map(find).filter(Boolean))
const cycle = (direction: 1 | -1) => {
const recentList = recent()
const current = currentModel()
if (!current) return
const index = recentList.findIndex((x) => x?.provider.id === current.provider.id && x?.id === current.id)
if (index === -1) return
let next = index + direction
if (next < 0) next = recentList.length - 1
if (next >= recentList.length) next = 0
const val = recentList[next]
if (!val) return
model.set({
providerID: val.provider.id,
modelID: val.id,
})
}
return { return {
current: currentModel, current: currentModel,
recent, recent,
list, list,
cycle,
set(model: ModelKey | undefined, options?: { recent?: boolean }) { set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => { batch(() => {
setStore("model", agent.current().name, model ?? fallbackModel()) setStore("model", agent.current().name, model ?? fallbackModel())
+114 -16
View File
@@ -9,7 +9,6 @@ import {
Accordion, Accordion,
Diff, Diff,
Collapsible, Collapsible,
Part,
DiffChanges, DiffChanges,
ProgressCircle, ProgressCircle,
Message, Message,
@@ -548,7 +547,79 @@ export default function Page() {
<For each={local.session.userMessages()}> <For each={local.session.userMessages()}>
{(message) => { {(message) => {
const diffs = createMemo(() => message.summary?.diffs ?? []) const diffs = createMemo(() => message.summary?.diffs ?? [])
const working = createMemo(() => !message.summary?.title) const working = createMemo(() => !message.summary?.body)
const assistantMessages = createMemo(() => {
return sync.data.message[activeSession().id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const parts = createMemo(() =>
assistantMessages().flatMap((m) => sync.data.part[m.id]),
)
const lastPart = createMemo(() => parts().slice(-1)?.at(0))
const rawStatus = createMemo(() => {
const defaultStatus = "Working..."
const last = lastPart()
if (!last) return defaultStatus
if (last.type === "tool") {
switch (last.tool) {
case "task":
return "Delegating work..."
case "todowrite":
case "todoread":
return "Planning next steps..."
case "read":
return "Gathering context..."
case "list":
case "grep":
case "glob":
return "Searching the codebase..."
case "webfetch":
return "Searching the web..."
case "edit":
case "write":
return "Making edits..."
case "bash":
return "Running commands..."
default:
break
}
} else if (last.type === "reasoning") {
return "Thinking..."
} else if (last.type === "text") {
return "Gathering thoughts..."
}
return defaultStatus
})
const [status, setStatus] = createSignal(rawStatus())
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === status()) return
const timeSinceLastChange = Date.now() - lastStatusChange
if (timeSinceLastChange >= 1000) {
setStatus(newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
setStatus(rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 1000 - timeSinceLastChange) as unknown as number
}
})
return ( return (
<li class="group/li flex items-center self-stretch"> <li class="group/li flex items-center self-stretch">
<button <button
@@ -570,7 +641,10 @@ export default function Page() {
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true, "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}} }}
> >
{message.summary?.title ?? local.session.getMessageText(message)} <Switch>
<Match when={working()}>{status()}</Match>
<Match when={true}>{message.summary?.title}</Match>
</Switch>
</div> </div>
</button> </button>
</li> </li>
@@ -583,7 +657,8 @@ export default function Page() {
<For each={local.session.userMessages()}> <For each={local.session.userMessages()}>
{(message) => { {(message) => {
const isActive = createMemo(() => local.session.activeMessage()?.id === message.id) const isActive = createMemo(() => local.session.activeMessage()?.id === message.id)
const [initialized, setInitialized] = createSignal(!!message.summary?.title) const [titled, setTitled] = createSignal(!!message.summary?.title)
const [completed, setCompleted] = createSignal(!!message.summary?.body)
const [expanded, setExpanded] = createSignal(false) const [expanded, setExpanded] = createSignal(false)
const parts = createMemo(() => sync.data.part[message.id]) const parts = createMemo(() => sync.data.part[message.id])
const title = createMemo(() => message.summary?.title) const title = createMemo(() => message.summary?.title)
@@ -597,11 +672,18 @@ export default function Page() {
const hasToolPart = createMemo(() => const hasToolPart = createMemo(() =>
assistantMessages() assistantMessages()
?.flatMap((m) => sync.data.part[m.id]) ?.flatMap((m) => sync.data.part[m.id])
.some((p) => p.type === "tool"), .some((p) => p?.type === "tool"),
) )
const working = createMemo(() => !summary()) const working = createMemo(() => !summary())
// allowing time for the animations to finish
createEffect(() => { createEffect(() => {
setTimeout(() => setInitialized(!!title()), 10_000) title()
setTimeout(() => setTitled(!!title()), 10_000)
})
createEffect(() => {
summary()
setTimeout(() => setCompleted(!!summary()), 1200)
}) })
return ( return (
@@ -612,9 +694,18 @@ export default function Page() {
> >
{/* Title */} {/* Title */}
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10"> <div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
<div class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0"> <div class="w-full text-14-medium text-text-strong">
<Show when={initialized()} fallback={<Typewriter as="h1" text={title()} />}> <Show
<h1>{title()}</h1> when={titled()}
fallback={
<Typewriter
as="h1"
text={title()}
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
/>
}
>
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">{title()}</h1>
</Show> </Show>
</div> </div>
</div> </div>
@@ -622,7 +713,7 @@ export default function Page() {
<Message message={message} parts={parts()} /> <Message message={message} parts={parts()} />
</div> </div>
{/* Summary */} {/* Summary */}
<Show when={!working()}> <Show when={completed()}>
<div class="w-full flex flex-col gap-6 items-start self-stretch"> <div class="w-full flex flex-col gap-6 items-start self-stretch">
<div class="flex flex-col items-start gap-1 self-stretch"> <div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak"> <h2 class="text-12-medium text-text-weak">
@@ -631,7 +722,14 @@ export default function Page() {
<Match when={true}>Response</Match> <Match when={true}>Response</Match>
</Switch> </Switch>
</h2> </h2>
<Show when={summary()}>{(summary) => <Markdown text={summary()} />}</Show> <Show when={summary()}>
{(summary) => (
<Markdown
classList={{ "[&>*]:fade-up-text": !diffs().length }}
text={summary()}
/>
)}
</Show>
</div> </div>
<Accordion class="w-full" multiple> <Accordion class="w-full" multiple>
<For each={diffs()}> <For each={diffs()}>
@@ -684,17 +782,17 @@ export default function Page() {
{/* Response */} {/* Response */}
<div class="w-full"> <div class="w-full">
<Switch> <Switch>
<Match when={working()}> <Match when={!completed()}>
<MessageProgress assistantMessages={assistantMessages} /> <MessageProgress assistantMessages={assistantMessages} done={!working()} />
</Match> </Match>
<Match when={!working() && hasToolPart()}> <Match when={completed() && hasToolPart()}>
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}> <Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
<Collapsible.Trigger class="text-text-weak hover:text-text-strong"> <Collapsible.Trigger class="text-text-weak hover:text-text-strong">
<div class="flex items-center gap-1 self-stretch"> <div class="flex items-center gap-1 self-stretch">
<div class="text-12-medium"> <div class="text-12-medium">
<Switch> <Switch>
<Match when={expanded()}>Hide steps</Match> <Match when={expanded()}>Hide details</Match>
<Match when={!expanded()}>Show steps</Match> <Match when={!expanded()}>Show details</Match>
</Switch> </Switch>
</div> </div>
<Collapsible.Arrow /> <Collapsible.Arrow />
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.0.2", "version": "1.0.13",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.0.2", "version": "1.0.13",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -54,8 +54,8 @@
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.0.0-20251031-fc297165", "@opentui/core": "0.1.32",
"@opentui/solid": "0.0.0-20251031-fc297165", "@opentui/solid": "0.1.32",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"@pierre/precision-diffs": "catalog:", "@pierre/precision-diffs": "catalog:",
@@ -69,7 +69,7 @@
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
"hono": "catalog:", "hono": "catalog:",
"hono-openapi": "1.0.7", "hono-openapi": "1.1.1",
"ignore": "7.0.5", "ignore": "7.0.5",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"minimatch": "10.0.3", "minimatch": "10.0.3",
+18 -22
View File
@@ -28,8 +28,6 @@ import { Storage } from "@/storage/storage"
import { Command } from "@/command" import { Command } from "@/command"
import { Agent as Agents } from "@/agent/agent" import { Agent as Agents } from "@/agent/agent"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { Session } from "@/session"
import { Identifier } from "@/id/id"
import { SessionCompaction } from "@/session/compaction" import { SessionCompaction } from "@/session/compaction"
import type { Config } from "@/config/config" import type { Config } from "@/config/config"
import { MCP } from "@/mcp" import { MCP } from "@/mcp"
@@ -89,7 +87,11 @@ export namespace ACP {
}) })
if (!res) return if (!res) return
if (res.outcome.outcome !== "selected") { 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 return
} }
Permission.respond({ Permission.respond({
@@ -111,9 +113,11 @@ export namespace ACP {
const acpSession = this.sessionManager.get(part.sessionID) const acpSession = this.sessionManager.get(part.sessionID)
if (!acpSession) return if (!acpSession) return
const message = await Storage.read<MessageV2.Info>(["message", part.sessionID, part.messageID]).catch( const message = await Storage.read<MessageV2.Info>([
() => undefined, "message",
) part.sessionID,
part.messageID,
]).catch(() => undefined)
if (!message || message.role !== "assistant") return if (!message || message.role !== "assistant") return
if (part.type === "tool") { if (part.type === "tool") {
@@ -192,7 +196,9 @@ export namespace ACP {
sessionUpdate: "plan", sessionUpdate: "plan",
entries: parsedTodos.data.map((todo) => { entries: parsedTodos.data.map((todo) => {
const status: PlanEntry["status"] = const status: PlanEntry["status"] =
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) todo.status === "cancelled"
? "completed"
: (todo.status as PlanEntry["status"])
return { return {
priority: "medium", priority: "medium",
status, status,
@@ -375,11 +381,6 @@ export namespace ACP {
description: command.description ?? "", description: command.description ?? "",
})) }))
const names = new Set(availableCommands.map((c) => c.name)) 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")) if (!names.has("compact"))
availableCommands.push({ availableCommands.push({
name: "compact", name: "compact",
@@ -404,7 +405,8 @@ export namespace ACP {
description: agent.description, 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> = {} const mcpServers: Record<string, Config.Mcp> = {}
for (const server of params.mcpServers) { for (const server of params.mcpServers) {
@@ -585,14 +587,6 @@ export namespace ACP {
} }
switch (cmd.name) { switch (cmd.name) {
case "init":
await Session.initialize({
sessionID,
messageID: Identifier.ascending("message"),
providerID: model.providerID,
modelID: model.modelID,
})
break
case "compact": case "compact":
await SessionCompaction.run({ await SessionCompaction.run({
sessionID, sessionID,
@@ -665,7 +659,9 @@ export namespace ACP {
function parseUri( function parseUri(
uri: string, 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 { try {
if (uri.startsWith("file://")) { if (uri.startsWith("file://")) {
const path = uri.slice(7) const path = uri.slice(7)
-1
View File
@@ -12,7 +12,6 @@ export const ServeCommand = cmd({
default: 0, default: 0,
}) })
.option("hostname", { .option("hostname", {
alias: ["h"],
type: "string", type: "string",
describe: "hostname to listen on", describe: "hostname to listen on",
default: "127.0.0.1", default: "127.0.0.1",
-1
View File
@@ -65,7 +65,6 @@ export const TuiCommand = cmd({
default: 0, default: 0,
}) })
.option("hostname", { .option("hostname", {
alias: ["h"],
type: "string", type: "string",
describe: "hostname to listen on", describe: "hostname to listen on",
default: "127.0.0.1", default: "127.0.0.1",
+169 -10
View File
@@ -23,21 +23,81 @@ import { Session } from "@tui/routes/session"
import { PromptHistoryProvider } from "./component/prompt/history" import { PromptHistoryProvider } from "./component/prompt/history"
import { DialogAlert } from "./ui/dialog-alert" import { DialogAlert } from "./ui/dialog-alert"
import { ToastProvider, useToast } from "./ui/toast" import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider } from "./context/exit" import { ExitProvider, useExit } from "./context/exit"
import type { SessionRoute } from "./context/route" import type { SessionRoute } from "./context/route"
import { Session as SessionApi } from "@/session" import { Session as SessionApi } from "@/session"
import { TuiEvent } from "./event" import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv" 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: { export function tui(input: {
url: string url: string
sessionID?: string sessionID?: string
model?: string model?: string
agent?: string agent?: string
prompt?: string
onExit?: () => Promise<void> onExit?: () => Promise<void>
}) { }) {
// promise to prevent immediate exit // 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 const routeData: Route | undefined = input.sessionID
? { ? {
type: "session", type: "session",
@@ -53,15 +113,23 @@ export function tui(input: {
render( render(
() => { () => {
return ( return (
<ErrorBoundary fallback={<text>Something went wrong</text>}> <ErrorBoundary
fallback={(error, reset) => (
<ErrorComponent error={error} reset={reset} onExit={onExit} />
)}
>
<ExitProvider onExit={onExit}> <ExitProvider onExit={onExit}>
<KVProvider> <KVProvider>
<ToastProvider> <ToastProvider>
<RouteProvider data={routeData}> <RouteProvider data={routeData}>
<SDKProvider url={input.url}> <SDKProvider url={input.url}>
<SyncProvider> <SyncProvider>
<ThemeProvider> <ThemeProvider mode={mode}>
<LocalProvider initialModel={input.model} initialAgent={input.agent}> <LocalProvider
initialModel={input.model}
initialAgent={input.agent}
initialPrompt={input.prompt}
>
<KeybindProvider> <KeybindProvider>
<DialogProvider> <DialogProvider>
<CommandProvider> <CommandProvider>
@@ -86,6 +154,7 @@ export function tui(input: {
targetFps: 60, targetFps: 60,
gatherStats: false, gatherStats: false,
exitOnCtrlC: false, exitOnCtrlC: false,
useKittyKeyboard: true,
}, },
) )
}) })
@@ -104,7 +173,8 @@ function App() {
const sync = useSync() const sync = useSync()
const toast = useToast() const toast = useToast()
const [sessionExists, setSessionExists] = createSignal(false) const [sessionExists, setSessionExists] = createSignal(false)
const { theme } = useTheme() const { theme, mode, setMode } = useTheme()
const exit = useExit()
useKeyboard(async (evt) => { useKeyboard(async (evt) => {
if (evt.meta && evt.name === "t") { if (evt.meta && evt.name === "t") {
@@ -168,6 +238,24 @@ function App() {
dialog.replace(() => <DialogModel />) dialog.replace(() => <DialogModel />)
}, },
}, },
{
title: "Model cycle",
value: "model.cycle_recent",
keybind: "model_cycle_recent",
category: "Agent",
onSelect: () => {
local.model.cycle(1)
},
},
{
title: "Model cycle reverse",
value: "model.cycle_recent_reverse",
keybind: "model_cycle_recent_reverse",
category: "Agent",
onSelect: () => {
local.model.cycle(-1)
},
},
{ {
title: "Switch agent", title: "Switch agent",
value: "agent.list", value: "agent.list",
@@ -214,6 +302,14 @@ function App() {
}, },
category: "System", category: "System",
}, },
{
title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
value: "theme.switch_mode",
onSelect: () => {
setMode(mode() === "dark" ? "light" : "dark")
},
category: "System",
},
{ {
title: "Help", title: "Help",
value: "help.show", value: "help.show",
@@ -222,11 +318,17 @@ function App() {
}, },
category: "System", category: "System",
}, },
{
title: "Exit the app",
value: "app.exit",
onSelect: exit,
category: "System",
},
]) ])
createEffect(() => { createEffect(() => {
const providerID = local.model.current().providerID const providerID = local.model.current().providerID
if (providerID === "openrouter" && !kv.data.openrouter_warning) { if (providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
untrack(() => { untrack(() => {
DialogAlert.show( DialogAlert.show(
dialog, dialog,
@@ -274,8 +376,9 @@ function App() {
/* @ts-expect-error */ /* @ts-expect-error */
renderer.writeOut(finalOsc52) renderer.writeOut(finalOsc52)
await Clipboard.copy(text) await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection() renderer.clearSelection()
toast.show({ message: "Copied to clipboard", variant: "info" })
} }
}} }}
> >
@@ -304,7 +407,9 @@ function App() {
paddingRight={1} paddingRight={1}
> >
<text fg={theme.textMuted}>open</text> <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> <text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box> </box>
<box paddingLeft={1} paddingRight={1}> <box paddingLeft={1} paddingRight={1}>
@@ -319,7 +424,7 @@ function App() {
<text <text
bg={local.agent.color(local.agent.current().name)} bg={local.agent.color(local.agent.current().name)}
fg={theme.background} fg={theme.background}
wrapMode="none" wrapMode={undefined}
> >
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span> <span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span> <span> AGENT </span>
@@ -329,3 +434,57 @@ function App() {
</box> </box>
) )
} }
function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => Promise<void> }) {
const term = useTerminalDimensions()
useKeyboard((evt) => {
if (evt.ctrl && evt.name === "c") {
props.onExit()
}
})
const [copied, setCopied] = createSignal(false)
const issueURL = new URL("https://github.com/sst/opencode/issues/new?template=bug-report.yml")
if (props.error.message) {
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
}
if (props.error.stack) {
issueURL.searchParams.set(
"description",
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
)
}
const copyIssueURL = () => {
Clipboard.copy(issueURL.toString()).then(() => {
setCopied(true)
})
}
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text attributes={TextAttributes.BOLD}>Please report an issue.</text>
<box onMouseUp={copyIssueURL} backgroundColor="#565f89" padding={1}>
<text attributes={TextAttributes.BOLD}>Copy issue URL (exception info pre-filled)</text>
</box>
{copied() && <text>Successfully copied</text>}
</box>
<box flexDirection="row" gap={2} alignItems="center">
<text>A fatal error occurred!</text>
<box onMouseUp={props.reset} backgroundColor="#565f89" padding={1}>
<text>Reset TUI</text>
</box>
<box onMouseUp={props.onExit} backgroundColor="#565f89" padding={1}>
<text>Exit</text>
</box>
</box>
<scrollbox height={Math.floor(term().height * 0.7)}>
<text>{props.error.stack}</text>
</scrollbox>
<text>{props.error.message}</text>
</box>
)
}
@@ -28,7 +28,9 @@ function init() {
return registrations().flatMap((x) => x()) return registrations().flatMap((x) => x())
}) })
let keybinds = true
useKeyboard((evt) => { useKeyboard((evt) => {
if (!keybinds) return
for (const option of options()) { for (const option of options()) {
if (option.keybind && keybind.match(option.keybind, evt)) { if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault() evt.preventDefault()
@@ -39,14 +41,20 @@ function init() {
}) })
const result = { const result = {
trigger(name: string) { trigger(name: string, source?: "prompt") {
for (const option of options()) { for (const option of options()) {
if (option.value === name) { if (option.value === name) {
option.onSelect?.(dialog) option.onSelect?.(dialog, source)
return return
} }
} }
}, },
keybinds(enabled: boolean) {
keybinds = enabled
},
show() {
dialog.replace(() => <DialogCommand options={options()} />)
},
register(cb: () => CommandOption[]) { register(cb: () => CommandOption[]) {
const results = createMemo(cb) const results = createMemo(cb)
setRegistrations((arr) => [results, ...arr]) setRegistrations((arr) => [results, ...arr])
@@ -75,7 +83,7 @@ export function CommandProvider(props: ParentProps) {
const keybind = useKeybind() const keybind = useKeybind()
useKeyboard((evt) => { useKeyboard((evt) => {
if (keybind.match("command_list", evt)) { if (keybind.match("command_list", evt) && dialog.stack.length === 0) {
evt.preventDefault() evt.preventDefault()
dialog.replace(() => <DialogCommand options={value.options} />) dialog.replace(() => <DialogCommand options={value.options} />)
return return
@@ -90,7 +98,10 @@ function DialogCommand(props: { options: CommandOption[] }) {
return ( return (
<DialogSelect <DialogSelect
title="Commands" title="Commands"
options={props.options.map((x) => ({ ...x, footer: x.keybind ? keybind.print(x.keybind) : undefined }))} options={props.options.map((x) => ({
...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined,
}))}
/> />
) )
} }
@@ -7,6 +7,7 @@ import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind" import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename"
export function DialogSessionList() { export function DialogSessionList() {
const dialog = useDialog() const dialog = useDialog()
@@ -59,7 +60,7 @@ export function DialogSessionList() {
}} }}
keybind={[ keybind={[
{ {
keybind: Keybind.parse("delete")[0], keybind: Keybind.parse("ctrl+d")[0],
title: "delete", title: "delete",
onTrigger: async (option) => { onTrigger: async (option) => {
if (toDelete() === option.value) { if (toDelete() === option.value) {
@@ -74,6 +75,13 @@ export function DialogSessionList() {
setToDelete(option.value) setToDelete(option.value)
}, },
}, },
{
keybind: Keybind.parse("ctrl+r")[0],
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
]} ]}
/> />
) )
@@ -0,0 +1,35 @@
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { useDialog } from "@tui/ui/dialog"
import { useSync } from "@tui/context/sync"
import { createMemo } from "solid-js"
import { useSDK } from "../context/sdk"
interface DialogSessionRenameProps {
session: string
}
export function DialogSessionRename(props: DialogSessionRenameProps) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const session = createMemo(() => sync.session.get(props.session))
return (
<DialogPrompt
title="Rename Session"
value={session()?.title}
onConfirm={(value) => {
sdk.client.session.update({
path: {
id: props.session,
},
body: {
title: value,
},
})
dialog.clear()
}}
onCancel={() => dialog.clear()}
/>
)
}
@@ -1,7 +1,7 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useSync } from "@tui/context/sync" 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 = {} export type DialogStatusProps = {}
@@ -9,15 +9,19 @@ export function DialogStatus() {
const sync = useSync() const sync = useSync()
const { theme } = useTheme() const { theme } = useTheme()
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
return ( return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}> <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between"> <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> <text fg={theme.textMuted}>esc</text>
</box> </box>
<Show when={Object.keys(sync.data.mcp).length > 0}> <Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text>No MCP Servers</text>}>
<box> <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)}> <For each={Object.entries(sync.data.mcp)}>
{([key, item]) => ( {([key, item]) => (
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
@@ -33,7 +37,7 @@ export function DialogStatus() {
> >
</text> </text>
<text wrapMode="word"> <text fg={theme.text} wrapMode="word">
<b>{key}</b>{" "} <b>{key}</b>{" "}
<span style={{ fg: theme.textMuted }}> <span style={{ fg: theme.textMuted }}>
<Switch> <Switch>
@@ -50,7 +54,7 @@ export function DialogStatus() {
</Show> </Show>
{sync.data.lsp.length > 0 && ( {sync.data.lsp.length > 0 && (
<box> <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}> <For each={sync.data.lsp}>
{(item) => ( {(item) => (
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
@@ -65,7 +69,7 @@ export function DialogStatus() {
> >
</text> </text>
<text wrapMode="word"> <text fg={theme.text} wrapMode="word">
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span> <b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
</text> </text>
</box> </box>
@@ -73,6 +77,31 @@ export function DialogStatus() {
</For> </For>
</box> </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> </box>
) )
} }
@@ -12,15 +12,16 @@ export function DialogThemeList() {
const dialog = useDialog() const dialog = useDialog()
let confirmed = false let confirmed = false
let ref: DialogSelectRef<keyof typeof THEMES> let ref: DialogSelectRef<keyof typeof THEMES>
const initial = theme.selectedTheme const initial = theme.selected
onMount(() => { onMount(() => {
// highlight the first theme in the list when we open it for UX // highlight the first theme in the list when we open it for UX
theme.setSelectedTheme(Object.keys(THEMES)[0] as keyof typeof THEMES) theme.set(Object.keys(THEMES)[0] as keyof typeof THEMES)
}) })
onCleanup(() => { onCleanup(() => {
// if we close the dialog without confirming, reset back to the initial theme // if we close the dialog without confirming, reset back to the initial theme
if (!confirmed) theme.setSelectedTheme(initial) if (!confirmed) theme.set(initial)
}) })
return ( return (
@@ -28,10 +29,10 @@ export function DialogThemeList() {
title="Themes" title="Themes"
options={options} options={options}
onMove={(opt) => { onMove={(opt) => {
theme.setSelectedTheme(opt.value) theme.set(opt.value)
}} }}
onSelect={(opt) => { onSelect={(opt) => {
theme.setSelectedTheme(opt.value) theme.set(opt.value)
confirmed = true confirmed = true
dialog.clear() dialog.clear()
}} }}
@@ -40,12 +41,12 @@ export function DialogThemeList() {
}} }}
onFilter={(query) => { onFilter={(query) => {
if (query.length === 0) { if (query.length === 0) {
theme.setSelectedTheme(initial) theme.set(initial)
return return
} }
const first = ref.filtered[0] const first = ref.filtered[0]
if (first) theme.setSelectedTheme(first.value) if (first) theme.set(first.value)
}} }}
/> />
) )
@@ -18,6 +18,7 @@ export type AutocompleteRef = {
export type AutocompleteOption = { export type AutocompleteOption = {
display: string display: string
aliases?: string[]
disabled?: boolean disabled?: boolean
description?: string description?: string
onSelect?: () => void onSelect?: () => void
@@ -207,6 +208,7 @@ export function Autocomplete(props: {
}, },
{ {
display: "/compact", display: "/compact",
aliases: ["/summarize"],
description: "compact the session", description: "compact the session",
onSelect: () => command.trigger("session.compact"), onSelect: () => command.trigger("session.compact"),
}, },
@@ -222,11 +224,22 @@ export function Autocomplete(props: {
description: "unshare a session", description: "unshare a session",
onSelect: () => command.trigger("session.unshare"), onSelect: () => command.trigger("session.unshare"),
}, },
{
display: "/rename",
description: "rename session",
onSelect: () => command.trigger("session.rename"),
},
{
display: "/timeline",
description: "jump to message",
onSelect: () => command.trigger("session.timeline"),
},
) )
} }
results.push( results.push(
{ {
display: "/new", display: "/new",
aliases: ["/clear"],
description: "create a new session", description: "create a new session",
onSelect: () => command.trigger("session.new"), onSelect: () => command.trigger("session.new"),
}, },
@@ -242,6 +255,7 @@ export function Autocomplete(props: {
}, },
{ {
display: "/session", display: "/session",
aliases: ["/resume", "/continue"],
description: "list sessions", description: "list sessions",
onSelect: () => command.trigger("session.list"), onSelect: () => command.trigger("session.list"),
}, },
@@ -250,11 +264,32 @@ export function Autocomplete(props: {
description: "show status", description: "show status",
onSelect: () => command.trigger("opencode.status"), onSelect: () => command.trigger("opencode.status"),
}, },
{
display: "/theme",
description: "toggle theme",
onSelect: () => command.trigger("theme.switch"),
},
{
display: "/editor",
description: "open editor",
onSelect: () => command.trigger("prompt.editor", "prompt"),
},
{ {
display: "/help", display: "/help",
description: "show help", description: "show help",
onSelect: () => command.trigger("help.show"), onSelect: () => command.trigger("help.show"),
}, },
{
display: "/commands",
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 const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
if (!max) return results if (!max) return results
@@ -273,7 +308,7 @@ export function Autocomplete(props: {
const currentFilter = filter() const currentFilter = filter()
if (!currentFilter) return mixed.slice(0, 10) if (!currentFilter) return mixed.slice(0, 10)
const result = fuzzysort.go(currentFilter, mixed, { const result = fuzzysort.go(currentFilter, mixed, {
keys: ["display", "description"], keys: ["display", "description", (obj) => obj.aliases?.join(" ") ?? ""],
limit: 10, limit: 10,
}) })
return result.map((arr) => arr.obj) return result.map((arr) => arr.obj)
@@ -301,6 +336,7 @@ export function Autocomplete(props: {
} }
function show(mode: "@" | "/") { function show(mode: "@" | "/") {
command.keybinds(false)
setStore({ setStore({
visible: mode, visible: mode,
index: props.input().visualCursor.offset, index: props.input().visualCursor.offset,
@@ -318,6 +354,7 @@ export function Autocomplete(props: {
const cursor = props.input().logicalCursor const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col) props.input().deleteRange(0, 0, cursor.row, cursor.col)
} }
command.keybinds(true)
setStore("visible", false) setStore("visible", false)
} }
@@ -9,9 +9,9 @@ import {
dim, dim,
fg, fg,
} from "@opentui/core" } 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 { 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 { SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
@@ -60,7 +60,7 @@ export function Prompt(props: PromptProps) {
const history = usePromptHistory() const history = usePromptHistory()
const command = useCommandDialog() const command = useCommandDialog()
const renderer = useRenderer() const renderer = useRenderer()
const { theme } = useTheme() const { theme, syntax } = useTheme()
const textareaKeybindings = createMemo(() => { const textareaKeybindings = createMemo(() => {
const newlineBindings = keybind.all.input_newline || [] const newlineBindings = keybind.all.input_newline || []
@@ -86,9 +86,9 @@ export function Prompt(props: PromptProps) {
] ]
}) })
const fileStyleId = SyntaxTheme.getStyleId("extmark.file")! const fileStyleId = syntax().getStyleId("extmark.file")!
const agentStyleId = SyntaxTheme.getStyleId("extmark.agent")! const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = SyntaxTheme.getStyleId("extmark.paste")! const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId: number let promptPartTypeId: number
command.register(() => { command.register(() => {
@@ -98,14 +98,9 @@ export function Prompt(props: PromptProps) {
category: "Session", category: "Session",
keybind: "editor_open", keybind: "editor_open",
value: "prompt.editor", value: "prompt.editor",
onSelect: async (dialog) => { onSelect: async (dialog, trigger) => {
dialog.clear() dialog.clear()
const value = input.plainText const value = trigger === "prompt" ? "" : input.plainText
input.clear()
setStore("prompt", {
input: "",
parts: [],
})
const content = await Editor.open({ value, renderer }) const content = await Editor.open({ value, renderer })
if (content) { if (content) {
input.setText(content, { history: false }) input.setText(content, { history: false })
@@ -194,6 +189,16 @@ export function Prompt(props: PromptProps) {
input.focus() input.focus()
}) })
local.setInitialPrompt.listen((initialPrompt) => {
batch(() => {
setStore("prompt", {
input: initialPrompt,
parts: [],
})
input.insertText(initialPrompt)
})
})
onMount(() => { onMount(() => {
promptPartTypeId = input.extmarks.registerType("prompt-part") promptPartTypeId = input.extmarks.registerType("prompt-part")
}) })
@@ -530,6 +535,17 @@ export function Prompt(props: PromptProps) {
setStore("extmarkToPartIndex", new Map()) setStore("extmarkToPartIndex", new Map())
return return
} }
if (keybind.match("input_forward_delete", e) && store.prompt.input !== "") {
const cursorOffset = input.cursorOffset
if (cursorOffset < input.plainText.length) {
const text = input.plainText
const newText = text.slice(0, cursorOffset) + text.slice(cursorOffset + 1)
input.setText(newText)
input.cursorOffset = cursorOffset
}
e.preventDefault()
return
}
if (keybind.match("app_exit", e)) { if (keybind.match("app_exit", e)) {
await exit() await exit()
return return
@@ -552,10 +568,11 @@ export function Prompt(props: PromptProps) {
if (store.mode === "normal") autocomplete.onKeyDown(e) if (store.mode === "normal") autocomplete.onKeyDown(e)
if (!autocomplete.visible) { if (!autocomplete.visible) {
if ( if (
(e.name === "up" && input.cursorOffset === 0) || (keybind.match("history_previous", e) && input.cursorOffset === 0) ||
(e.name === "down" && input.cursorOffset === input.plainText.length) (keybind.match("history_next", e) &&
input.cursorOffset === input.plainText.length)
) { ) {
const direction = e.name === "up" ? -1 : 1 const direction = keybind.match("history_previous", e) ? -1 : 1
const item = history.move(direction, input.plainText) const item = history.move(direction, input.plainText)
if (item) { if (item) {
@@ -599,14 +616,16 @@ export function Prompt(props: PromptProps) {
// trim ' from the beginning and end of the pasted content. just // trim ' from the beginning and end of the pasted content. just
// ' and nothing else // ' and nothing else
const filepath = pastedContent.replace(/^'+|'+$/g, "") const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
console.log(pastedContent, filepath)
try { try {
const file = Bun.file(filepath) const file = Bun.file(filepath)
if (file.type.startsWith("image/")) { if (file.type.startsWith("image/")) {
event.preventDefault()
const content = await file const content = await file
.arrayBuffer() .arrayBuffer()
.then((buffer) => Buffer.from(buffer).toString("base64")) .then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {}) .catch(console.error)
if (content) { if (content) {
await pasteImage({ await pasteImage({
filename: file.name, filename: file.name,
@@ -663,7 +682,7 @@ export function Prompt(props: PromptProps) {
onMouseDown={(r: MouseEvent) => r.target?.focus()} onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement} focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.primary} cursorColor={theme.primary}
syntaxStyle={SyntaxTheme} syntaxStyle={syntax()}
/> />
</box> </box>
<box <box
@@ -674,7 +693,7 @@ export function Prompt(props: PromptProps) {
></box> ></box>
</box> </box>
<box flexDirection="row" justifyContent="space-between"> <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={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
<span style={{ bold: true }}>{local.model.parsed().model}</span> <span style={{ bold: true }}>{local.model.parsed().model}</span>
</text> </text>
@@ -684,14 +703,14 @@ export function Prompt(props: PromptProps) {
</Match> </Match>
<Match when={status() === "working"}> <Match when={status() === "working"}>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text> <text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>interrupt</span> esc <span style={{ fg: theme.textMuted }}>interrupt</span>
</text> </text>
</box> </box>
</Match> </Match>
<Match when={props.hint}>{props.hint!}</Match> <Match when={props.hint}>{props.hint!}</Match>
<Match when={true}> <Match when={true}>
<text> <text fg={theme.text}>
ctrl+p <span style={{ fg: theme.textMuted }}>commands</span> ctrl+p <span style={{ fg: theme.textMuted }}>commands</span>
</text> </text>
</Match> </Match>
@@ -1,5 +1,5 @@
import { Global } from "@/global" import { Global } from "@/global"
import { createSignal } from "solid-js" import { createSignal, type Setter } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import path from "path" import path from "path"
@@ -8,10 +8,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
name: "KV", name: "KV",
init: () => { init: () => {
const [ready, setReady] = createSignal(false) const [ready, setReady] = createSignal(false)
const [kvStore, setKvStore] = createStore({ const [kvStore, setKvStore] = createStore<Record<string, any>>()
openrouter_warning: false,
theme: "opencode",
})
const file = Bun.file(path.join(Global.Path.state, "kv.json")) const file = Bun.file(path.join(Global.Path.state, "kv.json"))
file file
@@ -24,22 +21,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
setReady(true) setReady(true)
}) })
return { const result = {
get data() {
return kvStore
},
get ready() { get ready() {
return 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) { set(key: string, value: any) {
setKvStore(key as any, value) setKvStore(key, value)
Bun.write( Bun.write(file, JSON.stringify(kvStore, null, 2))
file,
JSON.stringify({
[key]: value,
}),
)
}, },
} }
return result
}, },
}) })
@@ -8,10 +8,11 @@ import { Global } from "@/global"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { createEventBus } from "@solid-primitives/event-bus"
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local", name: "Local",
init: (props: { initialModel?: string; initialAgent?: string }) => { init: (props: { initialModel?: string; initialAgent?: string; initialPrompt?: string }) => {
const sync = useSync() const sync = useSync()
const toast = useToast() const toast = useToast()
@@ -147,15 +148,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setModelStore("ready", true) setModelStore("ready", true)
}) })
createEffect(() => {
Bun.write(
file,
JSON.stringify({
recent: modelStore.recent,
}),
)
})
const fallbackModel = createMemo(() => { const fallbackModel = createMemo(() => {
if (sync.data.config.model) { if (sync.data.config.model) {
const [providerID, modelID] = sync.data.config.model.split("/") const [providerID, modelID] = sync.data.config.model.split("/")
@@ -206,6 +198,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model: model.name ?? value.modelID, model: model.name ?? value.modelID,
} }
}), }),
cycle(direction: 1 | -1) {
const current = currentModel()
if (!current) return
const recent = modelStore.recent
const index = recent.findIndex(
(x) => x.providerID === current.providerID && x.modelID === current.modelID,
)
if (index === -1) return
let next = index + direction
if (next < 0) next = recent.length - 1
if (next >= recent.length) next = 0
const val = recent[next]
if (!val) return
setModelStore("model", agent.current().name, { ...val })
},
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => { batch(() => {
if (!isModelValid(model)) { if (!isModelValid(model)) {
@@ -216,21 +223,36 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}) })
return return
} }
setModelStore("model", agent.current().name, model) setModelStore("model", agent.current().name, model)
if (options?.recent) { if (options?.recent) {
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID) const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop() if (uniq.length > 5) uniq.pop()
setModelStore("recent", uniq) setModelStore("recent", uniq)
Bun.write(
file,
JSON.stringify({
recent: modelStore.recent,
}),
)
} }
}) })
}, },
} }
}) })
const setInitialPrompt = createEventBus<string>()
onMount(() => {
if (props.initialPrompt)
setInitialPrompt.emit(props.initialPrompt)
})
const result = { const result = {
model, model,
agent, agent,
get setInitialPrompt() {
return setInitialPrompt
},
} }
return result return result
}, },
@@ -10,6 +10,7 @@ import type {
Permission, Permission,
LspStatus, LspStatus,
McpStatus, McpStatus,
FormatterStatus,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
@@ -42,6 +43,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: { mcp: {
[key: string]: McpStatus [key: string]: McpStatus
} }
formatter: FormatterStatus[]
}>({ }>({
config: {}, config: {},
ready: false, ready: false,
@@ -55,6 +57,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
part: {}, part: {},
lsp: [], lsp: [],
mcp: {}, mcp: {},
formatter: [],
}) })
const sdk = useSDK() 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.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)), sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)), sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
]) ])
const result = { const result = {
@@ -1,5 +1,5 @@
import { SyntaxStyle, RGBA } from "@opentui/core" import { SyntaxStyle, RGBA } from "@opentui/core"
import { createMemo } from "solid-js" import { createMemo, createSignal } from "solid-js"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" } import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" }
@@ -14,6 +14,7 @@ import kanagawa from "../../../../../../tui/internal/theme/themes/kanagawa.json"
import material from "../../../../../../tui/internal/theme/themes/material.json" with { type: "json" } import material from "../../../../../../tui/internal/theme/themes/material.json" with { type: "json" }
import matrix from "../../../../../../tui/internal/theme/themes/matrix.json" with { type: "json" } import matrix from "../../../../../../tui/internal/theme/themes/matrix.json" with { type: "json" }
import monokai from "../../../../../../tui/internal/theme/themes/monokai.json" with { type: "json" } import monokai from "../../../../../../tui/internal/theme/themes/monokai.json" with { type: "json" }
import nightowl from "../../../../../../tui/internal/theme/themes/nightowl.json" with { type: "json" }
import nord from "../../../../../../tui/internal/theme/themes/nord.json" with { type: "json" } import nord from "../../../../../../tui/internal/theme/themes/nord.json" with { type: "json" }
import onedark from "../../../../../../tui/internal/theme/themes/one-dark.json" with { type: "json" } import onedark from "../../../../../../tui/internal/theme/themes/one-dark.json" with { type: "json" }
import opencode from "../../../../../../tui/internal/theme/themes/opencode.json" with { type: "json" } import opencode from "../../../../../../tui/internal/theme/themes/opencode.json" with { type: "json" }
@@ -68,52 +69,61 @@ type Theme = {
markdownImage: RGBA markdownImage: RGBA
markdownImageText: RGBA markdownImageText: RGBA
markdownCodeBlock: RGBA markdownCodeBlock: RGBA
syntaxComment: RGBA
syntaxKeyword: RGBA
syntaxFunction: RGBA
syntaxVariable: RGBA
syntaxString: RGBA
syntaxNumber: RGBA
syntaxType: RGBA
syntaxOperator: RGBA
syntaxPunctuation: RGBA
} }
type HexColor = `#${string}` type HexColor = `#${string}`
type RefName = string type RefName = string
type ColorModeObj = { type Variant = {
dark: HexColor | RefName dark: HexColor | RefName
light: HexColor | RefName light: HexColor | RefName
} }
type ColorValue = HexColor | RefName | ColorModeObj type ColorValue = HexColor | RefName | Variant
type ThemeJson = { type ThemeJson = {
$schema?: string $schema?: string
defs?: Record<string, HexColor | RefName> defs?: Record<string, HexColor | RefName>
theme: Record<keyof Theme, ColorValue> theme: Record<keyof Theme, ColorValue>
} }
export const THEMES = { export const THEMES: Record<string, ThemeJson> = {
aura: resolveTheme(aura), aura,
ayu: resolveTheme(ayu), ayu,
catppuccin: resolveTheme(catppuccin), catppuccin,
cobalt2: resolveTheme(cobalt2), cobalt2,
dracula: resolveTheme(dracula), dracula,
everforest: resolveTheme(everforest), everforest,
github: resolveTheme(github), github,
gruvbox: resolveTheme(gruvbox), gruvbox,
kanagawa: resolveTheme(kanagawa), kanagawa,
material: resolveTheme(material), material,
matrix: resolveTheme(matrix), matrix,
monokai: resolveTheme(monokai), monokai,
nord: resolveTheme(nord), nightowl,
["one-dark"]: resolveTheme(onedark), nord,
opencode: resolveTheme(opencode), ["one-dark"]: onedark,
palenight: resolveTheme(palenight), opencode,
rosepine: resolveTheme(rosepine), palenight,
solarized: resolveTheme(solarized), rosepine,
synthwave84: resolveTheme(synthwave84), solarized,
tokyonight: resolveTheme(tokyonight), synthwave84,
vesper: resolveTheme(vesper), tokyonight,
zenburn: resolveTheme(zenburn), vesper,
zenburn,
} }
function resolveTheme(theme: ThemeJson) { function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {} const defs = theme.defs ?? {}
function resolveColor(c: ColorValue): RGBA { function resolveColor(c: ColorValue): RGBA {
if (typeof c === "string") return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c]) if (typeof c === "string") return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c])
// TODO: support light theme when opentui has the equivalent of lipgloss.AdaptiveColor return resolveColor(c[mode])
return resolveColor(c.dark)
} }
return Object.fromEntries( return Object.fromEntries(
Object.entries(theme.theme).map(([key, value]) => { Object.entries(theme.theme).map(([key, value]) => {
@@ -122,78 +132,92 @@ function resolveTheme(theme: ThemeJson) {
) as Theme ) as Theme
} }
const syntaxThemeDark = [ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light" }) => {
const sync = useSync()
const kv = useKV()
const [theme, setTheme] = createSignal(sync.data.config.theme ?? kv.get("theme", "opencode"))
const [mode, setMode] = createSignal(props.mode)
const values = createMemo(() => {
return resolveTheme(THEMES[theme()] ?? THEMES.opencode, mode())
})
const syntax = createMemo(() => {
return SyntaxStyle.fromTheme([
{ {
scope: ["prompt"], scope: ["prompt"],
style: { style: {
foreground: "#7dcfff", foreground: values().accent,
}, },
}, },
{ {
scope: ["extmark.file"], scope: ["extmark.file"],
style: { style: {
foreground: "#ff9e64", foreground: values().warning,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["extmark.agent"], scope: ["extmark.agent"],
style: { style: {
foreground: "#bb9af7", foreground: values().secondary,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["extmark.paste"], scope: ["extmark.paste"],
style: { style: {
foreground: "#1a1b26", foreground: values().background,
background: "#ff9e64", background: values().warning,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["comment"], scope: ["comment"],
style: { style: {
foreground: "#565f89", foreground: values().syntaxComment,
italic: true, italic: true,
}, },
}, },
{ {
scope: ["comment.documentation"], scope: ["comment.documentation"],
style: { style: {
foreground: "#565f89", foreground: values().syntaxComment,
italic: true, italic: true,
}, },
}, },
{ {
scope: ["string", "symbol"], scope: ["string", "symbol"],
style: { style: {
foreground: "#9ece6a", foreground: values().syntaxString,
}, },
}, },
{ {
scope: ["number", "boolean"], scope: ["number", "boolean"],
style: { style: {
foreground: "#ff9e64", foreground: values().syntaxNumber,
}, },
}, },
{ {
scope: ["character.special"], scope: ["character.special"],
style: { style: {
foreground: "#9ece6a", foreground: values().syntaxString,
}, },
}, },
{ {
scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"],
style: { style: {
foreground: "#bb9af7", foreground: values().syntaxKeyword,
italic: true, italic: true,
}, },
}, },
{ {
scope: ["keyword.type"], scope: ["keyword.type"],
style: { style: {
foreground: "#2ac3de", foreground: values().syntaxType,
bold: true, bold: true,
italic: true, italic: true,
}, },
@@ -201,80 +225,80 @@ const syntaxThemeDark = [
{ {
scope: ["keyword.function", "function.method"], scope: ["keyword.function", "function.method"],
style: { style: {
foreground: "#bb9af7", foreground: values().syntaxFunction,
}, },
}, },
{ {
scope: ["keyword"], scope: ["keyword"],
style: { style: {
foreground: "#bb9af7", foreground: values().syntaxKeyword,
italic: true, italic: true,
}, },
}, },
{ {
scope: ["keyword.import"], scope: ["keyword.import"],
style: { style: {
foreground: "#bb9af7", foreground: values().syntaxKeyword,
}, },
}, },
{ {
scope: ["operator", "keyword.operator", "punctuation.delimiter"], scope: ["operator", "keyword.operator", "punctuation.delimiter"],
style: { style: {
foreground: "#89ddff", foreground: values().syntaxOperator,
}, },
}, },
{ {
scope: ["keyword.conditional.ternary"], scope: ["keyword.conditional.ternary"],
style: { style: {
foreground: "#89ddff", foreground: values().syntaxOperator,
}, },
}, },
{ {
scope: ["variable", "variable.parameter", "function.method.call", "function.call"], scope: ["variable", "variable.parameter", "function.method.call", "function.call"],
style: { style: {
foreground: "#7dcfff", foreground: values().syntaxVariable,
}, },
}, },
{ {
scope: ["variable.member", "function", "constructor"], scope: ["variable.member", "function", "constructor"],
style: { style: {
foreground: "#7aa2f7", foreground: values().syntaxFunction,
}, },
}, },
{ {
scope: ["type", "module"], scope: ["type", "module"],
style: { style: {
foreground: "#2ac3de", foreground: values().syntaxType,
}, },
}, },
{ {
scope: ["constant"], scope: ["constant"],
style: { style: {
foreground: "#ff9e64", foreground: values().syntaxNumber,
}, },
}, },
{ {
scope: ["property"], scope: ["property"],
style: { style: {
foreground: "#73daca", foreground: values().syntaxVariable,
}, },
}, },
{ {
scope: ["class"], scope: ["class"],
style: { style: {
foreground: "#2ac3de", foreground: values().syntaxType,
}, },
}, },
{ {
scope: ["parameter"], scope: ["parameter"],
style: { style: {
foreground: "#e0af68", foreground: values().syntaxVariable,
}, },
}, },
{ {
scope: ["punctuation", "punctuation.bracket"], scope: ["punctuation", "punctuation.bracket"],
style: { style: {
foreground: "#89ddff", foreground: values().syntaxPunctuation,
}, },
}, },
{ {
@@ -286,45 +310,45 @@ const syntaxThemeDark = [
"constant.builtin", "constant.builtin",
], ],
style: { style: {
foreground: "#f7768e", foreground: values().error,
}, },
}, },
{ {
scope: ["variable.super"], scope: ["variable.super"],
style: { style: {
foreground: "#f7768e", foreground: values().error,
}, },
}, },
{ {
scope: ["string.escape", "string.regexp"], scope: ["string.escape", "string.regexp"],
style: { style: {
foreground: "#bb9af7", foreground: values().syntaxKeyword,
}, },
}, },
{ {
scope: ["keyword.directive"], scope: ["keyword.directive"],
style: { style: {
foreground: "#bb9af7", foreground: values().syntaxKeyword,
italic: true, italic: true,
}, },
}, },
{ {
scope: ["punctuation.special"], scope: ["punctuation.special"],
style: { style: {
foreground: "#89ddff", foreground: values().syntaxOperator,
}, },
}, },
{ {
scope: ["keyword.modifier"], scope: ["keyword.modifier"],
style: { style: {
foreground: "#bb9af7", foreground: values().syntaxKeyword,
italic: true, italic: true,
}, },
}, },
{ {
scope: ["keyword.exception"], scope: ["keyword.exception"],
style: { style: {
foreground: "#bb9af7", foreground: values().syntaxKeyword,
italic: true, italic: true,
}, },
}, },
@@ -332,155 +356,155 @@ const syntaxThemeDark = [
{ {
scope: ["markup.heading"], scope: ["markup.heading"],
style: { style: {
foreground: "#7aa2f7", foreground: values().markdownHeading,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["markup.heading.1"], scope: ["markup.heading.1"],
style: { style: {
foreground: "#bb9af7", foreground: values().markdownHeading,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["markup.heading.2"], scope: ["markup.heading.2"],
style: { style: {
foreground: "#7aa2f7", foreground: values().markdownHeading,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["markup.heading.3"], scope: ["markup.heading.3"],
style: { style: {
foreground: "#7dcfff", foreground: values().markdownHeading,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["markup.heading.4"], scope: ["markup.heading.4"],
style: { style: {
foreground: "#73daca", foreground: values().markdownHeading,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["markup.heading.5"], scope: ["markup.heading.5"],
style: { style: {
foreground: "#9ece6a", foreground: values().markdownHeading,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["markup.heading.6"], scope: ["markup.heading.6"],
style: { style: {
foreground: "#565f89", foreground: values().markdownHeading,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["markup.bold", "markup.strong"], scope: ["markup.bold", "markup.strong"],
style: { style: {
foreground: "#e6edf3", foreground: values().markdownStrong,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["markup.italic"], scope: ["markup.italic"],
style: { style: {
foreground: "#e6edf3", foreground: values().markdownEmph,
italic: true, italic: true,
}, },
}, },
{ {
scope: ["markup.list"], scope: ["markup.list"],
style: { style: {
foreground: "#ff9e64", foreground: values().markdownListItem,
}, },
}, },
{ {
scope: ["markup.quote"], scope: ["markup.quote"],
style: { style: {
foreground: "#565f89", foreground: values().markdownBlockQuote,
italic: true, italic: true,
}, },
}, },
{ {
scope: ["markup.raw", "markup.raw.block"], scope: ["markup.raw", "markup.raw.block"],
style: { style: {
foreground: "#9ece6a", foreground: values().markdownCode,
}, },
}, },
{ {
scope: ["markup.raw.inline"], scope: ["markup.raw.inline"],
style: { style: {
foreground: "#9ece6a", foreground: values().markdownCode,
background: "#1a1b26", background: values().background,
}, },
}, },
{ {
scope: ["markup.link"], scope: ["markup.link"],
style: { style: {
foreground: "#7aa2f7", foreground: values().markdownLink,
underline: true, underline: true,
}, },
}, },
{ {
scope: ["markup.link.label"], scope: ["markup.link.label"],
style: { style: {
foreground: "#7dcfff", foreground: values().markdownLinkText,
underline: true, underline: true,
}, },
}, },
{ {
scope: ["markup.link.url"], scope: ["markup.link.url"],
style: { style: {
foreground: "#7aa2f7", foreground: values().markdownLink,
underline: true, underline: true,
}, },
}, },
{ {
scope: ["label"], scope: ["label"],
style: { style: {
foreground: "#73daca", foreground: values().markdownLinkText,
}, },
}, },
{ {
scope: ["spell", "nospell"], scope: ["spell", "nospell"],
style: { style: {
foreground: "#e6edf3", foreground: values().text,
}, },
}, },
{ {
scope: ["conceal"], scope: ["conceal"],
style: { style: {
foreground: "#565f89", foreground: values().textMuted,
}, },
}, },
// Additional common highlight groups // Additional common highlight groups
{ {
scope: ["string.special", "string.special.url"], scope: ["string.special", "string.special.url"],
style: { style: {
foreground: "#73daca", foreground: values().markdownLink,
underline: true, underline: true,
}, },
}, },
{ {
scope: ["character"], scope: ["character"],
style: { style: {
foreground: "#9ece6a", foreground: values().syntaxString,
}, },
}, },
{ {
scope: ["float"], scope: ["float"],
style: { style: {
foreground: "#ff9e64", foreground: values().syntaxNumber,
}, },
}, },
{ {
scope: ["comment.error"], scope: ["comment.error"],
style: { style: {
foreground: "#f7768e", foreground: values().error,
italic: true, italic: true,
bold: true, bold: true,
}, },
@@ -488,7 +512,7 @@ const syntaxThemeDark = [
{ {
scope: ["comment.warning"], scope: ["comment.warning"],
style: { style: {
foreground: "#e0af68", foreground: values().warning,
italic: true, italic: true,
bold: true, bold: true,
}, },
@@ -496,7 +520,7 @@ const syntaxThemeDark = [
{ {
scope: ["comment.todo", "comment.note"], scope: ["comment.todo", "comment.note"],
style: { style: {
foreground: "#7aa2f7", foreground: values().info,
italic: true, italic: true,
bold: true, bold: true,
}, },
@@ -504,148 +528,142 @@ const syntaxThemeDark = [
{ {
scope: ["namespace"], scope: ["namespace"],
style: { style: {
foreground: "#2ac3de", foreground: values().syntaxType,
}, },
}, },
{ {
scope: ["field"], scope: ["field"],
style: { style: {
foreground: "#73daca", foreground: values().syntaxVariable,
}, },
}, },
{ {
scope: ["type.definition"], scope: ["type.definition"],
style: { style: {
foreground: "#2ac3de", foreground: values().syntaxType,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["keyword.export"], scope: ["keyword.export"],
style: { style: {
foreground: "#bb9af7", foreground: values().syntaxKeyword,
}, },
}, },
{ {
scope: ["attribute", "annotation"], scope: ["attribute", "annotation"],
style: { style: {
foreground: "#e0af68", foreground: values().warning,
}, },
}, },
{ {
scope: ["tag"], scope: ["tag"],
style: { style: {
foreground: "#f7768e", foreground: values().error,
}, },
}, },
{ {
scope: ["tag.attribute"], scope: ["tag.attribute"],
style: { style: {
foreground: "#bb9af7", foreground: values().syntaxKeyword,
}, },
}, },
{ {
scope: ["tag.delimiter"], scope: ["tag.delimiter"],
style: { style: {
foreground: "#89ddff", foreground: values().syntaxOperator,
}, },
}, },
{ {
scope: ["markup.strikethrough"], scope: ["markup.strikethrough"],
style: { style: {
foreground: "#565f89", foreground: values().textMuted,
}, },
}, },
{ {
scope: ["markup.underline"], scope: ["markup.underline"],
style: { style: {
foreground: "#e6edf3", foreground: values().text,
underline: true, underline: true,
}, },
}, },
{ {
scope: ["markup.list.checked"], scope: ["markup.list.checked"],
style: { style: {
foreground: "#9ece6a", foreground: values().success,
}, },
}, },
{ {
scope: ["markup.list.unchecked"], scope: ["markup.list.unchecked"],
style: { style: {
foreground: "#565f89", foreground: values().textMuted,
}, },
}, },
{ {
scope: ["diff.plus"], scope: ["diff.plus"],
style: { style: {
foreground: "#9ece6a", foreground: values().diffAdded,
}, },
}, },
{ {
scope: ["diff.minus"], scope: ["diff.minus"],
style: { style: {
foreground: "#f7768e", foreground: values().diffRemoved,
}, },
}, },
{ {
scope: ["diff.delta"], scope: ["diff.delta"],
style: { style: {
foreground: "#7dcfff", foreground: values().diffContext,
}, },
}, },
{ {
scope: ["error"], scope: ["error"],
style: { style: {
foreground: "#f7768e", foreground: values().error,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["warning"], scope: ["warning"],
style: { style: {
foreground: "#e0af68", foreground: values().warning,
bold: true, bold: true,
}, },
}, },
{ {
scope: ["info"], scope: ["info"],
style: { style: {
foreground: "#7dcfff", foreground: values().info,
}, },
}, },
{ {
scope: ["debug"], scope: ["debug"],
style: { style: {
foreground: "#565f89", foreground: values().textMuted,
}, },
}, },
] ])
export const SyntaxTheme = SyntaxStyle.fromTheme(syntaxThemeDark)
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: () => {
const sync = useSync()
const kv = useKV()
const theme = createMemo(() => {
return THEMES[kv.data.theme as keyof typeof THEMES] ?? THEMES.opencode
}) })
return { return {
get theme() { theme: new Proxy(values(), {
return new Proxy(theme(), {
get(_target, prop) { get(_target, prop) {
// @ts-expect-error // @ts-expect-error
return theme()[prop] return values()[prop]
}, },
}) }),
get selected() {
return theme()
}, },
get selectedTheme() { syntax,
return kv.data.theme mode,
setMode(mode: "dark" | "light") {
setMode(mode)
}, },
setSelectedTheme(theme: string) { set(theme: string) {
if (!THEMES[theme]) return
setTheme(theme)
kv.set("theme", theme) kv.set("theme", theme)
}, },
get ready() { get ready() {
@@ -27,7 +27,7 @@ export function Home() {
const Hint = ( const Hint = (
<Show when={Object.keys(sync.data.mcp).length > 0}> <Show when={Object.keys(sync.data.mcp).length > 0}>
<box flexShrink={0} flexDirection="row" gap={1}> <box flexShrink={0} flexDirection="row" gap={1}>
<text> <text fg={theme.text}>
<Switch> <Switch>
<Match when={mcpError()}> <Match when={mcpError()}>
<span style={{ fg: theme.error }}></span> mcp errors{" "} <span style={{ fg: theme.error }}></span> mcp errors{" "}
@@ -76,7 +76,7 @@ function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
const { theme } = useTheme() const { theme } = useTheme()
return ( return (
<box flexDirection="row" justifyContent="space-between" width="100%"> <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> <text fg={theme.primary}>{keybind.print(props.keybind)}</text>
</box> </box>
) )
@@ -51,7 +51,7 @@ export function Header() {
borderColor={theme.backgroundElement} borderColor={theme.backgroundElement}
flexShrink={0} flexShrink={0}
> >
<text> <text fg={theme.text}>
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "} <span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{session().title}</span> <span style={{ bold: true }}>{session().title}</span>
</text> </text>
@@ -64,7 +64,7 @@ export function Header() {
</text> </text>
</Match> </Match>
<Match when={true}> <Match when={true}>
<text wrapMode="word"> <text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span> /share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
</text> </text>
</Match> </Match>
@@ -12,10 +12,10 @@ import {
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import path from "path" import path from "path"
import { useRouteData } from "@tui/context/route" import { useRoute, useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border" 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 { BoxRenderable, ScrollBoxRenderable, addDefaultParsers } from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt" import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { import type {
@@ -62,7 +62,10 @@ import { DialogTimeline } from "./dialog-timeline"
import { Sidebar } from "./sidebar" import { Sidebar } from "./sidebar"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import parsers from "../../../../../../parsers-config.ts" 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) addDefaultParsers(parsers.parsers)
@@ -79,7 +82,9 @@ function use() {
export function Session() { export function Session() {
const route = useRouteData("session") const route = useRouteData("session")
const { navigate } = useRoute()
const sync = useSync() const sync = useSync()
const kv = useKV()
const { theme } = useTheme() const { theme } = useTheme()
const session = createMemo(() => sync.session.get(route.sessionID)!) const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
@@ -90,7 +95,7 @@ export function Session() {
}) })
const dimensions = useTerminalDimensions() 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 [conceal, setConceal] = createSignal(true)
const wide = createMemo(() => dimensions().width > 120) const wide = createMemo(() => dimensions().width > 120)
@@ -99,6 +104,8 @@ export function Session() {
createEffect(() => sync.session.sync(route.sessionID)) createEffect(() => sync.session.sync(route.sessionID))
const toast = useToast()
const sdk = useSDK() const sdk = useSDK()
let scroll: ScrollBoxRenderable let scroll: ScrollBoxRenderable
@@ -150,6 +157,23 @@ export function Session() {
const local = useLocal() const local = useLocal()
function moveChild(direction: number) {
const parentID = session()?.parentID ?? session()?.id
let children = sync.data.session
.filter((x) => x.parentID === parentID || x.id === parentID)
.toSorted((b, a) => a.id.localeCompare(b.id))
if (children.length === 1) return
let next = children.findIndex((x) => x.id === session()?.id) + direction
if (next >= children.length) next = 0
if (next < 0) next = children.length - 1
if (children[next]) {
navigate({
type: "session",
sessionID: children[next].id,
})
}
}
const command = useCommandDialog() const command = useCommandDialog()
command.register(() => [ command.register(() => [
{ {
@@ -195,12 +219,20 @@ export function Session() {
keybind: "session_share", keybind: "session_share",
disabled: !!session()?.share?.url, disabled: !!session()?.share?.url,
category: "Session", category: "Session",
onSelect: (dialog) => { onSelect: async (dialog) => {
sdk.client.session.share({ await sdk.client.session
.share({
path: { path: {
id: route.sessionID, 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() dialog.clear()
}, },
}, },
@@ -240,7 +272,9 @@ export function Session() {
prompt.set( prompt.set(
parts.reduce( parts.reduce(
(agg, part) => { (agg, part) => {
if (part.type === "text") agg.input += part.text if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(part) if (part.type === "file") agg.parts.push(part)
return agg return agg
}, },
@@ -291,6 +325,8 @@ export function Session() {
if (prev === "show") return "hide" if (prev === "show") return "hide"
return "show" return "show"
}) })
if (sidebar() === "show") kv.set("sidebar", "auto")
if (sidebar() === "hide") kv.set("sidebar", "hide")
dialog.clear() dialog.clear()
}, },
}, },
@@ -370,6 +406,37 @@ export function Session() {
dialog.clear() dialog.clear()
}, },
}, },
{
title: "Rename session",
value: "session.rename",
keybind: "session_rename",
category: "Session",
onSelect: (dialog) => {
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
},
},
{
title: "Next child session",
value: "session.child.next",
keybind: "session_child_cycle",
category: "Session",
disabled: true,
onSelect: (dialog) => {
moveChild(1)
dialog.clear()
},
},
{
title: "Previous child session",
value: "session.child.previous",
keybind: "session_child_cycle_reverse",
category: "Session",
disabled: true,
onSelect: (dialog) => {
moveChild(-1)
dialog.clear()
},
},
]) ])
const revert = createMemo(() => { const revert = createMemo(() => {
@@ -431,6 +498,34 @@ export function Session() {
> >
<box flexGrow={1} gap={1}> <box flexGrow={1} gap={1}>
<Show when={session()}> <Show when={session()}>
<Show when={session().parentID}>
<box
backgroundColor={theme.backgroundPanel}
justifyContent="space-between"
flexDirection="row"
paddingTop={1}
paddingBottom={1}
flexShrink={0}
paddingLeft={2}
paddingRight={2}
>
<text fg={theme.text}>
Previous{" "}
<span style={{ fg: theme.textMuted }}>
{keybind.print("session_child_cycle_reverse")}
</span>
</text>
<text fg={theme.text}>
<b>Viewing subagent session</b>
</text>
<text fg={theme.text}>
<span style={{ fg: theme.textMuted }}>
{keybind.print("session_child_cycle")}
</span>{" "}
Next
</text>
</box>
</Show>
<Show when={!sidebarVisible()}> <Show when={!sidebarVisible()}>
<Header /> <Header />
</Show> </Show>
@@ -614,7 +709,7 @@ function UserMessage(props: {
borderColor={color()} borderColor={color()}
flexShrink={0} flexShrink={0}
> >
<text>{text()?.text}</text> <text fg={theme.text}>{text()?.text}</text>
<Show when={files().length}> <Show when={files().length}>
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap"> <box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}> <For each={files()}>
@@ -625,7 +720,7 @@ function UserMessage(props: {
return theme.secondary return theme.secondary
}) })
return ( return (
<text> <text fg={theme.text}>
<span style={{ bg: bg(), fg: theme.background }}> <span style={{ bg: bg(), fg: theme.background }}>
{" "} {" "}
{MIME_BADGE[file.mime] ?? file.mime}{" "} {MIME_BADGE[file.mime] ?? file.mime}{" "}
@@ -640,7 +735,7 @@ function UserMessage(props: {
</For> </For>
</box> </box>
</Show> </Show>
<text> <text fg={theme.text}>
{sync.data.config.username ?? "You"}{" "} {sync.data.config.username ?? "You"}{" "}
<Show <Show
when={queued()} when={queued()}
@@ -755,7 +850,7 @@ function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }
paddingLeft={2} paddingLeft={2}
backgroundColor={theme.backgroundPanel} backgroundColor={theme.backgroundPanel}
> >
<text>{props.part.text.trim()}</text> <text fg={theme.text}>{props.part.text.trim()}</text>
</box> </box>
</box> </box>
</Show> </Show>
@@ -764,13 +859,14 @@ function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }
function TextPart(props: { part: TextPart; message: AssistantMessage }) { function TextPart(props: { part: TextPart; message: AssistantMessage }) {
const ctx = use() const ctx = use()
const { syntax } = useTheme()
return ( return (
<Show when={props.part.text.trim()}> <Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}> <box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<code <code
filetype="markdown" filetype="markdown"
drawUnstyledText={false} drawUnstyledText={false}
syntaxStyle={SyntaxTheme} syntaxStyle={syntax()}
content={props.part.text.trim()} content={props.part.text.trim()}
conceal={ctx.conceal()} conceal={ctx.conceal()}
/> />
@@ -893,16 +989,14 @@ function GenericTool(props: ToolProps<any>) {
) )
} }
const ToolRegistry = (() => { type ToolRegistration<T extends Tool.Info = any> = {
const state: Record<
string,
{ name: string; container: "inline" | "block"; render?: Component<ToolProps<any>> }
> = {}
function register<T extends Tool.Info>(input: {
name: string name: string
container: "inline" | "block" container: "inline" | "block"
render?: Component<ToolProps<T>> render?: Component<ToolProps<T>>
}) { }
const ToolRegistry = (() => {
const state: Record<string, ToolRegistration> = {}
function register<T extends Tool.Info>(input: ToolRegistration<T>) {
state[input.name] = input state[input.name] = input
return input return input
} }
@@ -970,7 +1064,7 @@ ToolRegistry.register<typeof WriteTool>({
name: "write", name: "write",
container: "block", container: "block",
render(props) { render(props) {
const { theme } = useTheme() const { theme, syntax } = useTheme()
const lines = createMemo(() => { const lines = createMemo(() => {
return props.input.content?.split("\n") ?? [] return props.input.content?.split("\n") ?? []
}) })
@@ -1001,7 +1095,7 @@ ToolRegistry.register<typeof WriteTool>({
<box paddingLeft={1} flexGrow={1}> <box paddingLeft={1} flexGrow={1}>
<code <code
filetype={filetype(props.input.filePath!)} filetype={filetype(props.input.filePath!)}
syntaxStyle={SyntaxTheme} syntaxStyle={syntax()}
content={code()} content={code()}
/> />
</box> </box>
@@ -1066,6 +1160,8 @@ ToolRegistry.register<typeof TaskTool>({
container: "block", container: "block",
render(props) { render(props) {
const { theme } = useTheme() const { theme } = useTheme()
const keybind = useKeybind()
return ( return (
<> <>
<ToolTitle icon="%" fallback="Delegating..." when={props.input.description}> <ToolTitle icon="%" fallback="Delegating..." when={props.input.description}>
@@ -1082,6 +1178,10 @@ ToolRegistry.register<typeof TaskTool>({
</For> </For>
</box> </box>
</Show> </Show>
<text fg={theme.text}>
{keybind.print("session_child_cycle")}, {keybind.print("session_child_cycle_reverse")}
<span style={{ fg: theme.textMuted }}> to navigate between subagent sessions</span>
</text>
</> </>
) )
}, },
@@ -1104,6 +1204,7 @@ ToolRegistry.register<typeof EditTool>({
container: "block", container: "block",
render(props) { render(props) {
const ctx = use() const ctx = use()
const { theme, syntax } = useTheme()
const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked")) const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked"))
@@ -1183,21 +1284,21 @@ ToolRegistry.register<typeof EditTool>({
</ToolTitle> </ToolTitle>
<Switch> <Switch>
<Match when={props.permission["diff"]}> <Match when={props.permission["diff"]}>
<text>{props.permission["diff"]?.trim()}</text> <text fg={theme.text}>{props.permission["diff"]?.trim()}</text>
</Match> </Match>
<Match when={diff() && style() === "split"}> <Match when={diff() && style() === "split"}>
<box paddingLeft={1} flexDirection="row" gap={2}> <box paddingLeft={1} flexDirection="row" gap={2}>
<box flexGrow={1} flexBasis={0}> <box flexGrow={1} flexBasis={0}>
<code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.oldContent} /> <code filetype={ft()} syntaxStyle={syntax()} content={diff()!.oldContent} />
</box> </box>
<box flexGrow={1} flexBasis={0}> <box flexGrow={1} flexBasis={0}>
<code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.newContent} /> <code filetype={ft()} syntaxStyle={syntax()} content={diff()!.newContent} />
</box> </box>
</box> </box>
</Match> </Match>
<Match when={code()}> <Match when={code()}>
<box paddingLeft={1}> <box paddingLeft={1}>
<code filetype={ft()} syntaxStyle={SyntaxTheme} content={code()} /> <code filetype={ft()} syntaxStyle={syntax()} content={code()} />
</box> </box>
</Match> </Match>
</Switch> </Switch>
@@ -1210,6 +1311,7 @@ ToolRegistry.register<typeof PatchTool>({
name: "patch", name: "patch",
container: "block", container: "block",
render(props) { render(props) {
const { theme } = useTheme()
return ( return (
<> <>
<ToolTitle icon="%" fallback="Preparing patch..." when={true}> <ToolTitle icon="%" fallback="Preparing patch..." when={true}>
@@ -1217,7 +1319,7 @@ ToolRegistry.register<typeof PatchTool>({
</ToolTitle> </ToolTitle>
<Show when={props.output}> <Show when={props.output}>
<box> <box>
<text>{props.output?.trim()}</text> <text fg={theme.text}>{props.output?.trim()}</text>
</box> </box>
</Show> </Show>
</> </>
@@ -42,7 +42,7 @@ export function Sidebar(props: { sessionID: string }) {
<Show when={session()}> <Show when={session()}>
<box flexShrink={0} gap={1} width={40}> <box flexShrink={0} gap={1} width={40}>
<box> <box>
<text> <text fg={theme.text}>
<b>{session().title}</b> <b>{session().title}</b>
</text> </text>
<Show when={session().share?.url}> <Show when={session().share?.url}>
@@ -50,7 +50,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show> </Show>
</box> </box>
<box> <box>
<text> <text fg={theme.text}>
<b>Context</b> <b>Context</b>
</text> </text>
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text> <text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
@@ -59,7 +59,7 @@ export function Sidebar(props: { sessionID: string }) {
</box> </box>
<Show when={Object.keys(sync.data.mcp).length > 0}> <Show when={Object.keys(sync.data.mcp).length > 0}>
<box> <box>
<text> <text fg={theme.text}>
<b>MCP</b> <b>MCP</b>
</text> </text>
<For each={Object.entries(sync.data.mcp)}> <For each={Object.entries(sync.data.mcp)}>
@@ -77,7 +77,7 @@ export function Sidebar(props: { sessionID: string }) {
> >
</text> </text>
<text wrapMode="word"> <text fg={theme.text} wrapMode="word">
{key}{" "} {key}{" "}
<span style={{ fg: theme.textMuted }}> <span style={{ fg: theme.textMuted }}>
<Switch> <Switch>
@@ -96,7 +96,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show> </Show>
<Show when={sync.data.lsp.length > 0}> <Show when={sync.data.lsp.length > 0}>
<box> <box>
<text> <text fg={theme.text}>
<b>LSP</b> <b>LSP</b>
</text> </text>
<For each={sync.data.lsp}> <For each={sync.data.lsp}>
@@ -123,7 +123,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show> </Show>
<Show when={session().summary?.diffs}> <Show when={session().summary?.diffs}>
<box> <box>
<text> <text fg={theme.text}>
<b>Modified Files</b> <b>Modified Files</b>
</text> </text>
<For each={session().summary?.diffs || []}> <For each={session().summary?.diffs || []}>
@@ -155,7 +155,7 @@ export function Sidebar(props: { sessionID: string }) {
</Show> </Show>
<Show when={todo().length > 0}> <Show when={todo().length > 0}>
<box> <box>
<text> <text fg={theme.text}>
<b>Todo</b> <b>Todo</b>
</text> </text>
<For each={todo()}> <For each={todo()}>
@@ -18,7 +18,6 @@ export const TuiSpawnCommand = cmd({
default: 0, default: 0,
}) })
.option("hostname", { .option("hostname", {
alias: ["h"],
type: "string", type: "string",
describe: "hostname to listen on", describe: "hostname to listen on",
default: "127.0.0.1", default: "127.0.0.1",
+6 -1
View File
@@ -32,6 +32,11 @@ export const TuiThreadCommand = cmd({
describe: "session id to continue", describe: "session id to continue",
type: "string", type: "string",
}) })
.option("prompt", {
alias: ["p"],
type: "string",
describe: "prompt to use",
})
.option("agent", { .option("agent", {
type: "string", type: "string",
describe: "agent to use", describe: "agent to use",
@@ -42,7 +47,6 @@ export const TuiThreadCommand = cmd({
default: 0, default: 0,
}) })
.option("hostname", { .option("hostname", {
alias: ["h"],
type: "string", type: "string",
describe: "hostname to listen on", describe: "hostname to listen on",
default: "127.0.0.1", default: "127.0.0.1",
@@ -96,6 +100,7 @@ export const TuiThreadCommand = cmd({
sessionID, sessionID,
model: args.model, model: args.model,
agent: args.agent, agent: args.agent,
prompt: args.prompt,
onExit: async () => { onExit: async () => {
await client.call("shutdown", undefined) await client.call("shutdown", undefined)
}, },
@@ -0,0 +1,65 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { onMount } from "solid-js"
export type DialogPromptProps = {
title: string
value?: string
onConfirm?: (value: string) => void
onCancel?: () => void
}
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { theme } = useTheme()
let textarea: TextareaRenderable
onMount(() => {
dialog.setSize("large")
setTimeout(() => {
textarea.focus()
}, 1)
textarea.gotoLineEnd()
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box>
<textarea
onSubmit={() => {
props.onConfirm?.(textarea.plainText)
dialog.clear()
}}
keyBindings={[{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.value}
placeholder="Enter text"
/>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>Press enter to confirm, esc to cancel</text>
</box>
</box>
)
}
DialogPrompt.show = (dialog: DialogContext, title: string, value?: string) => {
return new Promise<string | null>((resolve) => {
dialog.replace(
() => (
<DialogPrompt
title={title}
value={value}
onConfirm={(value) => resolve(value)}
onCancel={() => resolve(null)}
/>
),
() => resolve(null),
)
})
}
@@ -35,7 +35,7 @@ export interface DialogSelectOption<T = any> {
category?: string category?: string
disabled?: boolean disabled?: boolean
bg?: RGBA bg?: RGBA
onSelect?: (ctx: DialogContext) => void onSelect?: (ctx: DialogContext, trigger?: "prompt") => void
} }
export type DialogSelectRef<T> = { export type DialogSelectRef<T> = {
@@ -59,7 +59,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.options, props.options,
filter((x) => x.disabled !== true), filter((x) => x.disabled !== true),
take(props.limit ?? Infinity), take(props.limit ?? Infinity),
(x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)), (x) =>
!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj),
) )
return result return result
}) })
@@ -122,20 +123,26 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const keybind = useKeybind() const keybind = useKeybind()
useKeyboard((evt) => { useKeyboard((evt) => {
if (evt.name === "up") move(-1) if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
if (evt.name === "down") move(1) if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
if (evt.name === "pageup") move(-10) if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10) if (evt.name === "pagedown") move(10)
if (evt.name === "return") { if (evt.name === "return") {
const option = selected() const option = selected()
if (option) {
evt.preventDefault()
if (option.onSelect) option.onSelect(dialog) if (option.onSelect) option.onSelect(dialog)
props.onSelect?.(option) props.onSelect?.(option)
} }
}
for (const item of props.keybind ?? []) { for (const item of props.keybind ?? []) {
if (Keybind.match(item.keybind, keybind.parse(evt))) { if (Keybind.match(item.keybind, keybind.parse(evt))) {
const s = selected() const s = selected()
if (s) item.onTrigger(s) if (s) {
evt.preventDefault()
item.onTrigger(s)
}
} }
} }
}) })
@@ -155,7 +162,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box gap={1}> <box gap={1}>
<box paddingLeft={3} paddingRight={2}> <box paddingLeft={3} paddingRight={2}>
<box flexDirection="row" justifyContent="space-between"> <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> <text fg={theme.textMuted}>esc</text>
</box> </box>
<box paddingTop={1} paddingBottom={1}> <box paddingTop={1} paddingBottom={1}>
@@ -166,12 +175,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.onFilter?.(e) props.onFilter?.(e)
}) })
}} }}
onKeyDown={(e) => {}}
focusedBackgroundColor={theme.backgroundPanel} focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary} cursorColor={theme.primary}
focusedTextColor={theme.textMuted} focusedTextColor={theme.textMuted}
ref={(r) => { ref={(r) => {
input = r input = r
input.focus() setTimeout(() => input.focus(), 1)
}} }}
placeholder="Enter search term" placeholder="Enter search term"
/> />
@@ -206,11 +216,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.onSelect?.(option) props.onSelect?.(option)
}} }}
onMouseOver={() => { onMouseOver={() => {
const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value)) const index = filtered().findIndex((x) =>
isDeepEqual(x.value, option.value),
)
if (index === -1) return if (index === -1) return
moveTo(index) moveTo(index)
}} }}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} backgroundColor={
active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)
}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
gap={1} gap={1}
@@ -218,7 +232,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Option <Option
title={option.title} title={option.title}
footer={option.footer} footer={option.footer}
description={option.description !== category ? option.description : undefined} description={
option.description !== category ? option.description : undefined
}
active={active()} active={active()}
current={isDeepEqual(option.value, props.current)} current={isDeepEqual(option.value, props.current)}
/> />
@@ -230,11 +246,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
)} )}
</For> </For>
</scrollbox> </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 ?? []}> <For each={props.keybind ?? []}>
{(item) => ( {(item) => (
<text> <text>
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span> <span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>
{Keybind.toString(item.keybind)}
</span>
<span style={{ fg: theme.textMuted }}> {item.title}</span> <span style={{ fg: theme.textMuted }}> {item.title}</span>
</text> </text>
)} )}
@@ -263,7 +281,10 @@ function Option(props: {
wrapMode="none" wrapMode="none"
> >
{Locale.truncate(props.title, 62)} {Locale.truncate(props.title, 62)}
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span> <span style={{ fg: props.active ? theme.background : theme.textMuted }}>
{" "}
{props.description}
</span>
</text> </text>
<Show when={props.footer}> <Show when={props.footer}>
<box flexShrink={0}> <box flexShrink={0}>
+10 -17
View File
@@ -1,23 +1,18 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" 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 { useTheme } from "@tui/context/theme"
import { Renderable, RGBA } from "@opentui/core" import { Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createEventBus } from "@solid-primitives/event-bus" import { createEventBus } from "@solid-primitives/event-bus"
const Border = {
topLeft: "┃",
topRight: "┃",
bottomLeft: "┃",
bottomRight: "┃",
horizontal: "",
vertical: "┃",
topT: "+",
bottomT: "+",
leftT: "+",
rightT: "+",
cross: "+",
}
export function Dialog( export function Dialog(
props: ParentProps<{ props: ParentProps<{
size?: "medium" | "large" size?: "medium" | "large"
@@ -45,11 +40,9 @@ export function Dialog(
onMouseUp={async (e) => { onMouseUp={async (e) => {
e.stopPropagation() e.stopPropagation()
}} }}
customBorderChars={Border}
width={props.size === "large" ? 80 : 60} width={props.size === "large" ? 80 : 60}
maxWidth={dimensions().width - 2} maxWidth={dimensions().width - 2}
backgroundColor={theme.backgroundPanel} backgroundColor={theme.backgroundPanel}
borderColor={theme.border}
paddingTop={1} paddingTop={1}
> >
{props.children} {props.children}
@@ -138,7 +131,7 @@ function init() {
}, },
get allClosedEvent() { get allClosedEvent() {
return allClosedEvent return allClosedEvent
} },
} }
} }
+13 -1
View File
@@ -49,7 +49,7 @@ function init() {
let timeoutHandle: NodeJS.Timeout | null = null let timeoutHandle: NodeJS.Timeout | null = null
return { const toast = {
show(options: ToastOptions) { show(options: ToastOptions) {
const parsedOptions = TuiEvent.ToastShow.properties.parse(options) const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
const { duration, ...currentToast } = parsedOptions const { duration, ...currentToast } = parsedOptions
@@ -59,10 +59,22 @@ function init() {
setStore("currentToast", null) setStore("currentToast", null)
}, duration).unref() }, 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 { get currentToast(): ToastOptions | null {
return store.currentToast return store.currentToast
}, },
} }
return toast
} }
export type ToastContext = ReturnType<typeof init> export type ToastContext = ReturnType<typeof init>
@@ -61,7 +61,7 @@ export namespace Clipboard {
const getCopyMethod = lazy(() => { const getCopyMethod = lazy(() => {
const os = platform() const os = platform()
if (os === "darwin") { if (os === "darwin" && Bun.which("oascript")) {
console.log("clipboard: using osascript") console.log("clipboard: using osascript")
return async (text: string) => { return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
@@ -70,13 +70,13 @@ export namespace Clipboard {
} }
if (os === "linux") { if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"]) { if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
console.log("clipboard: using wl-copy") console.log("clipboard: using wl-copy")
return async (text: string) => { return async (text: string) => {
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
proc.stdin.write(text) proc.stdin.write(text)
proc.stdin.end() proc.stdin.end()
await proc.exited await proc.exited.catch(() => {})
} }
} }
if (Bun.which("xclip")) { if (Bun.which("xclip")) {
@@ -89,7 +89,7 @@ export namespace Clipboard {
}) })
proc.stdin.write(text) proc.stdin.write(text)
proc.stdin.end() proc.stdin.end()
await proc.exited await proc.exited.catch(() => {})
} }
} }
if (Bun.which("xsel")) { if (Bun.which("xsel")) {
@@ -102,7 +102,7 @@ export namespace Clipboard {
}) })
proc.stdin.write(text) proc.stdin.write(text)
proc.stdin.end() proc.stdin.end()
await proc.exited await proc.exited.catch(() => {})
} }
} }
} }
@@ -24,6 +24,7 @@ export namespace Editor {
}) })
await proc.exited await proc.exited
const content = await Bun.file(filepath).text() const content = await Bun.file(filepath).text()
opts.renderer.currentRenderBuffer.clear()
opts.renderer.resume() opts.renderer.resume()
opts.renderer.requestRender() opts.renderer.requestRender()
return content || undefined return content || undefined
+27
View File
@@ -1,8 +1,27 @@
import z from "zod" import z from "zod"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Instance } from "../project/instance" 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 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 export const Info = z
.object({ .object({
name: z.string(), 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 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 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} If there's already an AGENTS.md, improve it if it's located in ${path}
$ARGUMENTS
+26 -3
View File
@@ -385,7 +385,11 @@ export namespace Config {
.optional() .optional()
.default("ctrl+x") .default("ctrl+x")
.describe("Leader key for keybind combinations"), .describe("Leader key for keybind combinations"),
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"), app_exit: z
.string()
.optional()
.default("ctrl+c,ctrl+d,<leader>q")
.describe("Exit the application"),
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"), editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
theme_list: z.string().optional().default("<leader>t").describe("List available themes"), theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"), sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
@@ -449,18 +453,37 @@ export namespace Config {
.default("<leader>h") .default("<leader>h")
.describe("Toggle code block concealment in messages"), .describe("Toggle code block concealment in messages"),
model_list: z.string().optional().default("<leader>m").describe("List available models"), model_list: z.string().optional().default("<leader>m").describe("List available models"),
model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
model_cycle_recent_reverse: z
.string()
.optional()
.default("shift+f2")
.describe("Previous recently used model"),
command_list: z.string().optional().default("ctrl+p").describe("List available commands"), command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"), agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"), agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), 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_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 input_newline: z
.string() .string()
.optional() .optional()
.default("shift+enter,ctrl+j") .default("shift+return,ctrl+j")
.describe("Insert newline in input"), .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"),
session_child_cycle: z
.string()
.optional()
.default("ctrl+right")
.describe("Next child session"),
session_child_cycle_reverse: z
.string()
.optional()
.default("ctrl+left")
.describe("Previous child session"),
}) })
.strict() .strict()
.meta({ .meta({
+1
View File
@@ -367,6 +367,7 @@ export namespace Ripgrep {
args.push(`--max-count=${input.limit}`) args.push(`--max-count=${input.limit}`)
} }
args.push("--")
args.push(input.pattern) args.push(input.pattern)
const command = args.join(" ") const command = args.join(" ")
+26
View File
@@ -2,6 +2,7 @@ import { Bus } from "../bus"
import { File } from "../file" import { File } from "../file"
import { Log } from "../util/log" import { Log } from "../util/log"
import path from "path" import path from "path"
import z from "zod"
import * as Formatter from "./formatter" import * as Formatter from "./formatter"
import { Config } from "../config/config" import { Config } from "../config/config"
@@ -11,6 +12,17 @@ import { Instance } from "../project/instance"
export namespace Format { export namespace Format {
const log = Log.create({ service: "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 state = Instance.state(async () => {
const enabled: Record<string, boolean> = {} const enabled: Record<string, boolean> = {}
const cfg = await Config.get() const cfg = await Config.get()
@@ -62,6 +74,20 @@ export namespace Format {
return result 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() { export function init() {
log.info("init") log.info("init")
Bus.subscribe(File.Event.Edited, async (payload) => { Bus.subscribe(File.Event.Edited, async (payload) => {
+2 -1
View File
@@ -38,6 +38,7 @@ process.on("uncaughtException", (e) => {
const cli = yargs(hideBin(process.argv)) const cli = yargs(hideBin(process.argv))
.scriptName("opencode") .scriptName("opencode")
.help("help", "show help") .help("help", "show help")
.alias("help", "h")
.version("version", "show version number", Installation.VERSION) .version("version", "show version number", Installation.VERSION)
.alias("version", "v") .alias("version", "v")
.option("print-logs", { .option("print-logs", {
@@ -140,5 +141,5 @@ try {
// Most notably, some docker-container-based MCP servers don't handle such signals unless // Most notably, some docker-container-based MCP servers don't handle such signals unless
// run using `docker run --init`. // run using `docker run --init`.
// Explicitly exit to avoid any hanging subprocesses. // Explicitly exit to avoid any hanging subprocesses.
process.exit(); process.exit()
} }
@@ -5,6 +5,10 @@ import { LSP } from "../lsp"
import { FileWatcher } from "../file/watcher" import { FileWatcher } from "../file/watcher"
import { File } from "../file" import { File } from "../file"
import { Flag } from "../flag/flag" 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() { export async function InstanceBootstrap() {
if (Flag.OPENCODE_EXPERIMENTAL_NO_BOOTSTRAP) return if (Flag.OPENCODE_EXPERIMENTAL_NO_BOOTSTRAP) return
@@ -14,4 +18,10 @@ export async function InstanceBootstrap() {
await LSP.init() await LSP.init()
FileWatcher.init() FileWatcher.init()
File.init() File.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
await Project.setInitialized(Instance.project.id)
}
})
} }
+6 -2
View File
@@ -12,7 +12,11 @@ const context = Context.create<Context>("instance")
const cache = new Map<string, Context>() const cache = new Map<string, Context>()
export const Instance = { export const Instance = {
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> { async provide<R>(input: {
directory: string
init?: () => Promise<any>
fn: () => R
}): Promise<R> {
let existing = cache.get(input.directory) let existing = cache.get(input.directory)
if (!existing) { if (!existing) {
const project = await Project.fromDirectory(input.directory) const project = await Project.fromDirectory(input.directory)
@@ -24,8 +28,8 @@ export const Instance = {
} }
return context.provide(existing, async () => { return context.provide(existing, async () => {
if (!cache.has(input.directory)) { if (!cache.has(input.directory)) {
await input.init?.()
cache.set(input.directory, existing) cache.set(input.directory, existing)
await input.init?.()
} }
return input.fn() return input.fn()
}) })
+21
View File
@@ -20,6 +20,7 @@ import { Ripgrep } from "../file/ripgrep"
import { Config } from "../config/config" import { Config } from "../config/config"
import { File } from "../file" import { File } from "../file"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { Format } from "../format"
import { MessageV2 } from "../session/message-v2" import { MessageV2 } from "../session/message-v2"
import { TuiRoute } from "./tui" import { TuiRoute } from "./tui"
import { Permission } from "../permission" import { Permission } from "../permission"
@@ -1336,6 +1337,26 @@ export namespace Server {
return c.json(await LSP.status()) 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( .post(
"/tui/append-prompt", "/tui/append-prompt",
describeRoute({ describeRoute({
+14 -18
View File
@@ -2,8 +2,6 @@ import { Decimal } from "decimal.js"
import z from "zod" import z from "zod"
import { type LanguageModelUsage, type ProviderMetadata } from "ai" import { type LanguageModelUsage, type ProviderMetadata } from "ai"
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
@@ -14,11 +12,11 @@ import { Share } from "../share/share"
import { Storage } from "../storage/storage" import { Storage } from "../storage/storage"
import { Log } from "../util/log" import { Log } from "../util/log"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Project } from "../project/project"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { SessionPrompt } from "./prompt" import { SessionPrompt } from "./prompt"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { Command } from "../command"
export namespace Session { export namespace Session {
const log = Log.create({ service: "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 = { const result: Info = {
id: Identifier.descending("session", input.id), id: Identifier.descending("session", input.id),
version: Installation.VERSION, 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.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.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.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(), .toNumber(),
tokens, tokens,
} }
@@ -423,22 +428,13 @@ export namespace Session {
messageID: Identifier.schema("message"), messageID: Identifier.schema("message"),
}), }),
async (input) => { async (input) => {
await SessionPrompt.prompt({ await SessionPrompt.command({
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: input.messageID, messageID: input.messageID,
model: { model: input.providerID + "/" + input.modelID,
providerID: input.providerID, command: Command.Default.INIT,
modelID: input.modelID, arguments: "",
},
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
},
],
}) })
await Project.setInitialized(Instance.project.id)
}, },
) )
} }
+19 -7
View File
@@ -1593,6 +1593,7 @@ export namespace SessionPrompt {
let index = 0 let index = 0
template = template.replace(bashRegex, () => results[index++]) template = template.replace(bashRegex, () => results[index++])
} }
template = template.trim()
const parts = [ const parts = [
{ {
@@ -1657,6 +1658,8 @@ export namespace SessionPrompt {
})() })()
const agent = await Agent.get(agentName) const agent = await Agent.get(agentName)
let result: MessageV2.WithParts
if ((agent.mode === "subagent" && command.subtask !== false) || command.subtask === true) { if ((agent.mode === "subagent" && command.subtask !== false) || command.subtask === true) {
using abort = lock(input.sessionID) using abort = lock(input.sessionID)
@@ -1732,7 +1735,7 @@ export namespace SessionPrompt {
} }
await Session.updatePart(toolPart) await Session.updatePart(toolPart)
const result = await TaskTool.init().then((t) => const taskResult = await TaskTool.init().then((t) =>
t.execute(args, { t.execute(args, {
sessionID: input.sessionID, sessionID: input.sessionID,
abort: abort.signal, abort: abort.signal,
@@ -1760,16 +1763,15 @@ export namespace SessionPrompt {
}, },
input: toolPart.state.input, input: toolPart.state.input,
title: "", title: "",
metadata: result.metadata, metadata: taskResult.metadata,
output: result.output, output: taskResult.output,
} }
await Session.updatePart(toolPart) await Session.updatePart(toolPart)
} }
return { info: assistantMsg, parts: [toolPart] } result = { info: assistantMsg, parts: [toolPart] }
} } else {
result = await prompt({
return prompt({
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: input.messageID, messageID: input.messageID,
model, model,
@@ -1778,6 +1780,16 @@ export namespace SessionPrompt {
}) })
} }
Bus.publish(Command.Event.Executed, {
name: input.command,
sessionID: input.sessionID,
arguments: input.arguments,
messageID: result.info.id,
})
return result
}
async function ensureTitle(input: { async function ensureTitle(input: {
session: Session.Info session: Session.Info
message: MessageV2.WithParts message: MessageV2.WithParts
+7 -3
View File
@@ -6,10 +6,11 @@ import { generateText, type ModelMessage } from "ai"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { ProviderTransform } from "@/provider/transform" import { ProviderTransform } from "@/provider/transform"
import { SystemPrompt } from "./system" import { SystemPrompt } from "./system"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import path from "path"
import { Instance } from "@/project/instance"
export namespace SessionSummary { export namespace SessionSummary {
const log = Log.create({ service: "session.summary" }) const log = Log.create({ service: "session.summary" })
@@ -33,10 +34,13 @@ export namespace SessionSummary {
input.messages input.messages
.flatMap((x) => x.parts) .flatMap((x) => x.parts)
.filter((x) => x.type === "patch") .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) => 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) => { await Session.update(input.sessionID, (draft) => {
draft.summary = { draft.summary = {
+1 -1
View File
@@ -20,7 +20,7 @@ export const GrepTool = Tool.define("grep", {
const searchPath = params.path || Instance.directory const searchPath = params.path || Instance.directory
const rgPath = await Ripgrep.filepath() const rgPath = await Ripgrep.filepath()
const args = ["-nH", "--field-match-separator=|", params.pattern] const args = ["-nH", "--field-match-separator=|", "--regexp", params.pattern]
if (params.include) { if (params.include) {
args.push("--glob", params.include) args.push("--glob", params.include)
} }
+2 -2
View File
@@ -26,7 +26,7 @@ Usage notes:
Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above): Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above):
<example_agent_descriptions> <example_agent_descriptions>
"code-reviewer": use this agent after you are done writing a signficant piece of code "code-reviewer": use this agent after you are done writing a significant piece of code
"greeting-responder": use this agent when to respond to user greetings with a friendly joke "greeting-responder": use this agent when to respond to user greetings with a friendly joke
</example_agent_description> </example_agent_description>
@@ -45,7 +45,7 @@ function isPrime(n) {
} }
</code> </code>
<commentary> <commentary>
Since a signficant piece of code was written and the task was completed, now use the code-reviewer agent to review the code Since a significant piece of code was written and the task was completed, now use the code-reviewer agent to review the code
</commentary> </commentary>
assistant: Now let me use the code-reviewer agent to review the code assistant: Now let me use the code-reviewer agent to review the code
assistant: Uses the Task tool to launch the code-reviewer agent assistant: Uses the Task tool to launch the code-reviewer agent
+18 -6
View File
@@ -28,8 +28,14 @@ describe("Keybind.toString", () => {
}) })
test("should convert shift modifier to string", () => { test("should convert shift modifier to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: true, leader: false, name: "enter" } const info: Keybind.Info = {
expect(Keybind.toString(info)).toBe("shift+enter") ctrl: false,
meta: false,
shift: true,
leader: false,
name: "return",
}
expect(Keybind.toString(info)).toBe("shift+return")
}) })
test("should convert function key to string", () => { test("should convert function key to string", () => {
@@ -38,7 +44,13 @@ describe("Keybind.toString", () => {
}) })
test("should convert special key to string", () => { 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") expect(Keybind.toString(info)).toBe("pgup")
}) })
@@ -220,15 +232,15 @@ describe("Keybind.parse", () => {
]) ])
}) })
test("should parse shift+enter combination", () => { test("should parse shift+return combination", () => {
const result = Keybind.parse("shift+enter") const result = Keybind.parse("shift+return")
expect(result).toEqual([ expect(result).toEqual([
{ {
ctrl: false, ctrl: false,
meta: false, meta: false,
shift: true, shift: true,
leader: false, leader: false,
name: "enter", name: "return",
}, },
]) ])
}) })
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.0.2", "version": "1.0.13",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
+13 -2
View File
@@ -1,7 +1,18 @@
import { $ } from "bun" import { $ } from "bun"
import path from "path"
if (process.versions.bun !== "1.3.0") { const rootPkgPath = path.resolve(import.meta.dir, "../../../package.json")
throw new Error("This script requires bun@1.3.0") const rootPkg = await Bun.file(rootPkgPath).json()
const expectedBunVersion = rootPkg.packageManager?.split("@")[1]
if (!expectedBunVersion) {
throw new Error("packageManager field not found in root package.json")
}
if (process.versions.bun !== expectedBunVersion) {
throw new Error(
`This script requires bun@${expectedBunVersion}, but you are using bun@${process.versions.bun}`,
)
} }
const CHANNEL = const CHANNEL =
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.0.2", "version": "1.0.13",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
+17
View File
@@ -107,6 +107,8 @@ import type {
McpStatusResponses, McpStatusResponses,
LspStatusData, LspStatusData,
LspStatusResponses, LspStatusResponses,
FormatterStatusData,
FormatterStatusResponses,
TuiAppendPromptData, TuiAppendPromptData,
TuiAppendPromptResponses, TuiAppendPromptResponses,
TuiAppendPromptErrors, 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 { class Control extends _HeyApiClient {
/** /**
* Get the next TUI request from the queue * Get the next TUI request from the queue
@@ -1023,6 +1039,7 @@ export class OpencodeClient extends _HeyApiClient {
app = new App({ client: this._client }) app = new App({ client: this._client })
mcp = new Mcp({ client: this._client }) mcp = new Mcp({ client: this._client })
lsp = new Lsp({ client: this._client }) lsp = new Lsp({ client: this._client })
formatter = new Formatter({ client: this._client })
tui = new Tui({ client: this._client }) tui = new Tui({ client: this._client })
auth = new Auth({ client: this._client }) auth = new Auth({ client: this._client })
event = new Event({ client: this._client }) event = new Event({ client: this._client })
+63
View File
@@ -114,6 +114,14 @@ export type KeybindsConfig = {
* List available models * List available models
*/ */
model_list?: string model_list?: string
/**
* Next recently used model
*/
model_cycle_recent?: string
/**
* Previous recently used model
*/
model_cycle_recent_reverse?: string
/** /**
* List available commands * List available commands
*/ */
@@ -134,6 +142,10 @@ export type KeybindsConfig = {
* Clear input field * Clear input field
*/ */
input_clear?: string input_clear?: string
/**
* Forward delete
*/
input_forward_delete?: string
/** /**
* Paste from clipboard * Paste from clipboard
*/ */
@@ -146,6 +158,22 @@ export type KeybindsConfig = {
* Insert newline in input * Insert newline in input
*/ */
input_newline?: string input_newline?: string
/**
* Previous history item
*/
history_previous?: string
/**
* Previous history item
*/
history_next?: string
/**
* Next child session
*/
session_child_cycle?: string
/**
* Previous child session
*/
session_child_cycle_reverse?: string
} }
export type AgentConfig = { export type AgentConfig = {
@@ -1050,6 +1078,12 @@ export type LspStatus = {
status: "connected" | "error" status: "connected" | "error"
} }
export type FormatterStatus = {
name: string
extensions: Array<string>
enabled: boolean
}
export type EventTuiPromptAppend = { export type EventTuiPromptAppend = {
type: "tui.prompt.append" type: "tui.prompt.append"
properties: { properties: {
@@ -1228,6 +1262,16 @@ export type EventTodoUpdated = {
} }
} }
export type EventCommandExecuted = {
type: "command.executed"
properties: {
name: string
sessionID: string
arguments: string
messageID: string
}
}
export type EventSessionIdle = { export type EventSessionIdle = {
type: "session.idle" type: "session.idle"
properties: { properties: {
@@ -1290,6 +1334,7 @@ export type Event =
| EventFileEdited | EventFileEdited
| EventFileWatcherUpdated | EventFileWatcherUpdated
| EventTodoUpdated | EventTodoUpdated
| EventCommandExecuted
| EventSessionIdle | EventSessionIdle
| EventSessionCreated | EventSessionCreated
| EventSessionUpdated | EventSessionUpdated
@@ -2491,6 +2536,24 @@ export type LspStatusResponses = {
export type LspStatusResponse = LspStatusResponses[keyof 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 = { export type TuiAppendPromptData = {
body?: { body?: {
text: string text: string
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.0.2", "version": "1.0.13",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun run src/index.ts", "dev": "bun run src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.0.2", "version": "1.0.13",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/components/index.ts", ".": "./src/components/index.ts",
+3 -3
View File
@@ -6,12 +6,12 @@
color: var(--text-base); color: var(--text-base);
text-wrap: pretty; text-wrap: pretty;
/* text-14-regular */ /* text-12-regular */
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-base); font-size: var(--font-size-small);
font-style: normal; font-style: normal;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); /* 142.857% */ line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
&::-webkit-scrollbar { &::-webkit-scrollbar {
+107
View File
@@ -11,3 +11,110 @@
opacity: 1; opacity: 1;
} }
} }
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-up-text {
animation: fadeUp 0.4s ease-out forwards;
opacity: 0;
&:nth-child(1) {
animation-delay: 0.1s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
&:nth-child(4) {
animation-delay: 0.4s;
}
&:nth-child(5) {
animation-delay: 0.5s;
}
&:nth-child(6) {
animation-delay: 0.6s;
}
&:nth-child(7) {
animation-delay: 0.7s;
}
&:nth-child(8) {
animation-delay: 0.8s;
}
&:nth-child(9) {
animation-delay: 0.9s;
}
&:nth-child(10) {
animation-delay: 1s;
}
&:nth-child(11) {
animation-delay: 1.1s;
}
&:nth-child(12) {
animation-delay: 1.2s;
}
&:nth-child(13) {
animation-delay: 1.3s;
}
&:nth-child(14) {
animation-delay: 1.4s;
}
&:nth-child(15) {
animation-delay: 1.5s;
}
&:nth-child(16) {
animation-delay: 1.6s;
}
&:nth-child(17) {
animation-delay: 1.7s;
}
&:nth-child(18) {
animation-delay: 1.8s;
}
&:nth-child(19) {
animation-delay: 1.9s;
}
&:nth-child(20) {
animation-delay: 2s;
}
&:nth-child(21) {
animation-delay: 2.1s;
}
&:nth-child(22) {
animation-delay: 2.2s;
}
&:nth-child(23) {
animation-delay: 2.3s;
}
&:nth-child(24) {
animation-delay: 2.4s;
}
&:nth-child(25) {
animation-delay: 2.5s;
}
&:nth-child(26) {
animation-delay: 2.6s;
}
&:nth-child(27) {
animation-delay: 2.7s;
}
&:nth-child(28) {
animation-delay: 2.8s;
}
&:nth-child(29) {
animation-delay: 2.9s;
}
&:nth-child(30) {
animation-delay: 3s;
}
}
+1 -1
View File
@@ -63,7 +63,7 @@
--shadow-xs: var(--shadow-xs); --shadow-xs: var(--shadow-xs);
--shadow-md: var(--shadow-md); --shadow-md: var(--shadow-md);
--shadow-xs-border-selected: var(--shadow-xs-border-selected); --shadow-xs-border-select: var(--shadow-xs-border-select);
--animate-pulse: var(--animate-pulse); --animate-pulse: var(--animate-pulse);
} }
@@ -15,3 +15,99 @@
direction: rtl; direction: rtl;
text-align: left; text-align: left;
} }
@utility fade-up-text {
animation: fadeUp 0.4s ease-out forwards;
opacity: 0;
&:nth-child(1) {
animation-delay: 0.1s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
&:nth-child(4) {
animation-delay: 0.4s;
}
&:nth-child(5) {
animation-delay: 0.5s;
}
&:nth-child(6) {
animation-delay: 0.6s;
}
&:nth-child(7) {
animation-delay: 0.7s;
}
&:nth-child(8) {
animation-delay: 0.8s;
}
&:nth-child(9) {
animation-delay: 0.9s;
}
&:nth-child(10) {
animation-delay: 1s;
}
&:nth-child(11) {
animation-delay: 1.1s;
}
&:nth-child(12) {
animation-delay: 1.2s;
}
&:nth-child(13) {
animation-delay: 1.3s;
}
&:nth-child(14) {
animation-delay: 1.4s;
}
&:nth-child(15) {
animation-delay: 1.5s;
}
&:nth-child(16) {
animation-delay: 1.6s;
}
&:nth-child(17) {
animation-delay: 1.7s;
}
&:nth-child(18) {
animation-delay: 1.8s;
}
&:nth-child(19) {
animation-delay: 1.9s;
}
&:nth-child(20) {
animation-delay: 2s;
}
&:nth-child(21) {
animation-delay: 2.1s;
}
&:nth-child(22) {
animation-delay: 2.2s;
}
&:nth-child(23) {
animation-delay: 2.3s;
}
&:nth-child(24) {
animation-delay: 2.4s;
}
&:nth-child(25) {
animation-delay: 2.5s;
}
&:nth-child(26) {
animation-delay: 2.6s;
}
&:nth-child(27) {
animation-delay: 2.7s;
}
&:nth-child(28) {
animation-delay: 2.8s;
}
&:nth-child(29) {
animation-delay: 2.9s;
}
&:nth-child(30) {
animation-delay: 3s;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"version": "1.0.2", "version": "1.0.13",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+7 -6
View File
@@ -13,8 +13,8 @@ The new TUI works like the old one since it connects to the same opencode server
## Upgrading ## Upgrading
You will not be autoupgraded to 1.0 if you are currently using a previous You should not be autoupgraded to 1.0 if you are currently using a previous
version. version. However some older versions of OpenCode always grab latest.
To upgrade manually, run To upgrade manually, run
@@ -22,6 +22,11 @@ To upgrade manually, run
$ opencode upgrade 1.0.0 $ opencode upgrade 1.0.0
``` ```
To downgrade back to 0.x, run
```bash
$ opencode upgrade 0.15.31
```
--- ---
## UX changes ## UX changes
@@ -59,7 +64,3 @@ We removed some functionality that we weren't sure anyone actually used. If some
- project_init - project_init
- tool_details - tool_details
- thinking_blocks - thinking_blocks
- session_child_cycle
- session_child_cycle_reverse
- model_cycle_recent
- model_cycle_recent_reverse
+31 -10
View File
@@ -19,6 +19,28 @@ opencode run "Explain how closures work in JavaScript"
--- ---
### tui
Start the OpenCode terminal user interface.
```bash
opencode [project]
```
#### Flags
| Flag | Short | Description |
| ------------ | ----- | ------------------------------------------ |
| `--continue` | `-c` | Continue the last session |
| `--session` | `-s` | Session ID to continue |
| `--prompt` | `-p` | Prompt to use |
| `--model` | `-m` | Model to use in the form of provider/model |
| `--agent` | | Agent to use |
| `--port` | | Port to listen on |
| `--hostname` | | Hostname to listen on |
---
## Commands ## Commands
The OpenCode CLI also has the following commands. The OpenCode CLI also has the following commands.
@@ -165,12 +187,16 @@ opencode run Explain the use of context in Go
#### Flags #### Flags
| Flag | Short | Description | | Flag | Short | Description |
| ------------ | ----- | ------------------------------------------ | | ------------ | ----- | ------------------------------------------------------------------ |
| `--command` | | The command to run, use message for args |
| `--continue` | `-c` | Continue the last session | | `--continue` | `-c` | Continue the last session |
| `--session` | `-s` | Session ID to continue | | `--session` | `-s` | Session ID to continue |
| `--share` | | Share the session | | `--share` | | Share the session |
| `--model` | `-m` | Model to use in the form of provider/model | | `--model` | `-m` | Model to use in the form of provider/model |
| `--agent` | | Agent to use | | `--agent` | | Agent to use |
| `--file` | `-f` | File(s) to attach to message |
| `--format` | | Format: default (formatted) or json (raw JSON events) |
| `--title` | | Title for the session (uses truncated prompt if no value provided) |
--- ---
@@ -189,7 +215,7 @@ This starts an HTTP server that provides API access to opencode functionality wi
| Flag | Short | Description | | Flag | Short | Description |
| ------------ | ----- | --------------------- | | ------------ | ----- | --------------------- |
| `--port` | `-p` | Port to listen on | | `--port` | `-p` | Port to listen on |
| `--hostname` | `-h` | Hostname to listen on | | `--hostname` | | Hostname to listen on |
--- ---
@@ -221,18 +247,13 @@ opencode upgrade v0.1.48
--- ---
## Flags ## Global Flags
The opencode CLI takes the following global flags. The opencode CLI takes the following global flags.
| Flag | Short | Description | | Flag | Short | Description |
| -------------- | ----- | ------------------------------------------ | | -------------- | ----- | ------------------------------------ |
| `--help` | `-h` | Display help | | `--help` | `-h` | Display help |
| `--version` | | Print version number | | `--version` | `-v` | Print version number |
| `--print-logs` | | Print logs to stderr | | `--print-logs` | | Print logs to stderr |
| `--log-level` | | Log level (DEBUG, INFO, WARN, ERROR) | | `--log-level` | | Log level (DEBUG, INFO, WARN, ERROR) |
| `--prompt` | `-p` | Prompt to use |
| `--model` | `-m` | Model to use in the form of provider/model |
| `--agent` | | Agent to use |
| `--port` | | Port to listen on |
| `--hostname` | | Hostname to listen on |
+16 -14
View File
@@ -10,41 +10,43 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"keybinds": { "keybinds": {
"leader": "ctrl+x", "leader": "ctrl+x",
"app_help": "<leader>h", "app_exit": "ctrl+c,ctrl+d,<leader>q",
"app_exit": "ctrl+c,<leader>q",
"editor_open": "<leader>e", "editor_open": "<leader>e",
"theme_list": "<leader>t", "theme_list": "<leader>t",
"project_init": "<leader>i", "sidebar_toggle": "<leader>b",
"tool_details": "<leader>d", "status_view": "<leader>s",
"thinking_blocks": "<leader>b",
"session_export": "<leader>x", "session_export": "<leader>x",
"session_new": "<leader>n", "session_new": "<leader>n",
"session_list": "<leader>l", "session_list": "<leader>l",
"session_share": "<leader>s", "session_timeline": "<leader>g",
"session_share": "none",
"session_unshare": "none", "session_unshare": "none",
"session_interrupt": "esc", "session_interrupt": "escape",
"session_compact": "<leader>c", "session_compact": "<leader>c",
"session_child_cycle": "ctrl+right", "messages_page_up": "pageup",
"session_child_cycle_reverse": "ctrl+left", "messages_page_down": "pagedown",
"messages_page_up": "pgup",
"messages_page_down": "pgdown",
"messages_half_page_up": "ctrl+alt+u", "messages_half_page_up": "ctrl+alt+u",
"messages_half_page_down": "ctrl+alt+d", "messages_half_page_down": "ctrl+alt+d",
"messages_first": "ctrl+g", "messages_first": "ctrl+g,home",
"messages_last": "ctrl+alt+g", "messages_last": "ctrl+alt+g,end",
"messages_copy": "<leader>y", "messages_copy": "<leader>y",
"messages_undo": "<leader>u", "messages_undo": "<leader>u",
"messages_redo": "<leader>r", "messages_redo": "<leader>r",
"messages_toggle_conceal": "<leader>h",
"model_list": "<leader>m", "model_list": "<leader>m",
"model_cycle_recent": "f2", "model_cycle_recent": "f2",
"model_cycle_recent_reverse": "shift+f2", "model_cycle_recent_reverse": "shift+f2",
"command_list": "ctrl+p",
"agent_list": "<leader>a", "agent_list": "<leader>a",
"agent_cycle": "tab", "agent_cycle": "tab",
"agent_cycle_reverse": "shift+tab", "agent_cycle_reverse": "shift+tab",
"input_clear": "ctrl+c", "input_clear": "ctrl+c",
"input_forward_delete": "ctrl+d",
"input_paste": "ctrl+v", "input_paste": "ctrl+v",
"input_submit": "enter", "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. 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 ### LM Studio
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode", "name": "opencode",
"displayName": "opencode", "displayName": "opencode",
"description": "opencode for VS Code", "description": "opencode for VS Code",
"version": "1.0.2", "version": "1.0.13",
"publisher": "sst-dev", "publisher": "sst-dev",
"repository": { "repository": {
"type": "git", "type": "git",