Compare commits

..
57 Commits
Author SHA1 Message Date
adamdottv 7b0329f67f fix(tui): fetch tool more defensive
publish / publish (push) Has been cancelled
2025-06-20 09:00:28 -05:00
adamdottv 311b9c74dd fix(tui): typeahead open/close perf 2025-06-20 08:20:10 -05:00
adamdottv f7e8dd2ff8 chore: fix typescript issues 2025-06-20 07:48:42 -05:00
adamdottv 40b1dd7ef2 fix(tui): insert newline correctly positioned 2025-06-20 07:42:04 -05:00
adamdottv 261e76e0a3 fix(tui): input feels laggy 2025-06-20 07:31:45 -05:00
Dax Raad a300bfaccb docs: remove opencode.json 2025-06-20 01:00:15 -04:00
Dax Raad 41dba0db08 config validation
publish / publish (push) Has been cancelled
2025-06-20 00:57:28 -04:00
Rohan GodhaandGitHub 6674c6083a fix: phantom input bug on wsl (#200) 2025-06-19 20:08:56 -05:00
Tom WatkinsandGitHub f6afa2c6bb docs: fix typo in config.mdx (#218) 2025-06-19 21:08:21 -04:00
Dax Raad b2fb0508ea fix for azure models not liking tool definitions
publish / publish (push) Has been cancelled
2025-06-19 18:28:42 -04:00
Jay V 93f4252bb1 docs: tweak lander 2025-06-19 18:19:35 -04:00
JayandGitHub 46ab9c16dd docs: Update README.md 2025-06-19 18:19:06 -04:00
Dax Raad d869df4fee remove unused permission timeout
publish / publish (push) Has been cancelled
2025-06-19 18:00:53 -04:00
Dax Raad b99d4650ec temporarily disable project details in system prompt
publish / publish (push) Has been cancelled
2025-06-19 17:37:23 -04:00
Frank 261bb7f110 Infra: fix DO tag 2025-06-19 17:20:13 -04:00
Dax Raad 0515fbb260 fix gopls download spewing into terminal
publish / publish (push) Has been cancelled
2025-06-19 17:08:58 -04:00
adamdottv 88211d8c5b fix(tui): upgrade notification
publish / publish (push) Has been cancelled
2025-06-19 16:03:45 -05:00
Jay V a812f95b9d docs: share 2025-06-19 16:57:42 -04:00
adamdottv 3728a12bee fix(tui): better help on home
publish / publish (push) Has been cancelled
2025-06-19 15:56:28 -05:00
Jay V af07e51213 docs: tweak 2025-06-19 16:40:15 -04:00
Jay V 3113788c92 docs: copy 2025-06-19 16:39:36 -04:00
Jay V efb5fe6d4e docs: styles 2025-06-19 16:38:37 -04:00
Jay V 54dd6c644d docs: adding to config 2025-06-19 16:36:17 -04:00
Dax Raad 39ad8f2667 ignore: do migration 2025-06-19 16:32:32 -04:00
Jay V c4a2c84e53 docs: readme
publish / publish (push) Has been cancelled
2025-06-19 16:29:20 -04:00
Jay V 44fe012812 docs: edits 2025-06-19 16:28:11 -04:00
Jay V f5e7f079ea Copy changes 2025-06-19 16:28:03 -04:00
adamdottv 15a8936806 fix(tui): better tool titles 2025-06-19 15:11:53 -05:00
adamdottv 4e4cff49c0 feat(tui): better task tool rendering
publish / publish (push) Has been cancelled
2025-06-19 15:02:13 -05:00
adamdottv 5540503bee fix(tui): sorted tool arg maps 2025-06-19 14:07:33 -05:00
adamdottv 193718034b fix: typescript error 2025-06-19 13:57:25 -05:00
adamdottv 72108c0296 fix(tui): sorted tool arg maps 2025-06-19 13:56:09 -05:00
Dax Raad ec1c9f8cd1 use production share url
publish / publish (push) Has been cancelled
2025-06-19 14:21:00 -04:00
Dax Raad a85b0a370e ci: share 2025-06-19 13:26:15 -04:00
Dax Raadandopencode e7784d2864 add schema descriptions to config fields
Enhance configuration schema with descriptive text for all fields to improve developer experience and auto-generated documentation.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
2025-06-19 13:12:13 -04:00
Dax Raad 97c4815444 fix task agent performance issues
publish / publish (push) Has been cancelled
2025-06-19 13:00:57 -04:00
Dax Raad 7d1a1663c8 allow selecting model and continuing previous session for opencode run 2025-06-19 13:00:57 -04:00
adamdottv 24c0ce6e53 fix(tui): vscode and mac terminal colors 2025-06-19 11:46:08 -05:00
adamdottv 4cdc86612c fix(tui): overlay border backgrounds 2025-06-19 11:41:30 -05:00
Jay V f1f3f8d12c ignore: share version 2025-06-19 12:20:30 -04:00
adamdottv e78d3b54bf chore: cleanup logs 2025-06-19 10:52:45 -05:00
adamdottv f8a7cd372d fix(tui): toast placement and overlay rendering 2025-06-19 10:45:10 -05:00
adamdottv f48eac638d feat(tui): more toast messages 2025-06-19 10:41:59 -05:00
adamdottv e1f12f93eb feat(tui): toast messages 2025-06-19 10:12:29 -05:00
Dax Raad 7ca8334a8b fix webfetch tool when returning html as text
publish / publish (push) Has been cancelled
2025-06-19 10:43:54 -04:00
Dax Raad f1a2b2eba4 support token caching for anthropic via openrouter
publish / publish (push) Has been cancelled
2025-06-19 10:32:14 -04:00
adamdottv 4b132656df feat(tui): copy share url to clipboard
publish / publish (push) Has been cancelled
2025-06-19 09:06:25 -05:00
Dax Raad 26bab00dab remove opencode_ prefixes from tool names. unfortunately this will break
publish / publish (push) Has been cancelled
all old sessions and share links. we'll be more backwards compatible in
the future once we're more stable.
2025-06-19 09:59:44 -04:00
adamdottv 568c04753e feat(tui): expand input to fit message
publish / publish (push) Has been cancelled
2025-06-19 08:45:27 -05:00
Dax Raad 4a06e164d2 ensure session.info is synced when shared
publish / publish (push) Has been cancelled
2025-06-19 09:41:11 -04:00
adamdottv c57b52c300 fix: include schema in converted toml config 2025-06-19 06:02:02 -05:00
Guillermo Antony Cava NuñezandGitHub 0b8f48f17f Fixes tool tip layering (#199) 2025-06-19 00:23:29 -04:00
Dax Raad 3862184ccb hooks 2025-06-19 00:20:03 -04:00
Frank 8619c50976 Update SST 2025-06-18 23:38:06 -04:00
JoshandGitHub bb6b56b72a fix: incorrect command on main screen for exiting application (#201)
publish / publish (push) Has been cancelled
2025-06-18 23:19:43 -04:00
Dax Raad 1252b65166 stop loading models.dev format from global config
publish / publish (push) Has been cancelled
2025-06-18 23:08:51 -04:00
Dax Raad 6840276dad docs: update README 2025-06-18 23:03:54 -04:00
85 changed files with 4274 additions and 1150 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- dev
- production
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -20,6 +21,6 @@ jobs:
- run: bun install
- run: bun sst deploy --stage=dev
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+7 -127
View File
@@ -7,19 +7,17 @@
</picture>
</a>
</p>
<p align="center">AI coding agent, built for the terminal.</p>
<p align="center">
<a href="https://opencode.ai/docs"><img alt="View docs" src="https://img.shields.io/badge/view-docs-blue?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
[![opencode Terminal UI](packages/web/src/assets/themes/opencode.png)](https://opencode.ai)
---
AI coding agent, built for the terminal.
**Note:** Version 0.1.x is a full rewrite, and we do not have proper documentation for it yet. Should have this out week of June 17th 2025.
[![opencode Terminal UI](screenshot.png)](https://opencode.ai)
### Installation
```bash
@@ -34,127 +32,9 @@ paru -S opencode-bin # Arch Linux
> **Note:** Remove versions older than 0.1.x before installing
### Providers
### Documentation
The recommended approach is to sign up for Claude Pro or Max, run `opencode auth login`, and select Anthropic. It's the most cost-effective way to use opencode.
opencode is powered by the provider list at [Models.dev](https://models.dev), so you can use `opencode auth login` to configure API keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`.
```bash
$ opencode auth login
┌ Add credential
◆ Select provider
│ ● Anthropic (recommended)
│ ○ OpenAI
│ ○ Google
│ ○ Amazon Bedrock
│ ○ Azure
│ ○ DeepSeek
│ ○ Groq
│ ...
```
The Models.dev dataset is also used to detect common environment variables like `OPENAI_API_KEY` to autoload that provider.
If there are additional providers you want to use you can submit a PR to the [Models.dev repo](https://github.com/sst/models.dev). If configuring just for yourself check out the Config section below.
### Config
Config is optional and can be placed in the root of your repo or globally in `~/.config/opencode/config`. It can be checked in and shared with your team.
```json title="opencode.json"
{
"$schema": "http://opencode.ai/config.json"
"theme": "opencode",
"model": "anthropic/claude-sonnet-4-20250514" // format is provider/model
"autoshare": false,
"autoupdate": true,
}
```
#### Keybinds
You can configure custom keybinds, the values listed below are the defaults.
```json title="opencode.json"
{
"$schema": "http://opencode.ai/config.json",
"keybinds": {
"leader": "ctrl+x",
"help": "<leader>h",
"editor_open": "<leader>e",
"session_new": "<leader>n",
"session_list": "<leader>l",
"session_share": "<leader>s",
"session_interrupt": "esc",
"session_compact": "<leader>c",
"tool_details": "<leader>d",
"model_list": "<leader>m",
"theme_list": "<leader>t",
"project_init": "<leader>i",
"input_clear": "ctrl+c",
"input_paste": "ctrl+v",
"input_submit": "enter",
"input_newline": "shift+enter",
"history_previous": "up",
"history_next": "down",
"messages_page_up": "pgup",
"messages_page_down": "pgdown",
"messages_half_page_up": "ctrl+alt+u",
"messages_half_page_down": "ctrl+alt+d",
"messages_previous": "ctrl+alt+k",
"messages_next": "ctrl+alt+j",
"messages_first": "ctrl+g",
"messages_last": "ctrl+alt+g",
"app_exit": "ctrl+c,<leader>q"
}
}
```
#### MCP
```json title="opencode.json"
{
"$schema": "http://opencode.ai/config.json",
"mcp": {
"localmcp": {
"type": "local",
"command": ["bun", "x", "my-mcp-command"],
"environment": {
"MY_ENV_VAR": "my_env_var_value"
}
},
"remotemcp": {
"type": "remote",
"url": "https://my-mcp-server.com"
}
}
}
```
#### Providers
You can use opencode with any provider listed at [here](https://ai-sdk.dev/providers/ai-sdk-providers). Be sure to specify the npm package to use to load the provider. Remember most popular providers are preloaded from [models.dev](https://models.dev)
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "http://localhost:11434/v1"
},
"models": {
"llama2": {}
}
}
}
}
```
For more info on how to configure opencode [**head over to our docs**](https://opencode.ai/docs).
### Contributing
@@ -163,7 +43,7 @@ To run opencode locally you need.
- Bun
- Golang 1.24.x
To run.
And run.
```bash
$ bun install
+13 -10
View File
@@ -5,7 +5,7 @@
"name": "opencode",
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.4",
"sst": "3.17.6",
},
},
"packages/function": {
@@ -43,6 +43,7 @@
"yargs": "18.0.0",
"zod": "catalog:",
"zod-openapi": "4.2.4",
"zod-validation-error": "3.5.2",
},
"devDependencies": {
"@ai-sdk/anthropic": "1.2.12",
@@ -1425,23 +1426,23 @@
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
"sst": ["sst@3.17.4", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.4", "sst-darwin-x64": "3.17.4", "sst-linux-arm64": "3.17.4", "sst-linux-x64": "3.17.4", "sst-linux-x86": "3.17.4", "sst-win32-arm64": "3.17.4", "sst-win32-x64": "3.17.4", "sst-win32-x86": "3.17.4" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-WpAws1ASJIilKC9/DGBhZ5wk2I4gtlzHXKpuwPC25bHWjqllv1jZiehIYhhN0PpV2pV8xCvqzyN8Gdm3J4EWQg=="],
"sst": ["sst@3.17.6", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.6", "sst-darwin-x64": "3.17.6", "sst-linux-arm64": "3.17.6", "sst-linux-x64": "3.17.6", "sst-linux-x86": "3.17.6", "sst-win32-arm64": "3.17.6", "sst-win32-x64": "3.17.6", "sst-win32-x86": "3.17.6" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-p+AcqwfYQUdkxeRjCikQoTMviPCBiGoU7M0vcV6GDVmVis8hzhVw4EFfHTafZC+aWfy1Ke2UQi66vZlEVWuEqA=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IJansQWlPdiaQNsJw3FQ+Q/ZXN1hzrq2Q31xG4l2HhA1doj1C3y+6s57vu4cTRDFo2OwBlC4+zlQBJHsOYGhrA=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6tb7KlcPR7PTi3ofQv8dX/n6Jf7pNP9VfrnYL4HBWnWrcYaZeJ5MWobILfIJ/y2jHgoqmg9e5C3266Eds0JQyw=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-mHd26/AtaQ79ajqzsutRhgEjkCxX+bXgW4KJIN0AGT3110fo2OL0x2UXmfX+sxSWOFHvJQsjFjFm4CLtQSxyBg=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-lFakq6/EgTuBSjbl8Kry4pfgAPEIyn6o7ZkyRz3hz5331wUaX88yfjs3tL9JQ8Ey6jBUYxwhP/Q1n7fzIG046g=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-unaNWOY3oEI/jUUG47/2Gbreaoi/D/rLsTPeKyYEWhWEBWCojns7LfMQs1bgW0qjBGmazB2IJD4NVYhYqYQxqQ=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-SdTxXMbTEdiwOqp37w31kXv97vHqSx3oK9h/76lKg7V9k5JxPJ6JMefPLhoKWwK0Zh6AndY2zo2oRoEv4SIaDw=="],
"sst-linux-x64": ["sst-linux-x64@3.17.4", "", { "os": "linux", "cpu": "x64" }, "sha512-zoErI6dVoRxWcmoVVrzNJWKEqfUF/MyQInEkGROGY2YsFFzOM5RD5Dsdm9q6oDGwx+NxFAhQWc8/8C+OmoW1nA=="],
"sst-linux-x64": ["sst-linux-x64@3.17.6", "", { "os": "linux", "cpu": "x64" }, "sha512-qneh7uWDiTUYx8X1Y3h2YVw3SJ0ybBBlRrVybIvCM09JqQ8+qq/XjKXGzA/3/EF0Jr7Ug8cARSn9CwxhdQGN7Q=="],
"sst-linux-x86": ["sst-linux-x86@3.17.4", "", { "os": "linux", "cpu": "none" }, "sha512-7ZHS2rxzxVAxMFW3u5+GMRGGACaBMuLht8JYxqruD8mFVqk9UaPQgrFKIHGKWHLBJLVnF2AdwmlHOcEKP+UJWA=="],
"sst-linux-x86": ["sst-linux-x86@3.17.6", "", { "os": "linux", "cpu": "none" }, "sha512-pU3D5OeqnmfxGqN31DxuwWnc1OayxhkErnITHhZ39D0MTiwbIgCapH26FuLW8B08/uxJWG8djUlOboCRhSBvWA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-q4cedr6WD3NqeQkDvmAsIgMgPIjziIWy81wA3ZmnY6UT0jFgFus23ppLIi6F4BFJfOygvAP2PeGrRR3o8giclw=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rr3RTYWAsH9sM9CbM/sAZCk7dB1OsSAljjJuuHMvdSAYW3RDpXEza0PBJGxnBID2eOrpswEchzMPL2d8LtL7oA=="],
"sst-win32-x64": ["sst-win32-x64@3.17.4", "", { "os": "win32", "cpu": "x64" }, "sha512-sSQL041YCusZ8/0ynYGe9DCmPYVZOFsemXKUA9tX4IGSDqXae1FN0Sj7HQ17JyY24UUirY1zR7LFk+7KrP6wiA=="],
"sst-win32-x64": ["sst-win32-x64@3.17.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yZ3roxwI0Wve9PFzdrrF1kfzCmIMFCCoa8qKeXY7LxCJ4QQIqHbCOccLK1Wv/MIU/mcZHWXTQVCLHw77uaa0GQ=="],
"sst-win32-x86": ["sst-win32-x86@3.17.4", "", { "os": "win32", "cpu": "none" }, "sha512-WhjsD2dkA2fbQ03CgwIJb+2p0osll2PTXlr7HC3L+H8wG2DgLFPjoE+6N8n6r2dVMVaDzuNwy/7J8hRB29blaw=="],
"sst-win32-x86": ["sst-win32-x86@3.17.6", "", { "os": "win32", "cpu": "none" }, "sha512-zV7TJWPJN9PmIXr15iXFSs0tbGsa52oBR3+xiKrUj2qj9XsZe7HBFwskRnHyiFq0durZY9kk9ZtoVlpuUuzr1g=="],
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],
@@ -1655,6 +1656,8 @@
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
"zod-validation-error": ["zod-validation-error@3.5.2", "", { "peerDependencies": { "zod": "^3.25.0" } }, "sha512-mdi7YOLtram5dzJ5aDtm1AG9+mxRma1iaMrZdYIpFO7epdKBUwLHIxTF8CPDeCQ828zAXYtizrKlEJAtzgfgrw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
+3 -2
View File
@@ -23,8 +23,9 @@ export const api = new sst.cloudflare.Worker("Api", {
},
])
args.migrations = {
oldTag: "v1",
newTag: "v1",
// Note: when releasing the next tag, make sure all stages use tag v2
oldTag: $app.stage === "production" ? "" : "v1",
newTag: $app.stage === "production" ? "" : "v1",
//newSqliteClasses: ["SyncServer"],
}
},
+1 -3
View File
@@ -1,5 +1,3 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {},
"provider": {}
"$schema": "https://opencode.ai/config.json"
}
+3 -2
View File
@@ -5,7 +5,8 @@
"type": "module",
"packageManager": "bun@1.2.14",
"scripts": {
"typecheck": "bun run --filter='*' typecheck"
"typecheck": "bun run --filter='*' typecheck",
"postinstall": "./scripts/hooks"
},
"workspaces": {
"packages": [
@@ -20,7 +21,7 @@
},
"devDependencies": {
"prettier": "3.5.3",
"sst": "3.17.4"
"sst": "3.17.6"
},
"repository": {
"type": "git",
+1 -1
View File
@@ -120,7 +120,7 @@ export default {
return new Response(
JSON.stringify({
secret,
url: "https://dev.opencode.ai/s/" + short,
url: "https://opencode.ai/s/" + short,
}),
{
headers: { "Content-Type": "application/json" },
+74 -37
View File
@@ -2,97 +2,127 @@
"type": "object",
"properties": {
"$schema": {
"type": "string"
"type": "string",
"description": "JSON schema reference for configuration validation"
},
"theme": {
"type": "string"
"type": "string",
"description": "Theme name to use for the interface"
},
"keybinds": {
"type": "object",
"properties": {
"leader": {
"type": "string"
"type": "string",
"description": "Leader key for keybind combinations"
},
"help": {
"type": "string"
"type": "string",
"description": "Show help dialog"
},
"editor_open": {
"type": "string"
"type": "string",
"description": "Open external editor"
},
"session_new": {
"type": "string"
"type": "string",
"description": "Create a new session"
},
"session_list": {
"type": "string"
"type": "string",
"description": "List all sessions"
},
"session_share": {
"type": "string"
"type": "string",
"description": "Share current session"
},
"session_interrupt": {
"type": "string"
"type": "string",
"description": "Interrupt current session"
},
"session_compact": {
"type": "string"
"type": "string",
"description": "Toggle compact mode for session"
},
"tool_details": {
"type": "string"
"type": "string",
"description": "Show tool details"
},
"model_list": {
"type": "string"
"type": "string",
"description": "List available models"
},
"theme_list": {
"type": "string"
"type": "string",
"description": "List available themes"
},
"project_init": {
"type": "string"
"type": "string",
"description": "Initialize project configuration"
},
"input_clear": {
"type": "string"
"type": "string",
"description": "Clear input field"
},
"input_paste": {
"type": "string"
"type": "string",
"description": "Paste from clipboard"
},
"input_submit": {
"type": "string"
"type": "string",
"description": "Submit input"
},
"input_newline": {
"type": "string"
"type": "string",
"description": "Insert newline in input"
},
"history_previous": {
"type": "string"
"type": "string",
"description": "Navigate to previous history item"
},
"history_next": {
"type": "string"
"type": "string",
"description": "Navigate to next history item"
},
"messages_page_up": {
"type": "string"
"type": "string",
"description": "Scroll messages up by one page"
},
"messages_page_down": {
"type": "string"
"type": "string",
"description": "Scroll messages down by one page"
},
"messages_half_page_up": {
"type": "string"
"type": "string",
"description": "Scroll messages up by half page"
},
"messages_half_page_down": {
"type": "string"
"type": "string",
"description": "Scroll messages down by half page"
},
"messages_previous": {
"type": "string"
"type": "string",
"description": "Navigate to previous message"
},
"messages_next": {
"type": "string"
"type": "string",
"description": "Navigate to next message"
},
"messages_first": {
"type": "string"
"type": "string",
"description": "Navigate to first message"
},
"messages_last": {
"type": "string"
"type": "string",
"description": "Navigate to last message"
},
"app_exit": {
"type": "string"
"type": "string",
"description": "Exit the application"
}
},
"additionalProperties": false
"additionalProperties": false,
"description": "Custom keybind configurations"
},
"autoshare": {
"type": "boolean",
@@ -207,7 +237,8 @@
"models"
],
"additionalProperties": false
}
},
"description": "Custom provider configurations and model overrides"
},
"mcp": {
"type": "object",
@@ -218,19 +249,22 @@
"properties": {
"type": {
"type": "string",
"const": "local"
"const": "local",
"description": "Type of MCP server connection"
},
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Command and arguments to run the MCP server"
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"description": "Environment variables to set when running the MCP server"
}
},
"required": [
@@ -244,10 +278,12 @@
"properties": {
"type": {
"type": "string",
"const": "remote"
"const": "remote",
"description": "Type of MCP server connection"
},
"url": {
"type": "string"
"type": "string",
"description": "URL of the remote MCP server"
}
},
"required": [
@@ -257,7 +293,8 @@
"additionalProperties": false
}
]
}
},
"description": "MCP (Model Context Protocol) server configurations"
}
},
"additionalProperties": false,
+2 -1
View File
@@ -42,6 +42,7 @@
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
"zod-openapi": "4.2.4"
"zod-openapi": "4.2.4",
"zod-validation-error": "3.5.2"
}
}
+2 -2
View File
@@ -80,9 +80,9 @@ function main() {
// Create symlink to the actual binary
fs.symlinkSync(binaryPath, binScript)
console.log(`OpenCode binary symlinked: ${binScript} -> ${binaryPath}`)
console.log(`opencode binary symlinked: ${binScript} -> ${binaryPath}`)
} catch (error) {
console.error("Failed to create OpenCode binary symlink:", error.message)
console.error("Failed to create opencode binary symlink:", error.message)
process.exit(1)
}
}
+2 -2
View File
@@ -9,14 +9,14 @@ import { map, pipe, sortBy, values } from "remeda"
export const AuthCommand = cmd({
command: "auth",
describe: "manage credentials",
describe: "Manage credentials",
builder: (yargs) =>
yargs
.command(AuthLoginCommand)
.command(AuthLogoutCommand)
.command(AuthListCommand)
.demandCommand(),
async handler() {},
async handler() { },
})
export const AuthListCommand = cmd({
+47 -20
View File
@@ -11,35 +11,47 @@ import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
const TOOL: Record<string, [string, string]> = {
opencode_todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
opencode_todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
opencode_bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
opencode_edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
opencode_glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
opencode_grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
opencode_list: ["List", UI.Style.TEXT_INFO_BOLD],
opencode_read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
opencode_write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
list: ["List", UI.Style.TEXT_INFO_BOLD],
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
websearch: ["Search", UI.Style.TEXT_DIM_BOLD],
}
export const RunCommand = cmd({
command: "run [message..]",
describe: "Run OpenCode with a message",
describe: "Run opencode with a message",
builder: (yargs: Argv) => {
return yargs
.positional("message", {
describe: "Message to send",
describe: "message to send",
type: "string",
array: true,
default: [],
})
.option("continue", {
alias: ["c"],
describe: "Continue the last session",
type: "boolean",
})
.option("session", {
alias: ["s"],
describe: "Session ID to continue",
type: "string",
})
.option("share", {
type: "boolean",
describe: "Share the session",
describe: "share the session",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "Model to use in the format of provider/model",
})
},
handler: async (args) => {
@@ -50,9 +62,22 @@ export const RunCommand = cmd({
},
async () => {
await Share.init()
const session = args.session
? await Session.get(args.session)
: await Session.create()
const session = await (async () => {
if (args.continue) {
const first = await Session.list().next()
if (first.done) return
return first.value
}
if (args.session) return Session.get(args.session)
return Session.create()
})()
if (!session) {
UI.error("Session not found")
return
}
UI.empty()
UI.println(UI.logo())
@@ -65,13 +90,15 @@ export const RunCommand = cmd({
await Session.share(session.id)
UI.println(
UI.Style.TEXT_INFO_BOLD +
"~ https://dev.opencode.ai/s/" +
session.id.slice(-8),
"~ https://opencode.ai/s/" +
session.id.slice(-8),
)
}
UI.empty()
const { providerID, modelID } = await Provider.defaultModel()
const { providerID, modelID } = args.model
? Provider.parseModel(args.model)
: await Provider.defaultModel()
UI.println(
UI.Style.TEXT_NORMAL_BOLD + "@ ",
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
@@ -82,8 +109,8 @@ export const RunCommand = cmd({
UI.println(
color + `|`,
UI.Style.TEXT_NORMAL +
UI.Style.TEXT_DIM +
` ${type.padEnd(7, " ")}`,
UI.Style.TEXT_DIM +
` ${type.padEnd(7, " ")}`,
"",
UI.Style.TEXT_NORMAL + title,
)
+2 -2
View File
@@ -5,10 +5,10 @@ import { Installation } from "../../installation"
export const UpgradeCommand = {
command: "upgrade [target]",
describe: "Upgrade opencode to the latest version or a specific version",
describe: "upgrade opencode to the latest version or a specific version",
builder: (yargs: Argv) => {
return yargs.positional("target", {
describe: "Specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')",
describe: "specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')",
type: "string",
})
},
+13
View File
@@ -0,0 +1,13 @@
import { Config } from "../config/config"
export function FormatError(input: unknown) {
if (Config.JsonError.isInstance(input))
return `Config file at ${input.data.path} is not valid JSON`
if (Config.InvalidError.isInstance(input))
return [
`Config file at ${input.data.path} is invalid`,
...(input.data.issues?.map(
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
) ?? []),
].join("\n")
}
+4
View File
@@ -71,4 +71,8 @@ export namespace UI {
})
})
}
export function error(message: string) {
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}
}
+129 -63
View File
@@ -8,6 +8,7 @@ import { mergeDeep } from "remeda"
import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
import { NamedError } from "../util/error"
export namespace Config {
const log = Log.create({ service: "config" })
@@ -15,27 +16,9 @@ export namespace Config {
export const state = App.state("config", async (app) => {
let result = await global()
for (const file of ["opencode.jsonc", "opencode.json"]) {
const [resolved] = await Filesystem.findUp(
file,
app.path.cwd,
app.path.root,
)
if (!resolved) continue
try {
result = mergeDeep(
result,
await import(resolved).then((mod) => Info.parse(mod.default)),
)
log.info("found", { path: resolved })
break
} catch (e) {
if (e instanceof z.ZodError) {
for (const issue of e.issues) {
log.info(issue.message)
}
throw e
}
continue
const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
for (const resolved of found.toReversed()) {
result = mergeDeep(result, await load(resolved))
}
}
log.info("loaded", result)
@@ -44,19 +27,27 @@ export namespace Config {
export const McpLocal = z
.object({
type: z.literal("local"),
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
type: z.literal("local").describe("Type of MCP server connection"),
command: z
.string()
.array()
.describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
})
.strict()
.openapi({
ref: "Config.McpLocal",
})
export const McpRemote = z
.object({
type: z.literal("remote"),
url: z.string(),
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
})
.strict()
.openapi({
ref: "Config.McpRemote",
})
@@ -66,42 +57,85 @@ export namespace Config {
export const Keybinds = z
.object({
leader: z.string().optional(),
help: z.string().optional(),
editor_open: z.string().optional(),
session_new: z.string().optional(),
session_list: z.string().optional(),
session_share: z.string().optional(),
session_interrupt: z.string().optional(),
session_compact: z.string().optional(),
tool_details: z.string().optional(),
model_list: z.string().optional(),
theme_list: z.string().optional(),
project_init: z.string().optional(),
input_clear: z.string().optional(),
input_paste: z.string().optional(),
input_submit: z.string().optional(),
input_newline: z.string().optional(),
history_previous: z.string().optional(),
history_next: z.string().optional(),
messages_page_up: z.string().optional(),
messages_page_down: z.string().optional(),
messages_half_page_up: z.string().optional(),
messages_half_page_down: z.string().optional(),
messages_previous: z.string().optional(),
messages_next: z.string().optional(),
messages_first: z.string().optional(),
messages_last: z.string().optional(),
app_exit: z.string().optional(),
leader: z
.string()
.optional()
.describe("Leader key for keybind combinations"),
help: z.string().optional().describe("Show help dialog"),
editor_open: z.string().optional().describe("Open external editor"),
session_new: z.string().optional().describe("Create a new session"),
session_list: z.string().optional().describe("List all sessions"),
session_share: z.string().optional().describe("Share current session"),
session_interrupt: z
.string()
.optional()
.describe("Interrupt current session"),
session_compact: z
.string()
.optional()
.describe("Toggle compact mode for session"),
tool_details: z.string().optional().describe("Show tool details"),
model_list: z.string().optional().describe("List available models"),
theme_list: z.string().optional().describe("List available themes"),
project_init: z
.string()
.optional()
.describe("Initialize project configuration"),
input_clear: z.string().optional().describe("Clear input field"),
input_paste: z.string().optional().describe("Paste from clipboard"),
input_submit: z.string().optional().describe("Submit input"),
input_newline: z.string().optional().describe("Insert newline in input"),
history_previous: z
.string()
.optional()
.describe("Navigate to previous history item"),
history_next: z
.string()
.optional()
.describe("Navigate to next history item"),
messages_page_up: z
.string()
.optional()
.describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.describe("Scroll messages down by one page"),
messages_half_page_up: z
.string()
.optional()
.describe("Scroll messages up by half page"),
messages_half_page_down: z
.string()
.optional()
.describe("Scroll messages down by half page"),
messages_previous: z
.string()
.optional()
.describe("Navigate to previous message"),
messages_next: z.string().optional().describe("Navigate to next message"),
messages_first: z
.string()
.optional()
.describe("Navigate to first message"),
messages_last: z.string().optional().describe("Navigate to last message"),
app_exit: z.string().optional().describe("Exit the application"),
})
.strict()
.openapi({
ref: "Config.Keybinds",
})
export const Info = z
.object({
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: Keybinds.optional(),
$schema: z
.string()
.optional()
.describe("JSON schema reference for configuration validation"),
theme: z
.string()
.optional()
.describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
autoshare: z
.boolean()
.optional()
@@ -127,9 +161,14 @@ export namespace Config {
options: z.record(z.any()).optional(),
}),
)
.optional(),
mcp: z.record(z.string(), Mcp).optional(),
.optional()
.describe("Custom provider configurations and model overrides"),
mcp: z
.record(z.string(), Mcp)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
})
.strict()
.openapi({
ref: "Config.Info",
})
@@ -137,10 +176,7 @@ export namespace Config {
export type Info = z.output<typeof Info>
export const global = lazy(async () => {
let result = await Bun.file(path.join(Global.Path.config, "config.json"))
.json()
.then((mod) => Info.parse(mod))
.catch(() => ({}) as Info)
let result = await load(path.join(Global.Path.config, "config.json"))
await import(path.join(Global.Path.config, "config"), {
with: {
@@ -150,6 +186,7 @@ export namespace Config {
.then(async (mod) => {
const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await Bun.write(
path.join(Global.Path.config, "config.json"),
@@ -158,9 +195,38 @@ export namespace Config {
await fs.unlink(path.join(Global.Path.config, "config"))
})
.catch(() => {})
return Info.parse(result)
return result
})
async function load(path: string) {
const data = await Bun.file(path)
.json()
.catch((err) => {
if (err.code === "ENOENT") return {}
throw new JsonError({ path }, { cause: err })
})
const parsed = Info.safeParse(data)
if (parsed.success) return parsed.data
throw new InvalidError({ path, issues: parsed.error.issues })
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.ZodIssue[]>().optional(),
}),
)
export function get() {
return state()
}
+24 -4
View File
@@ -18,6 +18,8 @@ import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { Bus } from "./bus"
import { Config } from "./config/config"
import { NamedError } from "./util/error"
import { FormatError } from "./cli/error"
const cli = yargs(hideBin(process.argv))
.scriptName("opencode")
@@ -36,7 +38,7 @@ const cli = yargs(hideBin(process.argv))
.usage("\n" + UI.logo())
.command({
command: "$0 [project]",
describe: "Start opencode TUI",
describe: "start opencode TUI",
builder: (yargs) =>
yargs.positional("project", {
type: "string",
@@ -133,7 +135,25 @@ const cli = yargs(hideBin(process.argv))
try {
await cli.parse()
} catch (e) {
Log.Default.error(e, {
stack: e instanceof Error ? e.stack : undefined,
})
const data: Record<string, any> = {}
if (e instanceof NamedError) {
const obj = e.toObject()
Object.assign(data, {
...obj.data,
})
}
if (e instanceof Error) {
Object.assign(data, {
name: e.name,
message: e.message,
cause: e.cause?.toString(),
})
}
Log.Default.error("fatal", data)
const formatted = FormatError(e)
if (formatted) UI.error(formatted)
if (!formatted)
UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details",
)
}
@@ -41,6 +41,10 @@ export namespace Installation {
return VERSION.startsWith("0.0.0")
}
export function isDev() {
return VERSION === "dev"
}
export async function method() {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
const exec = process.execPath.toLowerCase()
+3
View File
@@ -70,6 +70,9 @@ export namespace LSPServer {
const proc = Bun.spawn({
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
env: { ...process.env, GOBIN: Global.Path.bin },
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
@@ -64,6 +64,7 @@ export namespace Permission {
title: Info["title"]
metadata: Info["metadata"]
}) {
return
const { pending, approved } = state()
log.info("asking", {
sessionID: input.sessionID,
+24 -31
View File
@@ -1,5 +1,4 @@
import z from "zod"
import path from "path"
import { App } from "../app/app"
import { Config } from "../config/config"
import { mergeDeep, sortBy } from "remeda"
@@ -24,7 +23,6 @@ import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
import { TaskTool } from "../tool/task"
import { Global } from "../global"
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -115,32 +113,6 @@ export namespace Provider {
}
const configProviders = Object.entries(config.provider ?? {})
for await (const providerPath of new Bun.Glob("*/provider.toml").scan({
cwd: Global.Path.providers,
})) {
const [providerID] = providerPath.split("/")
const toml = await import(
path.join(Global.Path.providers, providerPath),
{
with: {
type: "toml",
},
}
).then((mod) => mod.default)
toml.models = {}
const modelsPath = path.join(Global.Path.providers, providerID, "models")
for await (const modelPath of new Bun.Glob("**/*.toml").scan({
cwd: modelsPath,
})) {
const modelID = modelPath.slice(0, -5)
toml.models[modelID] = await import(path.join(modelsPath, modelPath), {
with: {
type: "toml",
},
})
}
configProviders.unshift([providerID, toml])
}
for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
@@ -209,7 +181,11 @@ export namespace Provider {
mergeProvider(providerID, provider.options ?? {}, "config")
}
for (const providerID of Object.keys(providers)) {
for (const [providerID, provider] of Object.entries(providers)) {
if (Object.keys(provider.info.models).length === 0) {
delete providers[providerID]
continue
}
log.info("found", { providerID })
}
@@ -300,9 +276,14 @@ export namespace Provider {
export async function defaultModel() {
const cfg = await Config.get()
if (cfg.model) return parseModel(cfg.model)
const provider = await list()
.then((val) => Object.values(val))
.then((x) => x.find((p) => !cfg.provider || cfg.provider === p.info.id))
.then((x) =>
x.find(
(p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id),
),
)
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.info.models))
if (!model) throw new Error("no models found")
@@ -312,6 +293,14 @@ export namespace Provider {
}
}
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: providerID,
modelID: rest.join("/"),
}
}
const TOOLS = [
BashTool,
EditTool,
@@ -332,11 +321,15 @@ export namespace Provider {
]
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
anthropic: TOOLS.filter((t) => t.id !== "opencode.patch"),
anthropic: TOOLS.filter((t) => t.id !== "patch"),
openai: TOOLS.map((t) => ({
...t,
parameters: optionalToNullable(t.parameters),
})),
azure: TOOLS.map((t) => ({
...t,
parameters: optionalToNullable(t.parameters),
})),
google: TOOLS,
}
@@ -0,0 +1,24 @@
import type { CoreMessage } from "ai"
export namespace ProviderTransform {
export function message(
msg: CoreMessage,
index: number,
providerID: string,
modelID: string,
) {
if (
(providerID === "anthropic" || modelID.includes("anthropic")) &&
index < 4
) {
msg.providerOptions = {
...msg.providerOptions,
anthropic: {
cacheControl: { type: "ephemeral" },
},
}
}
return msg
}
}
+11 -22
View File
@@ -32,6 +32,7 @@ import { Flag } from "../flag/flag"
import type { ModelsDev } from "../provider/models"
import { Installation } from "../installation"
import { Config } from "../config/config"
import { ProviderTransform } from "../provider/transform"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -151,6 +152,7 @@ export namespace Session {
}
})
await Storage.writeJSON<ShareInfo>("session/share/" + id, share)
await Share.sync("session/info/" + id, session)
for (const msg of await messages(id)) {
await Share.sync("session/message/" + id + "/" + msg.id, msg)
}
@@ -265,15 +267,6 @@ export namespace Session {
(x): CoreMessage => ({
role: "system",
content: x,
providerOptions: {
...(input.providerID === "anthropic"
? {
anthropic: {
cacheControl: { type: "ephemeral" },
},
}
: {}),
},
}),
),
...convertToCoreMessages([
@@ -283,7 +276,9 @@ export namespace Session {
parts: toParts(input.parts),
},
]),
],
].map((msg, i) =>
ProviderTransform.message(msg, i, input.providerID, input.modelID),
),
model: model.language,
})
.then((result) => {
@@ -514,24 +509,17 @@ export namespace Session {
maxSteps: 1000,
messages: [
...system.map(
(x, index): CoreMessage => ({
(x): CoreMessage => ({
role: "system",
content: x,
providerOptions: {
...(input.providerID === "anthropic" && index < 4
? {
anthropic: {
cacheControl: { type: "ephemeral" },
},
}
: {}),
},
}),
),
...convertToCoreMessages(
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
),
],
].map((msg, i) =>
ProviderTransform.message(msg, i, input.providerID, input.modelID),
),
temperature: model.info.temperature ? 0 : undefined,
tools: {
...tools,
@@ -596,7 +584,7 @@ export namespace Session {
break
case "tool-call-delta":
break
continue
// for some reason ai sdk claims to not send this part but it does
// @ts-expect-error
@@ -631,6 +619,7 @@ export namespace Session {
l.info("unhandled", {
type: value.type,
})
continue
}
await updateMessage(next)
}
+4 -4
View File
@@ -25,7 +25,7 @@ export namespace SystemPrompt {
export async function environment() {
const app = App.info()
const tree = async () => {
;async () => {
const files = await Ripgrep.files({
cwd: app.path.cwd,
})
@@ -82,9 +82,9 @@ export namespace SystemPrompt {
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
`<project>`,
` ${app.git ? await tree() : ""}`,
`</project>`,
// `<project>`,
// ` ${app.git ? await tree() : ""}`,
// `</project>`,
].join("\n"),
]
}
+5 -1
View File
@@ -1,5 +1,6 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { Installation } from "../installation"
import { Session } from "../session"
import { Storage } from "../storage/storage"
import { Log } from "../util/log"
@@ -56,7 +57,10 @@ export namespace Share {
}
export const URL =
process.env["OPENCODE_API"] ?? "https://api.dev.opencode.ai"
process.env["OPENCODE_API"] ??
(Installation.isSnapshot() || Installation.isDev()
? "https://api.dev.opencode.ai"
: "https://api.opencode.ai")
export async function create(sessionID: string) {
return fetch(`${URL}/share_create`, {
+1 -1
View File
@@ -26,7 +26,7 @@ const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
export const BashTool = Tool.define({
id: "opencode.bash",
id: "bash",
description: DESCRIPTION,
parameters: z.object({
command: z.string().describe("The command to execute"),
+2 -2
View File
@@ -9,7 +9,7 @@ import DESCRIPTION from "./edit.txt"
import { App } from "../app/app"
export const EditTool = Tool.define({
id: "opencode.edit",
id: "edit",
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
@@ -35,7 +35,7 @@ export const EditTool = Tool.define({
: path.join(app.path.cwd, params.filePath)
await Permission.ask({
id: "opencode.edit",
id: "edit",
sessionID: ctx.sessionID,
title: "Edit this file: " + filepath,
metadata: {
+1 -1
View File
@@ -5,7 +5,7 @@ import { App } from "../app/app"
import DESCRIPTION from "./glob.txt"
export const GlobTool = Tool.define({
id: "opencode.glob",
id: "glob",
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The glob pattern to match files against"),
+1 -1
View File
@@ -6,7 +6,7 @@ import { Ripgrep } from "../external/ripgrep"
import DESCRIPTION from "./grep.txt"
export const GrepTool = Tool.define({
id: "opencode.grep",
id: "grep",
description: DESCRIPTION,
parameters: z.object({
pattern: z
+1 -1
View File
@@ -21,7 +21,7 @@ export const IGNORE_PATTERNS = [
const LIMIT = 100
export const ListTool = Tool.define({
id: "opencode.list",
id: "list",
description: DESCRIPTION,
parameters: z.object({
path: z
@@ -6,7 +6,7 @@ import { App } from "../app/app"
import DESCRIPTION from "./lsp-diagnostics.txt"
export const LspDiagnosticTool = Tool.define({
id: "opencode.lsp_diagnostics",
id: "lsp_diagnostics",
description: DESCRIPTION,
parameters: z.object({
path: z.string().describe("The path to the file to get diagnostics."),
+1 -1
View File
@@ -6,7 +6,7 @@ import { App } from "../app/app"
import DESCRIPTION from "./lsp-hover.txt"
export const LspHoverTool = Tool.define({
id: "opencode.lsp_hover",
id: "lsp_hover",
description: DESCRIPTION,
parameters: z.object({
file: z.string().describe("The path to the file to get diagnostics."),
+1 -1
View File
@@ -6,7 +6,7 @@ import path from "path"
import { App } from "../app/app"
export const MultiEditTool = Tool.define({
id: "opencode.multiedit",
id: "multiedit",
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
+1 -1
View File
@@ -232,7 +232,7 @@ async function applyCommit(
}
export const PatchTool = Tool.define({
id: "opencode.patch",
id: "patch",
description: DESCRIPTION,
parameters: PatchParams,
execute: async (params, ctx) => {
+1 -1
View File
@@ -12,7 +12,7 @@ const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
export const ReadTool = Tool.define({
id: "opencode.read",
id: "read",
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The path to the file to read"),
+5 -2
View File
@@ -6,7 +6,7 @@ import { Bus } from "../bus"
import { Message } from "../session/message"
export const TaskTool = Tool.define({
id: "opencode.task",
id: "task",
description: DESCRIPTION,
parameters: z.object({
description: z
@@ -34,13 +34,16 @@ export const TaskTool = Tool.define({
}
const unsub = Bus.subscribe(Message.Event.Updated, async (evt) => {
if (evt.properties.info.metadata.sessionID !== ctx.sessionID) return
if (evt.properties.info.metadata.sessionID !== session.id) return
ctx.metadata({
title: params.description,
summary: summary(evt.properties.info),
})
})
ctx.abort.addEventListener("abort", () => {
Session.abort(session.id)
})
const result = await Session.chat({
sessionID: session.id,
modelID: metadata.modelID,
+2 -2
View File
@@ -23,7 +23,7 @@ const state = App.state("todo-tool", () => {
})
export const TodoWriteTool = Tool.define({
id: "opencode.todowrite",
id: "todowrite",
description: DESCRIPTION_WRITE,
parameters: z.object({
todos: z.array(TodoInfo).describe("The updated todo list"),
@@ -42,7 +42,7 @@ export const TodoWriteTool = Tool.define({
})
export const TodoReadTool = Tool.define({
id: "opencode.todoread",
id: "todoread",
description: "Use this tool to read your todo list",
parameters: z.object({}),
async execute(_params, opts) {
+41 -6
View File
@@ -8,7 +8,7 @@ const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
export const WebFetchTool = Tool.define({
id: "opencode.webfetch",
id: "webfetch",
description: DESCRIPTION,
parameters: z.object({
url: z.string().describe("The URL to fetch content from"),
@@ -76,7 +76,7 @@ export const WebFetchTool = Tool.define({
switch (params.format) {
case "text":
if (contentType.includes("text/html")) {
const text = extractTextFromHTML(content)
const text = await extractTextFromHTML(content)
return {
output: text,
metadata: {
@@ -127,10 +127,45 @@ export const WebFetchTool = Tool.define({
},
})
function extractTextFromHTML(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html")
const text = doc.body.textContent || doc.body.innerText || ""
return text.replace(/\s+/g, " ").trim()
async function extractTextFromHTML(html: string) {
let text = ""
let skipContent = false
const rewriter = new HTMLRewriter()
.on("script, style, noscript, iframe, object, embed", {
element() {
skipContent = true
},
text() {
// Skip text content inside these elements
},
})
.on("*", {
element(element) {
// Reset skip flag when entering other elements
if (
![
"script",
"style",
"noscript",
"iframe",
"object",
"embed",
].includes(element.tagName)
) {
skipContent = false
}
},
text(input) {
if (!skipContent) {
text += input.text
}
},
})
.transform(new Response(html))
await rewriter.text()
return text.trim()
}
function convertHTMLToMarkdown(html: string): string {
+1 -1
View File
@@ -1,5 +1,5 @@
- Allows OpenCode to search the web and use the results to inform responses
- Allows opencode to search the web and use the results to inform responses
- Provides up-to-date information for current events and recent data
- Returns search result information formatted as search result blocks
- Use this tool for accessing information beyond Claude's knowledge cutoff
+2 -2
View File
@@ -8,7 +8,7 @@ import DESCRIPTION from "./write.txt"
import { App } from "../app/app"
export const WriteTool = Tool.define({
id: "opencode.write",
id: "write",
description: DESCRIPTION,
parameters: z.object({
filePath: z
@@ -29,7 +29,7 @@ export const WriteTool = Tool.define({
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
await Permission.ask({
id: "opencode.write",
id: "write",
sessionID: ctx.sessionID,
title: exists
? "Overwrite this file: " + filepath
+2 -6
View File
@@ -1,7 +1,7 @@
import { z, type ZodSchema } from "zod"
import { Log } from "./log"
// import { Log } from "./log"
const log = Log.create()
// const log = Log.create()
export abstract class NamedError extends Error {
abstract schema(): ZodSchema
@@ -30,10 +30,6 @@ export abstract class NamedError extends Error {
) {
super(name, options)
this.name = name
log.error(name, {
...this.data,
cause: options?.cause?.toString(),
})
}
static isInstance(input: any): input is InstanceType<typeof result> {
+3 -3
View File
@@ -68,13 +68,13 @@ export namespace Log {
}
const result = {
info(message?: any, extra?: Record<string, any>) {
process.stderr.write(build(message, extra))
process.stderr.write("INFO " + build(message, extra))
},
error(message?: any, extra?: Record<string, any>) {
process.stderr.write(build(message, extra))
process.stderr.write("ERROR " + build(message, extra))
},
warn(message?: any, extra?: Record<string, any>) {
process.stderr.write(build(message, extra))
process.stderr.write("WARN " + build(message, extra))
},
tag(key: string, value: string) {
if (tags) tags[key] = value
+2 -2
View File
@@ -23,7 +23,7 @@ type App struct {
Info client.AppInfo
Version string
StatePath string
Configg *client.ConfigInfo
Config *client.ConfigInfo
Client *client.ClientWithResponses
State *config.State
Provider *client.ProviderInfo
@@ -95,7 +95,7 @@ func New(
Info: appInfo,
Version: version,
StatePath: appStatePath,
Configg: configInfo,
Config: configInfo,
State: appState,
Client: httpClient,
Session: &client.SessionInfo{},
+12 -12
View File
@@ -186,7 +186,7 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
},
{
Name: ProjectInitCommand,
Description: "create or update AGENTS.md",
Description: "create/update AGENTS.md",
Keybindings: parseBindings("<leader>i"),
Trigger: "init",
},
@@ -208,18 +208,18 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
{
Name: InputNewlineCommand,
Description: "insert newline",
Keybindings: parseBindings("shift+enter"),
},
{
Name: HistoryPreviousCommand,
Description: "previous prompt",
Keybindings: parseBindings("up"),
},
{
Name: HistoryNextCommand,
Description: "next prompt",
Keybindings: parseBindings("down"),
Keybindings: parseBindings("shift+enter", "ctrl+j"),
},
// {
// Name: HistoryPreviousCommand,
// Description: "previous prompt",
// Keybindings: parseBindings("up"),
// },
// {
// Name: HistoryNextCommand,
// Description: "next prompt",
// Keybindings: parseBindings("down"),
// },
{
Name: MessagesPageUpCommand,
Description: "page up",
@@ -35,8 +35,7 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
return "no matching commands"
}
func getCommandCompletionItem(cmd commands.Command, space int) dialog.CompletionItemI {
t := theme.CurrentTheme()
func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
spacer := strings.Repeat(" ", space)
title := " /" + cmd.Trigger + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
value := string(cmd.Name)
@@ -47,6 +46,9 @@ func getCommandCompletionItem(cmd commands.Command, space int) dialog.Completion
}
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
t := theme.CurrentTheme()
commands := c.app.Commands
space := 1
for _, cmd := range c.app.Commands {
if lipgloss.Width(cmd.Trigger) > space {
@@ -55,15 +57,16 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
}
space += 2
sorted := commands.Sorted()
if query == "" {
// If no query, return all commands
items := []dialog.CompletionItemI{}
for _, cmd := range c.app.Commands {
for _, cmd := range sorted {
if cmd.Trigger == "" {
continue
}
space := space - lipgloss.Width(cmd.Trigger)
items = append(items, getCommandCompletionItem(cmd, space))
items = append(items, getCommandCompletionItem(cmd, space, t))
}
return items, nil
}
@@ -72,13 +75,13 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
var commandNames []string
commandMap := make(map[string]dialog.CompletionItemI)
for _, cmd := range c.app.Commands {
for _, cmd := range sorted {
if cmd.Trigger == "" {
continue
}
space := space - lipgloss.Width(cmd.Trigger)
commandNames = append(commandNames, cmd.Trigger)
commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, space)
commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, space, t)
}
// Find fuzzy matches
+33 -30
View File
@@ -6,12 +6,12 @@ import (
"strings"
"github.com/charmbracelet/bubbles/v2/spinner"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/textarea"
"github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
@@ -23,6 +23,8 @@ type EditorComponent interface {
tea.Model
tea.ViewModel
layout.Sizeable
Content() string
Lines() int
Value() string
Submit() (tea.Model, tea.Cmd)
Clear() (tea.Model, tea.Cmd)
@@ -50,22 +52,15 @@ func (m *editorComponent) Init() tea.Cmd {
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
// Maximize editor responsiveness for printable characters
if msg.Text != "" {
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// // TODO: ?
// if key.Matches(msg, messageKeys.PageUp) ||
// key.Matches(msg, messageKeys.PageDown) ||
// key.Matches(msg, messageKeys.HalfPageUp) ||
// key.Matches(msg, messageKeys.HalfPageDown) {
// return m, nil
// }
case dialog.ThemeSelectedMsg:
m.textarea = createTextArea(&m.textarea)
m.spinner = createSpinner()
@@ -73,10 +68,11 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.CompletionSelectedMsg:
if msg.IsCommand {
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
m.textarea.Reset()
return m, util.CmdHandler(
commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)]),
)
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
return m, tea.Batch(cmds...)
} else {
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
@@ -94,7 +90,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m *editorComponent) View() string {
func (m *editorComponent) Content() string {
t := theme.CurrentTheme()
base := styles.BaseStyle().Background(t.Background()).Render
muted := styles.Muted().Background(t.Background()).Render
@@ -114,9 +110,6 @@ func (m *editorComponent) View() string {
PaddingTop(1).
PaddingBottom(1).
Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder(), false, true).
BorderForeground(t.BackgroundElement()).
BorderBackground(t.Background()).
Render(textarea)
hint := base("enter") + muted(" send ")
@@ -139,6 +132,13 @@ func (m *editorComponent) View() string {
return content
}
func (m *editorComponent) View() string {
if m.Lines() > 1 {
return ""
}
return m.Content()
}
func (m *editorComponent) GetSize() (width, height int) {
return m.width, m.height
}
@@ -146,18 +146,21 @@ func (m *editorComponent) GetSize() (width, height int) {
func (m *editorComponent) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
m.textarea.SetHeight(height - 4) // account for info underneath
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
// m.textarea.SetHeight(height - 4)
return nil
}
func (m *editorComponent) Lines() int {
return m.textarea.LineCount()
}
func (m *editorComponent) Value() string {
return m.textarea.Value()
}
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
value := strings.TrimSpace(m.Value())
m.textarea.Reset()
if value == "" {
return m, nil
}
@@ -167,6 +170,11 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
return m, nil
}
var cmds []tea.Cmd
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
attachments := m.attachments
// Save to history if not empty and not a duplicate of the last entry
@@ -180,12 +188,8 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
m.attachments = nil
return m, tea.Batch(
util.CmdHandler(app.SendMsg{
Text: value,
Attachments: attachments,
}),
)
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
return m, tea.Batch(cmds...)
}
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
@@ -210,8 +214,7 @@ func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
}
func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
value := m.textarea.Value()
m.textarea.SetValue(value + "\n")
m.textarea.Newline()
return m, nil
}
+105 -55
View File
@@ -1,6 +1,7 @@
package chat
import (
"encoding/json"
"fmt"
"path/filepath"
"slices"
@@ -252,8 +253,9 @@ func renderToolInvocation(
metadata client.MessageInfo_Metadata_Tool_AdditionalProperties,
showDetails bool,
isLast bool,
contentOnly bool,
) string {
ignoredTools := []string{"opencode_todoread"}
ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.ToolName) {
return ""
}
@@ -305,14 +307,19 @@ func renderToolInvocation(
toolArgsMap := make(map[string]any)
if toolCall.Args != nil {
value := *toolCall.Args
m, ok := value.(map[string]any)
if ok {
if m, ok := value.(map[string]any); ok {
toolArgsMap = m
firstKey := ""
keys := make([]string, 0, len(toolArgsMap))
for key := range toolArgsMap {
firstKey = key
break
keys = append(keys, key)
}
slices.Sort(keys)
firstKey := ""
if len(keys) > 0 {
firstKey = keys[0]
}
toolArgs = renderArgs(&toolArgsMap, firstKey)
}
}
@@ -337,30 +344,19 @@ func renderToolInvocation(
}
}
elapsed := ""
start := metadata.Time.Start
end := metadata.Time.End
durationMs := end - start
duration := time.Duration(durationMs * float32(time.Millisecond))
roundedDuration := time.Duration(duration.Round(time.Millisecond))
if durationMs > 1000 {
roundedDuration = time.Duration(duration.Round(time.Second))
}
elapsed = styles.Muted().Render(roundedDuration.String())
title := ""
switch toolCall.ToolName {
case "opencode_read":
case "read":
toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("READ %s %s", toolArgs, elapsed)
title = fmt.Sprintf("READ %s", toolArgs)
if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string)
body = preview.(string)
body = renderFile(filename, body, WithTruncate(6))
}
case "opencode_edit":
case "edit":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("EDIT %s %s", relative(filename), elapsed)
title = fmt.Sprintf("EDIT %s", relative(filename))
if d, ok := metadata.Get("diff"); ok {
patch := d.(string)
var formattedDiff string
@@ -399,16 +395,16 @@ func renderToolInvocation(
)
}
}
case "opencode_write":
case "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("WRITE %s %s", relative(filename), elapsed)
title = fmt.Sprintf("WRITE %s", relative(filename))
if content, ok := toolArgsMap["content"].(string); ok {
body = renderFile(filename, content)
}
}
case "opencode_bash":
case "bash":
if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("SHELL %s %s", description, elapsed)
title = fmt.Sprintf("SHELL %s", description)
}
if stdout, ok := metadata.Get("stdout"); ok {
command := toolArgsMap["command"].(string)
@@ -417,19 +413,21 @@ func renderToolInvocation(
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
case "opencode_webfetch":
case "webfetch":
toolArgs = renderArgs(&toolArgsMap, "url")
title = fmt.Sprintf("FETCH %s %s", toolArgs, elapsed)
title = fmt.Sprintf("FETCH %s", toolArgs)
if format, ok := toolArgsMap["format"].(string); ok {
body = *result
body = truncateHeight(body, 10)
if format == "html" || format == "markdown" {
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
if result != nil {
body = *result
body = truncateHeight(body, 10)
if format == "html" || format == "markdown" {
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
}
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
case "opencode_todowrite":
title = fmt.Sprintf("PLAN %s", elapsed)
case "todowrite":
title = fmt.Sprintf("PLAN")
if to, ok := metadata.Get("todos"); ok && finished {
todos := to.([]any)
@@ -448,14 +446,60 @@ func renderToolInvocation(
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
case "task":
if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("TASK %s", description)
if summary, ok := metadata.Get("summary"); ok {
toolcalls := summary.([]any)
// toolcalls :=
steps := []string{}
for _, toolcall := range toolcalls {
call := toolcall.(map[string]any)
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
data, _ := json.Marshal(toolInvocation)
var toolCall client.MessageToolInvocationToolCall
_ = json.Unmarshal(data, &toolCall)
if metadata, ok := call["metadata"].(map[string]any); ok {
data, _ = json.Marshal(metadata)
var toolMetadata client.MessageInfo_Metadata_Tool_AdditionalProperties
_ = json.Unmarshal(data, &toolMetadata)
step := renderToolInvocation(
toolCall,
nil,
toolMetadata,
false,
false,
true,
)
steps = append(steps, step)
}
}
}
body = strings.Join(steps, "\n")
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
}
default:
toolName := renderToolName(toolCall.ToolName)
title = fmt.Sprintf("%s %s %s", toolName, toolArgs, elapsed)
title = fmt.Sprintf("%s %s", toolName, toolArgs)
if result == nil {
empty := ""
result = &empty
}
body = *result
body = truncateHeight(body, 10)
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
if contentOnly {
title = "∟ " + title
return title
}
if !showDetails {
title = "∟ " + title
padding := calculatePadding()
@@ -496,13 +540,11 @@ func renderToolInvocation(
func renderToolName(name string) string {
switch name {
// case agent.AgentToolName:
// return "Task"
case "opencode_ls":
case "list":
return "LIST"
case "opencode_webfetch":
case "webfetch":
return "FETCH"
case "opencode_todowrite":
case "todowrite":
return "PLAN"
default:
normalizedName := name
@@ -557,29 +599,29 @@ func renderFile(filename string, content string, options ...fileRenderingOption)
func renderToolAction(name string) string {
switch name {
// case agent.AgentToolName:
// return "Preparing prompt..."
case "opencode_bash":
case "task":
return "Searching..."
case "bash":
return "Building command..."
case "opencode_edit":
case "edit":
return "Preparing edit..."
case "opencode_fetch":
return "Writing fetch..."
case "opencode_glob":
case "webfetch":
return "Fetching from the web..."
case "glob":
return "Finding files..."
case "opencode_grep":
case "grep":
return "Searching content..."
case "opencode_ls":
case "list":
return "Listing directory..."
case "opencode_read":
case "read":
return "Reading file..."
case "opencode_write":
case "write":
return "Preparing write..."
case "opencode_todowrite", "opencode_todoread":
case "todowrite", "todoread":
return "Planning..."
case "opencode_patch":
case "patch":
return "Preparing patch..."
case "opencode_batch":
case "batch":
return "Running batch operations..."
}
return "Working..."
@@ -589,9 +631,17 @@ func renderArgs(args *map[string]any, titleKey string) string {
if args == nil || len(*args) == 0 {
return ""
}
keys := make([]string, 0, len(*args))
for key := range *args {
keys = append(keys, key)
}
slices.Sort(keys)
title := ""
parts := []string{}
for key, value := range *args {
for _, key := range keys {
value := (*args)[key]
if value == nil {
continue
}
@@ -10,6 +10,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
@@ -28,6 +29,7 @@ type MessagesComponent interface {
Last() (tea.Model, tea.Cmd)
// Previous() (tea.Model, tea.Cmd)
// Next() (tea.Model, tea.Cmd)
ToolDetailsVisible() bool
}
type messagesComponent struct {
@@ -36,6 +38,7 @@ type messagesComponent struct {
viewport viewport.Model
spinner spinner.Model
attachments viewport.Model
commands commands.CommandsComponent
cache *MessageCache
rendering bool
showToolDetails bool
@@ -45,7 +48,7 @@ type renderFinishedMsg struct{}
type ToggleToolDetailsMsg struct{}
func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.commands.Init())
}
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -92,6 +95,11 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
spinner, cmd := m.spinner.Update(msg)
m.spinner = spinner
cmds = append(cmds, cmd)
updated, cmd := m.commands.Update(msg)
m.commands = updated.(commands.CommandsComponent)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
@@ -190,6 +198,7 @@ func (m *messagesComponent) renderView() {
metadata,
m.showToolDetails,
isLastToolInvocation,
false,
)
m.cache.Set(key, content)
}
@@ -201,6 +210,7 @@ func (m *messagesComponent) renderView() {
metadata,
m.showToolDetails,
isLastToolInvocation,
false,
)
}
@@ -278,7 +288,13 @@ func (m *messagesComponent) View() string {
return m.home()
}
if m.rendering {
return m.viewport.View()
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
"Loading session...",
)
}
t := theme.CurrentTheme()
return lipgloss.JoinVertical(
@@ -316,50 +332,42 @@ func (m *messagesComponent) home() string {
// cwd := app.Info.Path.Cwd
// config := app.Info.Path.Config
commands := [][]string{
{"/help", "show help"},
{"/sessions", "list sessions"},
{"/new", "start a new session"},
{"/model", "switch model"},
{"/theme", "switch theme"},
{"/quit", "exit the app"},
}
versionStyle := lipgloss.NewStyle().
Background(t.Background()).
Foreground(t.TextMuted()).
Width(lipgloss.Width(logo)).
Align(lipgloss.Right)
version := versionStyle.Render(m.app.Version)
commandLines := []string{}
for _, command := range commands {
commandLines = append(commandLines, (base(command[0]+" ") + muted(command[1])))
}
logoAndVersion := lipgloss.JoinVertical(
lipgloss.Right,
logo,
muted(m.app.Version),
logoAndVersion := strings.Join([]string{logo, version}, "\n")
logoAndVersion = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
logoAndVersion,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
commands := lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
m.commands.View(),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
lines := []string{}
lines = append(lines, "")
lines = append(lines, "")
lines = append(lines, logoAndVersion)
lines = append(lines, "")
lines = append(lines, "")
// lines = append(lines, base("cwd ")+muted(cwd))
// lines = append(lines, base("config ")+muted(config))
// lines = append(lines, "")
lines = append(lines, commandLines...)
lines = append(lines, "")
if m.rendering {
lines = append(lines, base("Loading session..."))
} else {
lines = append(lines, "")
}
lines = append(lines, commands)
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
baseStyle.Width(lipgloss.Width(logoAndVersion)).Render(
strings.Join(lines, "\n"),
),
baseStyle.Render(strings.Join(lines, "\n")),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
)
}
@@ -378,6 +386,7 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
m.viewport.SetHeight(height - lipgloss.Height(m.header()))
m.attachments.SetWidth(width + 40)
m.attachments.SetHeight(3)
m.commands.SetSize(width, height)
m.renderView()
return nil
}
@@ -426,6 +435,10 @@ func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
return m, nil
}
func (m *messagesComponent) ToolDetailsVisible() bool {
return m.showToolDetails
}
func NewMessagesComponent(app *app.App) MessagesComponent {
customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"},
@@ -437,11 +450,19 @@ func NewMessagesComponent(app *app.App) MessagesComponent {
attachments := viewport.New()
vp.KeyMap = viewport.KeyMap{}
t := theme.CurrentTheme()
commandsView := commands.New(
app,
commands.WithBackground(t.Background()),
commands.WithLimit(6),
)
return &messagesComponent{
app: app,
viewport: vp,
spinner: s,
attachments: attachments,
commands: commandsView,
showToolDetails: true,
cache: NewMessageCache(),
tail: true,
@@ -0,0 +1,196 @@
package commands
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type CommandsComponent interface {
tea.Model
tea.ViewModel
layout.Sizeable
}
type commandsComponent struct {
app *app.App
width, height int
showKeybinds bool
background *compat.AdaptiveColor
limit *int
}
func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
c.width = width
c.height = height
return nil
}
func (c *commandsComponent) GetSize() (int, int) {
return c.width, c.height
}
func (c *commandsComponent) Init() tea.Cmd {
return nil
}
func (c *commandsComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
}
return c, nil
}
func (c *commandsComponent) View() string {
t := theme.CurrentTheme()
triggerStyle := lipgloss.NewStyle().
Foreground(t.Primary()).
Bold(true)
descriptionStyle := lipgloss.NewStyle().
Foreground(t.Text())
keybindStyle := lipgloss.NewStyle().
Foreground(t.TextMuted())
if c.background != nil {
triggerStyle = triggerStyle.Background(*c.background)
descriptionStyle = descriptionStyle.Background(*c.background)
keybindStyle = keybindStyle.Background(*c.background)
}
var commandsWithTriggers []commands.Command
for _, cmd := range c.app.Commands.Sorted() {
if cmd.Trigger != "" {
commandsWithTriggers = append(commandsWithTriggers, cmd)
}
}
if c.limit != nil && len(commandsWithTriggers) > *c.limit {
commandsWithTriggers = commandsWithTriggers[:*c.limit]
}
if len(commandsWithTriggers) == 0 {
return styles.Muted().Render("No commands with triggers available")
}
// Calculate column widths
maxTriggerWidth := 0
maxDescriptionWidth := 0
maxKeybindWidth := 0
// Prepare command data
type commandRow struct {
trigger string
description string
keybinds string
}
rows := make([]commandRow, 0, len(commandsWithTriggers))
for _, cmd := range commandsWithTriggers {
trigger := "/" + cmd.Trigger
description := cmd.Description
// Format keybindings
var keybindStrs []string
if c.showKeybinds {
for _, kb := range cmd.Keybindings {
if kb.RequiresLeader {
keybindStrs = append(keybindStrs, *c.app.Config.Keybinds.Leader+" "+kb.Key)
} else {
keybindStrs = append(keybindStrs, kb.Key)
}
}
}
keybinds := strings.Join(keybindStrs, ", ")
rows = append(rows, commandRow{
trigger: trigger,
description: description,
keybinds: keybinds,
})
// Update max widths
if len(trigger) > maxTriggerWidth {
maxTriggerWidth = len(trigger)
}
if len(description) > maxDescriptionWidth {
maxDescriptionWidth = len(description)
}
if len(keybinds) > maxKeybindWidth {
maxKeybindWidth = len(keybinds)
}
}
// Add padding between columns
columnPadding := 3
// Build the output
var output strings.Builder
for _, row := range rows {
// Pad each column to align properly
trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger)
description := fmt.Sprintf("%-*s", maxDescriptionWidth, row.description)
// Apply styles and combine
line := triggerStyle.Render(trigger) +
triggerStyle.Render(strings.Repeat(" ", columnPadding)) +
descriptionStyle.Render(description)
if c.showKeybinds && row.keybinds != "" {
line += keybindStyle.Render(strings.Repeat(" ", columnPadding)) +
keybindStyle.Render(row.keybinds)
}
output.WriteString(line + "\n")
}
// Remove trailing newline
result := strings.TrimSuffix(output.String(), "\n")
return result
}
type Option func(*commandsComponent)
func WithKeybinds(show bool) Option {
return func(c *commandsComponent) {
c.showKeybinds = show
}
}
func WithBackground(background compat.AdaptiveColor) Option {
return func(c *commandsComponent) {
c.background = &background
}
}
func WithLimit(limit int) Option {
return func(c *commandsComponent) {
c.limit = &limit
}
}
func New(app *app.App, opts ...Option) CommandsComponent {
c := &commandsComponent{
app: app,
background: nil,
showKeybinds: true,
}
for _, opt := range opts {
opt(c)
}
return c
}
@@ -6,7 +6,6 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/styles"
@@ -101,10 +100,10 @@ type completionDialogKeyMap struct {
var completionDialogKeys = completionDialogKeyMap{
Complete: key.NewBinding(
key.WithKeys("tab", "enter"),
key.WithKeys("tab", "enter", "right"),
),
Cancel: key.NewBinding(
key.WithKeys(" ", "esc", "backspace"),
key.WithKeys(" ", "esc", "backspace", "ctrl+c"),
),
}
@@ -174,7 +173,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
cmds = append(cmds, cmd)
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
// c.pseudoSearchTextArea.SetValue(msg.String())
return c, tea.Batch(cmds...)
}
case tea.WindowSizeMsg:
@@ -203,13 +201,6 @@ func (c *completionDialogComponent) View() string {
return baseStyle.Padding(0, 0).
Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder()).
BorderTop(false).
BorderBottom(false).
BorderRight(true).
BorderLeft(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()).
Width(c.width).
Render(c.list.View())
}
@@ -226,12 +217,12 @@ func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
if c.completionProvider.GetId() != provider.GetId() {
c.completionProvider = provider
c.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
c.list.SetItems([]CompletionItemI{})
}
}
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
if value == "" {
return nil
}
@@ -250,10 +241,8 @@ func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
}
func (c *completionDialogComponent) close() tea.Cmd {
c.list.SetItems([]CompletionItemI{})
c.pseudoSearchTextArea.Reset()
c.pseudoSearchTextArea.Blur()
return util.CmdHandler(CompletionDialogCloseMsg{})
}
@@ -103,7 +103,6 @@ func (m *Modal) Render(contentView string, background string) string {
Bold(true).
Padding(0, 1)
// titleView := titleStyle.Render(m.title)
escStyle := baseStyle.Foreground(t.TextMuted()).Bold(false)
escText := escStyle.Render("esc")
@@ -124,14 +123,7 @@ func (m *Modal) Render(contentView string, background string) string {
PaddingTop(1).
PaddingBottom(1).
PaddingLeft(2).
PaddingRight(2).
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(true).
BorderLeftForeground(t.BackgroundSubtle()).
BorderLeftBackground(t.Background()).
BorderRightForeground(t.BackgroundSubtle()).
BorderRightBackground(t.Background())
PaddingRight(2)
modalView := modalStyle.
Width(outerWidth).
@@ -151,5 +143,7 @@ func (m *Modal) Render(contentView string, background string) string {
row,
modalView,
background,
layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(t.Primary()),
)
}
@@ -0,0 +1,125 @@
// Package memoization implement a simple memoization cache. It's designed to
// improve performance in textarea.
package textarea
import (
"container/list"
"crypto/sha256"
"fmt"
"sync"
)
// Hasher is an interface that requires a Hash method. The Hash method is
// expected to return a string representation of the hash of the object.
type Hasher interface {
Hash() string
}
// entry is a struct that holds a key-value pair. It is used as an element
// in the evictionList of the MemoCache.
type entry[T any] struct {
key string
value T
}
// MemoCache is a struct that represents a cache with a set capacity. It
// uses an LRU (Least Recently Used) eviction policy. It is safe for
// concurrent use.
type MemoCache[H Hasher, T any] struct {
capacity int
mutex sync.Mutex
cache map[string]*list.Element // The cache holding the results
evictionList *list.List // A list to keep track of the order for LRU
hashableItems map[string]T // This map keeps track of the original hashable items (optional)
}
// NewMemoCache is a function that creates a new MemoCache with a given
// capacity. It returns a pointer to the created MemoCache.
func NewMemoCache[H Hasher, T any](capacity int) *MemoCache[H, T] {
return &MemoCache[H, T]{
capacity: capacity,
cache: make(map[string]*list.Element),
evictionList: list.New(),
hashableItems: make(map[string]T),
}
}
// Capacity is a method that returns the capacity of the MemoCache.
func (m *MemoCache[H, T]) Capacity() int {
return m.capacity
}
// Size is a method that returns the current size of the MemoCache. It is
// the number of items currently stored in the cache.
func (m *MemoCache[H, T]) Size() int {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.evictionList.Len()
}
// Get is a method that returns the value associated with the given
// hashable item in the MemoCache. If there is no corresponding value, the
// method returns nil.
func (m *MemoCache[H, T]) Get(h H) (T, bool) {
m.mutex.Lock()
defer m.mutex.Unlock()
hashedKey := h.Hash()
if element, found := m.cache[hashedKey]; found {
m.evictionList.MoveToFront(element)
return element.Value.(*entry[T]).value, true
}
var result T
return result, false
}
// Set is a method that sets the value for the given hashable item in the
// MemoCache. If the cache is at capacity, it evicts the least recently
// used item before adding the new item.
func (m *MemoCache[H, T]) Set(h H, value T) {
m.mutex.Lock()
defer m.mutex.Unlock()
hashedKey := h.Hash()
if element, found := m.cache[hashedKey]; found {
m.evictionList.MoveToFront(element)
element.Value.(*entry[T]).value = value
return
}
// Check if the cache is at capacity
if m.evictionList.Len() >= m.capacity {
// Evict the least recently used item from the cache
toEvict := m.evictionList.Back()
if toEvict != nil {
evictedEntry := m.evictionList.Remove(toEvict).(*entry[T])
delete(m.cache, evictedEntry.key)
delete(m.hashableItems, evictedEntry.key) // if you're keeping track of original items
}
}
// Add the value to the cache and the evictionList
newEntry := &entry[T]{
key: hashedKey,
value: value,
}
element := m.evictionList.PushFront(newEntry)
m.cache[hashedKey] = element
m.hashableItems[hashedKey] = value // if you're keeping track of original items
}
// HString is a type that implements the Hasher interface for strings.
type HString string
// Hash is a method that returns the hash of the string.
func (h HString) Hash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(h)))
}
// HInt is a type that implements the Hasher interface for integers.
type HInt int
// Hash is a method that returns the hash of the integer.
func (h HInt) Hash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", h))))
}
@@ -0,0 +1,102 @@
// Package runeutil provides utility functions for tidying up incoming runes
// from Key messages.
package textarea
import (
"unicode"
"unicode/utf8"
)
// Sanitizer is a helper for bubble widgets that want to process
// Runes from input key messages.
type Sanitizer interface {
// Sanitize removes control characters from runes in a KeyRunes
// message, and optionally replaces newline/carriage return/tabs by a
// specified character.
//
// The rune array is modified in-place if possible. In that case, the
// returned slice is the original slice shortened after the control
// characters have been removed/translated.
Sanitize(runes []rune) []rune
}
// NewSanitizer constructs a rune sanitizer.
func NewSanitizer(opts ...Option) Sanitizer {
s := sanitizer{
replaceNewLine: []rune("\n"),
replaceTab: []rune(" "),
}
for _, o := range opts {
s = o(s)
}
return &s
}
// Option is the type of option that can be passed to Sanitize().
type Option func(sanitizer) sanitizer
// ReplaceTabs replaces tabs by the specified string.
func ReplaceTabs(tabRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceTab = []rune(tabRepl)
return s
}
}
// ReplaceNewlines replaces newline characters by the specified string.
func ReplaceNewlines(nlRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceNewLine = []rune(nlRepl)
return s
}
}
func (s *sanitizer) Sanitize(runes []rune) []rune {
// dstrunes are where we are storing the result.
dstrunes := runes[:0:len(runes)]
// copied indicates whether dstrunes is an alias of runes
// or a copy. We need a copy when dst moves past src.
// We use this as an optimization to avoid allocating
// a new rune slice in the common case where the output
// is smaller or equal to the input.
copied := false
for src := 0; src < len(runes); src++ {
r := runes[src]
switch {
case r == utf8.RuneError:
// skip
case r == '\r' || r == '\n':
if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceNewLine...)
case r == '\t':
if len(dstrunes)+len(s.replaceTab) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceTab...)
case unicode.IsControl(r):
// Other control characters: skip.
default:
// Keep the character.
dstrunes = append(dstrunes, runes[src])
}
}
return dstrunes
}
type sanitizer struct {
replaceNewLine []rune
replaceTab []rune
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,267 @@
package toast
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
// ShowToastMsg is a message to display a toast notification
type ShowToastMsg struct {
Message string
Title *string
Color compat.AdaptiveColor
Duration time.Duration
}
// DismissToastMsg is a message to dismiss a specific toast
type DismissToastMsg struct {
ID string
}
// Toast represents a single toast notification
type Toast struct {
ID string
Message string
Title *string
Color compat.AdaptiveColor
CreatedAt time.Time
Duration time.Duration
}
// ToastManager manages multiple toast notifications
type ToastManager struct {
toasts []Toast
}
// NewToastManager creates a new toast manager
func NewToastManager() *ToastManager {
return &ToastManager{
toasts: []Toast{},
}
}
// Init initializes the toast manager
func (tm *ToastManager) Init() tea.Cmd {
return nil
}
// Update handles messages for the toast manager
func (tm *ToastManager) Update(msg tea.Msg) (*ToastManager, tea.Cmd) {
switch msg := msg.(type) {
case ShowToastMsg:
toast := Toast{
ID: fmt.Sprintf("toast-%d", time.Now().UnixNano()),
Title: msg.Title,
Message: msg.Message,
Color: msg.Color,
CreatedAt: time.Now(),
Duration: msg.Duration,
}
tm.toasts = append(tm.toasts, toast)
// Return command to dismiss after duration
return tm, tea.Tick(toast.Duration, func(t time.Time) tea.Msg {
return DismissToastMsg{ID: toast.ID}
})
case DismissToastMsg:
var newToasts []Toast
for _, t := range tm.toasts {
if t.ID != msg.ID {
newToasts = append(newToasts, t)
}
}
tm.toasts = newToasts
}
return tm, nil
}
// renderSingleToast renders a single toast notification
func (tm *ToastManager) renderSingleToast(toast Toast) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().
Background(t.BackgroundElement()).
Foreground(t.Text()).
Padding(1, 2)
maxWidth := max(40, layout.Current.Viewport.Width/3)
contentMaxWidth := max(maxWidth-6, 20)
// Build content with wrapping
var content strings.Builder
if toast.Title != nil {
titleStyle := lipgloss.NewStyle().
Foreground(toast.Color).
Bold(true)
content.WriteString(titleStyle.Render(*toast.Title))
content.WriteString("\n")
}
// Wrap message text
messageStyle := lipgloss.NewStyle()
contentWidth := lipgloss.Width(toast.Message)
if contentWidth > contentMaxWidth {
messageStyle = messageStyle.Width(contentMaxWidth)
}
content.WriteString(messageStyle.Render(toast.Message))
// Render toast with max width
return baseStyle.MaxWidth(maxWidth).Render(content.String())
}
// View renders all active toasts
func (tm *ToastManager) View() string {
if len(tm.toasts) == 0 {
return ""
}
var toastViews []string
for _, toast := range tm.toasts {
toastView := tm.renderSingleToast(toast)
toastViews = append(toastViews, toastView+"\n")
}
return strings.Join(toastViews, "\n")
}
// RenderOverlay renders the toasts as an overlay on the given background
func (tm *ToastManager) RenderOverlay(background string) string {
if len(tm.toasts) == 0 {
return background
}
bgWidth := lipgloss.Width(background)
bgHeight := lipgloss.Height(background)
result := background
// Start from top with 2 character padding
currentY := 2
// Render each toast individually
for _, toast := range tm.toasts {
// Render individual toast
toastView := tm.renderSingleToast(toast)
toastWidth := lipgloss.Width(toastView)
toastHeight := lipgloss.Height(toastView)
// Position at top-right with 2 character padding from right edge
x := max(bgWidth-toastWidth-4, 0)
// Check if toast fits vertically
if currentY+toastHeight > bgHeight-2 {
// No more room for toasts
break
}
// Place this toast
result = layout.PlaceOverlay(
x,
currentY,
toastView,
result,
layout.WithOverlayBorder(),
layout.WithOverlayBorderColor(toast.Color),
)
// Move down for next toast (add 1 for spacing between toasts)
currentY += toastHeight + 1
}
return result
}
type ToastOptions struct {
Title string
Duration time.Duration
}
type toastOptions struct {
title *string
duration *time.Duration
color *compat.AdaptiveColor
}
type ToastOption func(*toastOptions)
func WithTitle(title string) ToastOption {
return func(t *toastOptions) {
t.title = &title
}
}
func WithDuration(duration time.Duration) ToastOption {
return func(t *toastOptions) {
t.duration = &duration
}
}
func WithColor(color compat.AdaptiveColor) ToastOption {
return func(t *toastOptions) {
t.color = &color
}
}
func NewToast(message string, options ...ToastOption) tea.Cmd {
t := theme.CurrentTheme()
duration := 5 * time.Second
color := t.Primary()
opts := toastOptions{
duration: &duration,
color: &color,
}
for _, option := range options {
option(&opts)
}
return func() tea.Msg {
return ShowToastMsg{
Message: message,
Title: opts.title,
Duration: *opts.duration,
Color: *opts.color,
}
}
}
func NewInfoToast(message string, options ...ToastOption) tea.Cmd {
options = append(options, WithColor(theme.CurrentTheme().Info()))
return NewToast(
message,
options...,
)
}
func NewSuccessToast(message string, options ...ToastOption) tea.Cmd {
options = append(options, WithColor(theme.CurrentTheme().Success()))
return NewToast(
message,
options...,
)
}
func NewWarningToast(message string, options ...ToastOption) tea.Cmd {
options = append(options, WithColor(theme.CurrentTheme().Warning()))
return NewToast(
message,
options...,
)
}
func NewErrorToast(message string, options ...ToastOption) tea.Cmd {
options = append(options, WithColor(theme.CurrentTheme().Error()))
return NewToast(
message,
options...,
)
}
+248 -15
View File
@@ -1,9 +1,13 @@
package layout
import (
"fmt"
"regexp"
"strings"
"unicode/utf8"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
chAnsi "github.com/charmbracelet/x/ansi"
"github.com/muesli/ansi"
"github.com/muesli/reflow/truncate"
@@ -23,29 +27,58 @@ func getLines(s string) (lines []string, widest int) {
return lines, widest
}
// overlayOptions holds configuration for overlay rendering
type overlayOptions struct {
whitespace *whitespace
border bool
borderColor *compat.AdaptiveColor
}
// OverlayOption sets options for overlay rendering
type OverlayOption func(*overlayOptions)
// PlaceOverlay places fg on top of bg.
func PlaceOverlay(
x, y int,
fg, bg string,
opts ...WhitespaceOption,
opts ...OverlayOption,
) string {
fgLines, fgWidth := getLines(fg)
bgLines, bgWidth := getLines(bg)
bgHeight := len(bgLines)
fgHeight := len(fgLines)
if fgWidth >= bgWidth && fgHeight >= bgHeight {
// FIXME: return fg or bg?
return fg
// Parse options
options := &overlayOptions{
whitespace: &whitespace{},
}
for _, opt := range opts {
opt(options)
}
// TODO: allow placement outside of the bg box?
x = util.Clamp(x, 0, bgWidth-fgWidth)
y = util.Clamp(y, 0, bgHeight-fgHeight)
// Adjust for borders if enabled
if options.border {
// Add space for left and right borders
adjustedFgWidth := fgWidth + 2
// Adjust placement to account for borders
x = util.Clamp(x, 0, bgWidth-adjustedFgWidth)
y = util.Clamp(y, 0, bgHeight-fgHeight)
ws := &whitespace{}
for _, opt := range opts {
opt(ws)
// Pad all foreground lines to the same width for consistent borders
for i := range fgLines {
lineWidth := ansi.PrintableRuneWidth(fgLines[i])
if lineWidth < fgWidth {
fgLines[i] += strings.Repeat(" ", fgWidth-lineWidth)
}
}
} else {
if fgWidth >= bgWidth && fgHeight >= bgHeight {
// FIXME: return fg or bg?
return fg
}
// TODO: allow placement outside of the bg box?
x = util.Clamp(x, 0, bgWidth-fgWidth)
y = util.Clamp(y, 0, bgHeight-fgHeight)
}
var b strings.Builder
@@ -59,25 +92,62 @@ func PlaceOverlay(
}
pos := 0
// Handle left side of the line up to the overlay
if x > 0 {
left := truncate.String(bgLine, uint(x))
pos = ansi.PrintableRuneWidth(left)
b.WriteString(left)
if pos < x {
b.WriteString(ws.render(x - pos))
b.WriteString(options.whitespace.render(x - pos))
pos = x
}
}
fgLine := fgLines[i-y]
b.WriteString(fgLine)
pos += ansi.PrintableRuneWidth(fgLine)
// Render the overlay content with optional borders
if options.border {
// Get the foreground line
fgLine := fgLines[i-y]
fgLineWidth := ansi.PrintableRuneWidth(fgLine)
// Extract the styles at the border positions
leftStyle := getStyleAtPosition(bgLine, pos)
rightStyle := getStyleAtPosition(bgLine, pos + 1 + fgLineWidth)
// Left border - combine background from original with border foreground
leftSeq := combineStyles(leftStyle, options.borderColor)
if leftSeq != "" {
b.WriteString(leftSeq)
}
b.WriteString("┃")
b.WriteString("\x1b[0m") // Reset all styles
pos++
// Content
b.WriteString(fgLine)
pos += fgLineWidth
// Right border - combine background from original with border foreground
rightSeq := combineStyles(rightStyle, options.borderColor)
if rightSeq != "" {
b.WriteString(rightSeq)
}
b.WriteString("┃")
b.WriteString("\x1b[0m") // Reset all styles
pos++
} else {
// No border, just render the content
fgLine := fgLines[i-y]
b.WriteString(fgLine)
pos += ansi.PrintableRuneWidth(fgLine)
}
// Handle right side of the line after the overlay
right := cutLeft(bgLine, pos)
bgWidth := ansi.PrintableRuneWidth(bgLine)
rightWidth := ansi.PrintableRuneWidth(right)
if rightWidth <= bgWidth-pos {
b.WriteString(ws.render(bgWidth - rightWidth - pos))
b.WriteString(options.whitespace.render(bgWidth - rightWidth - pos))
}
b.WriteString(right)
@@ -92,6 +162,146 @@ func cutLeft(s string, cutWidth int) string {
return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
}
// ansiStyle represents parsed ANSI style attributes
type ansiStyle struct {
fgColor string
bgColor string
attrs []string
}
// parseANSISequence parses an ANSI escape sequence into its components
func parseANSISequence(seq string) ansiStyle {
style := ansiStyle{}
// Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
return style
}
params := seq[2 : len(seq)-1]
if params == "" {
return style
}
parts := strings.Split(params, ";")
i := 0
for i < len(parts) {
switch parts[i] {
case "0": // Reset
style = ansiStyle{}
case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
style.attrs = append(style.attrs, parts[i])
case "38": // Foreground color
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
// 256 color mode
style.fgColor = strings.Join(parts[i:i+3], ";")
i += 2
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
// RGB color mode
style.fgColor = strings.Join(parts[i:i+5], ";")
i += 4
}
case "48": // Background color
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
// 256 color mode
style.bgColor = strings.Join(parts[i:i+3], ";")
i += 2
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
// RGB color mode
style.bgColor = strings.Join(parts[i:i+5], ";")
i += 4
}
case "30", "31", "32", "33", "34", "35", "36", "37": // Standard foreground colors
style.fgColor = parts[i]
case "40", "41", "42", "43", "44", "45", "46", "47": // Standard background colors
style.bgColor = parts[i]
case "90", "91", "92", "93", "94", "95", "96", "97": // Bright foreground colors
style.fgColor = parts[i]
case "100", "101", "102", "103", "104", "105", "106", "107": // Bright background colors
style.bgColor = parts[i]
}
i++
}
return style
}
// combineStyles creates an ANSI sequence that combines background from one style with foreground from another
func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
return ""
}
var parts []string
// Add attributes
parts = append(parts, bgStyle.attrs...)
// Add background color from the original style
if bgStyle.bgColor != "" {
parts = append(parts, bgStyle.bgColor)
}
// Add foreground color if specified
if fgColor != nil {
// Use the light color (could be improved to detect terminal background)
color := (*fgColor).Light
// Use RGBA to get color components
r, g, b, _ := color.RGBA()
// RGBA returns 16-bit values, we need 8-bit
parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
}
if len(parts) == 0 {
return ""
}
return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
}
// getStyleAtPosition extracts the active ANSI style at a given visual position
func getStyleAtPosition(s string, targetPos int) ansiStyle {
// ANSI escape sequence regex
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
visualPos := 0
currentStyle := ansiStyle{}
i := 0
for i < len(s) && visualPos <= targetPos {
// Check if we're at an ANSI escape sequence
if match := ansiRegex.FindStringIndex(s[i:]); match != nil && match[0] == 0 {
// Found an ANSI sequence at current position
seq := s[i : i+match[1]]
parsedStyle := parseANSISequence(seq)
// Update current style (merge with existing)
if parsedStyle.fgColor != "" {
currentStyle.fgColor = parsedStyle.fgColor
}
if parsedStyle.bgColor != "" {
currentStyle.bgColor = parsedStyle.bgColor
}
if len(parsedStyle.attrs) > 0 {
currentStyle.attrs = parsedStyle.attrs
}
i += match[1]
} else if i < len(s) {
// Regular character
if visualPos == targetPos {
return currentStyle
}
_, size := utf8.DecodeRuneInString(s[i:])
i += size
visualPos++
}
}
return currentStyle
}
type whitespace struct {
style termenv.Style
chars string
@@ -129,3 +339,26 @@ func (w whitespace) render(width int) string {
// WhitespaceOption sets a styling rule for rendering whitespace.
type WhitespaceOption func(*whitespace)
// WithWhitespace sets whitespace options for the overlay
func WithWhitespace(opts ...WhitespaceOption) OverlayOption {
return func(o *overlayOptions) {
for _, opt := range opts {
opt(o.whitespace)
}
}
}
// WithOverlayBorder enables border rendering for the overlay
func WithOverlayBorder() OverlayOption {
return func(o *overlayOptions) {
o.border = true
}
}
// WithOverlayBorderColor sets the border color for the overlay
func WithOverlayBorderColor(color compat.AdaptiveColor) OverlayOption {
return func(o *overlayOptions) {
o.borderColor = &color
}
}
+1 -1
View File
@@ -8,6 +8,6 @@ var Terminal *TerminalInfo
func init() {
Terminal = &TerminalInfo{
BackgroundIsDark: false,
BackgroundIsDark: true,
}
}
+121 -76
View File
@@ -18,6 +18,7 @@ import (
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/util"
@@ -38,16 +39,22 @@ type appModel struct {
showCompletionDialog bool
leaderBinding *key.Binding
isLeaderSequence bool
toastManager *toast.ToastManager
}
func (a appModel) Init() tea.Cmd {
var cmds []tea.Cmd
cmds = append(cmds, tea.RequestBackgroundColor)
// https://github.com/charmbracelet/bubbletea/issues/1440
// https://github.com/sst/opencode/issues/127
if !util.IsWsl() {
cmds = append(cmds, tea.RequestBackgroundColor)
}
cmds = append(cmds, a.app.InitializeProvider())
cmds = append(cmds, a.editor.Init())
cmds = append(cmds, a.messages.Init())
cmds = append(cmds, a.status.Init())
cmds = append(cmds, a.completions.Init())
cmds = append(cmds, a.toastManager.Init())
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
@@ -63,9 +70,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
keyString := msg.String()
// 1. Handle active modal
if a.modal != nil {
switch msg.String() {
switch keyString {
// Escape always closes current modal
case "esc", "ctrl+c":
a.modal = nil
@@ -81,7 +90,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 2. Check for commands that require leader
if a.isLeaderSequence {
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
// Reset leader state
a.isLeaderSequence = false
if len(matches) > 0 {
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
@@ -89,48 +97,49 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// 3. Handle completions trigger
switch msg.String() {
case "/":
if !a.showCompletionDialog {
a.showCompletionDialog = true
if keyString == "/" && !a.showCompletionDialog {
a.showCompletionDialog = true
initialValue := "/"
currentInput := a.editor.Value()
// if the input doesn't end with a space,
// then we want to include the last word
if !strings.HasSuffix(currentInput, " ") {
words := strings.Split(a.editor.Value(), " ")
if len(words) > 0 {
lastWord := words[len(words)-1]
lastWord = strings.TrimSpace(lastWord)
initialValue = lastWord + "/"
}
initialValue := "/"
currentInput := a.editor.Value()
// if the input doesn't end with a space,
// then we want to include the last word
// (ie, `packages/`)
if !strings.HasSuffix(currentInput, " ") {
words := strings.Split(a.editor.Value(), " ")
if len(words) > 0 {
lastWord := words[len(words)-1]
lastWord = strings.TrimSpace(lastWord)
initialValue = lastWord + "/"
}
updated, cmd := a.completions.Update(
app.CompletionDialogTriggerdMsg{
InitialValue: initialValue,
},
)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
updated, cmd = a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
return a, tea.Sequence(cmds...)
}
updated, cmd := a.completions.Update(
app.CompletionDialogTriggerdMsg{
InitialValue: initialValue,
},
)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
updated, cmd = a.editor.Update(msg)
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
updated, cmd = a.updateCompletions(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
return a, tea.Sequence(cmds...)
}
if a.showCompletionDialog {
switch msg.String() {
case "tab", "enter", "esc":
context, contextCmd := a.completions.Update(msg)
a.completions = context.(dialog.CompletionDialog)
cmds = append(cmds, contextCmd)
switch keyString {
case "tab", "enter", "esc", "ctrl+c":
updated, cmd := a.updateCompletions(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}
@@ -138,13 +147,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
currentInput := a.editor.Value()
provider := a.completionManager.GetProvider(currentInput)
a.completions.SetProvider(provider)
updated, cmd = a.updateCompletions(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
context, contextCmd := a.completions.Update(msg)
a.completions = context.(dialog.CompletionDialog)
cmds = append(cmds, contextCmd)
return a, tea.Batch(cmds...)
}
@@ -170,10 +176,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
}
// 7. Fallback to editor. This shouldn't happen?
// All printable characters were already sent, and
// any other keypress that didn't match a command
// is likely a noop.
// 7. Fallback to editor. This is for other characters
// likek backspace, tab, etc.
updatedEditor, cmd := a.editor.Update(msg)
a.editor = updatedEditor.(chat.EditorComponent)
return a, cmd
@@ -208,7 +212,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
a.completions.SetProvider(a.completionManager.DefaultProvider())
case client.EventInstallationUpdated:
return a, toast.NewSuccessToast(
"opencode updated to "+msg.Properties.Version+", restart to apply.",
toast.WithTitle("New version installed"),
)
case client.EventSessionUpdated:
if msg.Properties.Info.Id == a.app.Session.Id {
a.app.Session = &msg.Properties.Info
@@ -226,6 +234,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Messages = append(a.app.Messages, msg.Properties.Info)
}
}
case client.EventSessionError:
unknownError, err := msg.Properties.Error.AsUnknownError()
if err == nil {
slog.Error("Server error", "name", unknownError.Name, "message", unknownError.Data.Message)
return a, toast.NewErrorToast(unknownError.Data.Message, toast.WithTitle(unknownError.Name))
}
case tea.WindowSizeMsg:
msg.Height -= 2 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height
@@ -240,12 +254,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
a.layout.SetSize(a.width, a.height)
case app.SessionSelectedMsg:
var err error
a.app.Session = msg
a.app.Messages, err = a.app.ListMessages(context.Background(), msg.Id)
messages, err := a.app.ListMessages(context.Background(), msg.Id)
if err != nil {
slog.Error("Failed to list messages", "error", err)
return a, toast.NewErrorToast("Failed to open session")
}
a.app.Session = msg
a.app.Messages = messages
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
@@ -255,6 +270,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.ThemeSelectedMsg:
a.app.State.Theme = msg.ThemeName
a.app.SaveState()
case toast.ShowToastMsg:
tm, cmd := a.toastManager.Update(msg)
a.toastManager = tm
cmds = append(cmds, cmd)
case toast.DismissToastMsg:
tm, cmd := a.toastManager.Update(msg)
a.toastManager = tm
cmds = append(cmds, cmd)
}
// update status bar
@@ -290,14 +313,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (a appModel) View() string {
layoutView := a.layout.View()
editorWidth, _ := a.editorContainer.GetSize()
editorX, editorY := a.editorContainer.GetPosition()
if a.editor.Lines() > 1 {
editorY = editorY - a.editor.Lines() + 1
layoutView = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(),
layoutView,
)
}
if a.showCompletionDialog {
editorWidth, _ := a.editorContainer.GetSize()
editorX, editorY := a.editorContainer.GetPosition()
a.completions.SetWidth(editorWidth)
overlay := a.completions.View()
layoutView = layout.PlaceOverlay(
editorX,
editorY-lipgloss.Height(overlay)+2,
@@ -311,10 +342,13 @@ func (a appModel) View() string {
a.status.View(),
}
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
if a.modal != nil {
appView = a.modal.Render(appView)
}
appView = a.toastManager.RenderOverlay(appView)
return appView
}
@@ -333,8 +367,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
}
editor := os.Getenv("EDITOR")
if editor == "" {
// TODO: let the user know there's no EDITOR set
return a, nil
return a, toast.NewErrorToast("No EDITOR set, can't open editor")
}
value := a.editor.Value()
@@ -346,7 +379,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
tmpfile.WriteString(value)
if err != nil {
slog.Error("Failed to create temp file", "error", err)
return a, nil
return a, toast.NewErrorToast("Something went wrong, couldn't open editor")
}
tmpfile.Close()
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
@@ -390,12 +423,21 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
if a.app.Session.Id == "" {
return a, nil
}
a.app.Client.PostSessionShareWithResponse(
response, err := a.app.Client.PostSessionShareWithResponse(
context.Background(),
client.PostSessionShareJSONRequestBody{
SessionID: a.app.Session.Id,
},
)
if err != nil {
slog.Error("Failed to share session", "error", err)
return a, toast.NewErrorToast("Failed to share session")
}
if response.JSON200 != nil && response.JSON200.Share != nil {
shareUrl := response.JSON200.Share.Url
cmds = append(cmds, tea.SetClipboard(shareUrl))
cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
}
case commands.SessionInterruptCommand:
if a.app.Session.Id == "" {
return a, nil
@@ -409,7 +451,12 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
// TODO: block until compaction is complete
a.app.CompactSession(context.Background())
case commands.ToolDetailsCommand:
message := "Tool details are now visible"
if a.messages.ToolDetailsVisible() {
message = "Tool details are now hidden"
}
cmds = append(cmds, util.CmdHandler(chat.ToggleToolDetailsMsg{}))
cmds = append(cmds, toast.NewInfoToast(message))
case commands.ModelListCommand:
modelDialog := dialog.NewModelDialog(a.app)
a.modal = modelDialog
@@ -460,30 +507,18 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesPageUpCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.messages.PageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesPageDownCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.messages.PageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesHalfPageUpCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.messages.HalfPageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesHalfPageDownCommand:
if a.showCompletionDialog {
return a, nil
}
updated, cmd := a.messages.HalfPageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
@@ -493,6 +528,15 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
return a, tea.Batch(cmds...)
}
func (a appModel) updateCompletions(msg tea.Msg) (tea.Model, tea.Cmd) {
currentInput := a.editor.Value()
if currentInput != "" {
provider := a.completionManager.GetProvider(currentInput)
a.completions.SetProvider(provider)
}
return a.completions.Update(msg)
}
func NewModel(app *app.App) tea.Model {
completionManager := completions.NewCompletionManager(app)
initialProvider := completionManager.DefaultProvider()
@@ -509,8 +553,8 @@ func NewModel(app *app.App) tea.Model {
messagesContainer := layout.NewContainer(messages)
var leaderBinding *key.Binding
if (*app.Configg.Keybinds).Leader != nil {
binding := key.NewBinding(key.WithKeys(*app.Configg.Keybinds.Leader))
if (*app.Config.Keybinds).Leader != nil {
binding := key.NewBinding(key.WithKeys(*app.Config.Keybinds.Leader))
leaderBinding = &binding
}
@@ -525,12 +569,13 @@ func NewModel(app *app.App) tea.Model {
isLeaderSequence: false,
showCompletionDialog: false,
editorContainer: editorContainer,
toastManager: toast.NewToastManager(),
layout: layout.NewFlexLayout(
[]tea.ViewModel{messagesContainer, editorContainer},
layout.WithDirection(layout.FlexDirectionVertical),
layout.WithSizes(
layout.FlexChildSizeGrow,
layout.FlexChildSizeFixed(6),
layout.FlexChildSizeFixed(5),
),
),
}
+18
View File
@@ -1,6 +1,9 @@
package util
import (
"os"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
)
@@ -17,3 +20,18 @@ func Clamp(v, low, high int) int {
}
return min(high, max(low, v))
}
func IsWsl() bool {
// Check for WSL environment variables
if os.Getenv("WSL_DISTRO_NAME") != "" {
return true
}
// Check /proc/version for WSL signature
if data, err := os.ReadFile("/proc/version"); err == nil {
version := strings.ToLower(string(data))
return strings.Contains(version, "microsoft") || strings.Contains(version, "wsl")
}
return false
}
+102 -65
View File
@@ -525,6 +525,9 @@
{
"$ref": "#/components/schemas/Event.storage.write"
},
{
"$ref": "#/components/schemas/Event.installation.updated"
},
{
"$ref": "#/components/schemas/Event.lsp.client.diagnostics"
},
@@ -537,9 +540,6 @@
{
"$ref": "#/components/schemas/Event.message.part.updated"
},
{
"$ref": "#/components/schemas/Event.installation.updated"
},
{
"$ref": "#/components/schemas/Event.session.updated"
},
@@ -551,11 +551,11 @@
"propertyName": "type",
"mapping": {
"storage.write": "#/components/schemas/Event.storage.write",
"installation.updated": "#/components/schemas/Event.installation.updated",
"lsp.client.diagnostics": "#/components/schemas/Event.lsp.client.diagnostics",
"permission.updated": "#/components/schemas/Event.permission.updated",
"message.updated": "#/components/schemas/Event.message.updated",
"message.part.updated": "#/components/schemas/Event.message.part.updated",
"installation.updated": "#/components/schemas/Event.installation.updated",
"session.updated": "#/components/schemas/Event.session.updated",
"session.error": "#/components/schemas/Event.session.error"
}
@@ -586,6 +586,30 @@
"properties"
]
},
"Event.installation.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "installation.updated"
},
"properties": {
"type": "object",
"properties": {
"version": {
"type": "string"
}
},
"required": [
"version"
]
}
},
"required": [
"type",
"properties"
]
},
"Event.lsp.client.diagnostics": {
"type": "object",
"properties": {
@@ -1201,30 +1225,6 @@
"properties"
]
},
"Event.installation.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "installation.updated"
},
"properties": {
"type": "object",
"properties": {
"version": {
"type": "string"
}
},
"required": [
"version"
]
}
},
"required": [
"type",
"properties"
]
},
"Event.session.updated": {
"type": "object",
"properties": {
@@ -1391,13 +1391,16 @@
"type": "object",
"properties": {
"$schema": {
"type": "string"
"type": "string",
"description": "JSON schema reference for configuration validation"
},
"theme": {
"type": "string"
"type": "string",
"description": "Theme name to use for the interface"
},
"keybinds": {
"$ref": "#/components/schemas/Config.Keybinds"
"$ref": "#/components/schemas/Config.Keybinds",
"description": "Custom keybind configurations"
},
"autoshare": {
"type": "boolean",
@@ -1508,7 +1511,8 @@
"required": [
"models"
]
}
},
"description": "Custom provider configurations and model overrides"
},
"mcp": {
"type": "object",
@@ -1528,7 +1532,8 @@
"remote": "#/components/schemas/Config.McpRemote"
}
}
}
},
"description": "MCP (Model Context Protocol) server configurations"
}
}
},
@@ -1536,85 +1541,112 @@
"type": "object",
"properties": {
"leader": {
"type": "string"
"type": "string",
"description": "Leader key for keybind combinations"
},
"help": {
"type": "string"
"type": "string",
"description": "Show help dialog"
},
"editor_open": {
"type": "string"
"type": "string",
"description": "Open external editor"
},
"session_new": {
"type": "string"
"type": "string",
"description": "Create a new session"
},
"session_list": {
"type": "string"
"type": "string",
"description": "List all sessions"
},
"session_share": {
"type": "string"
"type": "string",
"description": "Share current session"
},
"session_interrupt": {
"type": "string"
"type": "string",
"description": "Interrupt current session"
},
"session_compact": {
"type": "string"
"type": "string",
"description": "Toggle compact mode for session"
},
"tool_details": {
"type": "string"
"type": "string",
"description": "Show tool details"
},
"model_list": {
"type": "string"
"type": "string",
"description": "List available models"
},
"theme_list": {
"type": "string"
"type": "string",
"description": "List available themes"
},
"project_init": {
"type": "string"
"type": "string",
"description": "Initialize project configuration"
},
"input_clear": {
"type": "string"
"type": "string",
"description": "Clear input field"
},
"input_paste": {
"type": "string"
"type": "string",
"description": "Paste from clipboard"
},
"input_submit": {
"type": "string"
"type": "string",
"description": "Submit input"
},
"input_newline": {
"type": "string"
"type": "string",
"description": "Insert newline in input"
},
"history_previous": {
"type": "string"
"type": "string",
"description": "Navigate to previous history item"
},
"history_next": {
"type": "string"
"type": "string",
"description": "Navigate to next history item"
},
"messages_page_up": {
"type": "string"
"type": "string",
"description": "Scroll messages up by one page"
},
"messages_page_down": {
"type": "string"
"type": "string",
"description": "Scroll messages down by one page"
},
"messages_half_page_up": {
"type": "string"
"type": "string",
"description": "Scroll messages up by half page"
},
"messages_half_page_down": {
"type": "string"
"type": "string",
"description": "Scroll messages down by half page"
},
"messages_previous": {
"type": "string"
"type": "string",
"description": "Navigate to previous message"
},
"messages_next": {
"type": "string"
"type": "string",
"description": "Navigate to next message"
},
"messages_first": {
"type": "string"
"type": "string",
"description": "Navigate to first message"
},
"messages_last": {
"type": "string"
"type": "string",
"description": "Navigate to last message"
},
"app_exit": {
"type": "string"
"type": "string",
"description": "Exit the application"
}
}
},
@@ -1723,19 +1755,22 @@
"properties": {
"type": {
"type": "string",
"const": "local"
"const": "local",
"description": "Type of MCP server connection"
},
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"description": "Command and arguments to run the MCP server"
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"description": "Environment variables to set when running the MCP server"
}
},
"required": [
@@ -1748,10 +1783,12 @@
"properties": {
"type": {
"type": "string",
"const": "remote"
"const": "remote",
"description": "Type of MCP server connection"
},
"url": {
"type": "string"
"type": "string",
"description": "URL of the remote MCP server"
}
},
"required": [
+129 -61
View File
@@ -41,6 +41,7 @@ type AppInfo struct {
// ConfigInfo defines model for Config.Info.
type ConfigInfo struct {
// Schema JSON schema reference for configuration validation
Schema *string `json:"$schema,omitempty"`
// Autoshare Share newly created sessions automatically
@@ -50,12 +51,16 @@ type ConfigInfo struct {
Autoupdate *bool `json:"autoupdate,omitempty"`
// DisabledProviders Disable providers that are loaded automatically
DisabledProviders *[]string `json:"disabled_providers,omitempty"`
Keybinds *ConfigKeybinds `json:"keybinds,omitempty"`
Mcp *map[string]ConfigInfo_Mcp_AdditionalProperties `json:"mcp,omitempty"`
DisabledProviders *[]string `json:"disabled_providers,omitempty"`
Keybinds *ConfigKeybinds `json:"keybinds,omitempty"`
// Mcp MCP (Model Context Protocol) server configurations
Mcp *map[string]ConfigInfo_Mcp_AdditionalProperties `json:"mcp,omitempty"`
// Model Model to use in the format of provider/model, eg anthropic/claude-2
Model *string `json:"model,omitempty"`
Model *string `json:"model,omitempty"`
// Provider Custom provider configurations and model overrides
Provider *map[string]struct {
Api *string `json:"api,omitempty"`
Env *[]string `json:"env,omitempty"`
@@ -81,6 +86,8 @@ type ConfigInfo struct {
Npm *string `json:"npm,omitempty"`
Options *map[string]interface{} `json:"options,omitempty"`
} `json:"provider,omitempty"`
// Theme Theme name to use for the interface
Theme *string `json:"theme,omitempty"`
}
@@ -91,46 +98,107 @@ type ConfigInfo_Mcp_AdditionalProperties struct {
// ConfigKeybinds defines model for Config.Keybinds.
type ConfigKeybinds struct {
AppExit *string `json:"app_exit,omitempty"`
EditorOpen *string `json:"editor_open,omitempty"`
Help *string `json:"help,omitempty"`
HistoryNext *string `json:"history_next,omitempty"`
HistoryPrevious *string `json:"history_previous,omitempty"`
InputClear *string `json:"input_clear,omitempty"`
InputNewline *string `json:"input_newline,omitempty"`
InputPaste *string `json:"input_paste,omitempty"`
InputSubmit *string `json:"input_submit,omitempty"`
Leader *string `json:"leader,omitempty"`
MessagesFirst *string `json:"messages_first,omitempty"`
// AppExit Exit the application
AppExit *string `json:"app_exit,omitempty"`
// EditorOpen Open external editor
EditorOpen *string `json:"editor_open,omitempty"`
// Help Show help dialog
Help *string `json:"help,omitempty"`
// HistoryNext Navigate to next history item
HistoryNext *string `json:"history_next,omitempty"`
// HistoryPrevious Navigate to previous history item
HistoryPrevious *string `json:"history_previous,omitempty"`
// InputClear Clear input field
InputClear *string `json:"input_clear,omitempty"`
// InputNewline Insert newline in input
InputNewline *string `json:"input_newline,omitempty"`
// InputPaste Paste from clipboard
InputPaste *string `json:"input_paste,omitempty"`
// InputSubmit Submit input
InputSubmit *string `json:"input_submit,omitempty"`
// Leader Leader key for keybind combinations
Leader *string `json:"leader,omitempty"`
// MessagesFirst Navigate to first message
MessagesFirst *string `json:"messages_first,omitempty"`
// MessagesHalfPageDown Scroll messages down by half page
MessagesHalfPageDown *string `json:"messages_half_page_down,omitempty"`
MessagesHalfPageUp *string `json:"messages_half_page_up,omitempty"`
MessagesLast *string `json:"messages_last,omitempty"`
MessagesNext *string `json:"messages_next,omitempty"`
MessagesPageDown *string `json:"messages_page_down,omitempty"`
MessagesPageUp *string `json:"messages_page_up,omitempty"`
MessagesPrevious *string `json:"messages_previous,omitempty"`
ModelList *string `json:"model_list,omitempty"`
ProjectInit *string `json:"project_init,omitempty"`
SessionCompact *string `json:"session_compact,omitempty"`
SessionInterrupt *string `json:"session_interrupt,omitempty"`
SessionList *string `json:"session_list,omitempty"`
SessionNew *string `json:"session_new,omitempty"`
SessionShare *string `json:"session_share,omitempty"`
ThemeList *string `json:"theme_list,omitempty"`
ToolDetails *string `json:"tool_details,omitempty"`
// MessagesHalfPageUp Scroll messages up by half page
MessagesHalfPageUp *string `json:"messages_half_page_up,omitempty"`
// MessagesLast Navigate to last message
MessagesLast *string `json:"messages_last,omitempty"`
// MessagesNext Navigate to next message
MessagesNext *string `json:"messages_next,omitempty"`
// MessagesPageDown Scroll messages down by one page
MessagesPageDown *string `json:"messages_page_down,omitempty"`
// MessagesPageUp Scroll messages up by one page
MessagesPageUp *string `json:"messages_page_up,omitempty"`
// MessagesPrevious Navigate to previous message
MessagesPrevious *string `json:"messages_previous,omitempty"`
// ModelList List available models
ModelList *string `json:"model_list,omitempty"`
// ProjectInit Initialize project configuration
ProjectInit *string `json:"project_init,omitempty"`
// SessionCompact Toggle compact mode for session
SessionCompact *string `json:"session_compact,omitempty"`
// SessionInterrupt Interrupt current session
SessionInterrupt *string `json:"session_interrupt,omitempty"`
// SessionList List all sessions
SessionList *string `json:"session_list,omitempty"`
// SessionNew Create a new session
SessionNew *string `json:"session_new,omitempty"`
// SessionShare Share current session
SessionShare *string `json:"session_share,omitempty"`
// ThemeList List available themes
ThemeList *string `json:"theme_list,omitempty"`
// ToolDetails Show tool details
ToolDetails *string `json:"tool_details,omitempty"`
}
// ConfigMcpLocal defines model for Config.McpLocal.
type ConfigMcpLocal struct {
Command []string `json:"command"`
// Command Command and arguments to run the MCP server
Command []string `json:"command"`
// Environment Environment variables to set when running the MCP server
Environment *map[string]string `json:"environment,omitempty"`
Type string `json:"type"`
// Type Type of MCP server connection
Type string `json:"type"`
}
// ConfigMcpRemote defines model for Config.McpRemote.
type ConfigMcpRemote struct {
// Type Type of MCP server connection
Type string `json:"type"`
Url string `json:"url"`
// Url URL of the remote MCP server
Url string `json:"url"`
}
// Error defines model for Error.
@@ -684,6 +752,34 @@ func (t *Event) MergeEventStorageWrite(v EventStorageWrite) error {
return err
}
// AsEventInstallationUpdated returns the union data inside the Event as a EventInstallationUpdated
func (t Event) AsEventInstallationUpdated() (EventInstallationUpdated, error) {
var body EventInstallationUpdated
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromEventInstallationUpdated overwrites any union data inside the Event as the provided EventInstallationUpdated
func (t *Event) FromEventInstallationUpdated(v EventInstallationUpdated) error {
v.Type = "installation.updated"
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeEventInstallationUpdated performs a merge with any union data inside the Event, using the provided EventInstallationUpdated
func (t *Event) MergeEventInstallationUpdated(v EventInstallationUpdated) error {
v.Type = "installation.updated"
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsEventLspClientDiagnostics returns the union data inside the Event as a EventLspClientDiagnostics
func (t Event) AsEventLspClientDiagnostics() (EventLspClientDiagnostics, error) {
var body EventLspClientDiagnostics
@@ -796,34 +892,6 @@ func (t *Event) MergeEventMessagePartUpdated(v EventMessagePartUpdated) error {
return err
}
// AsEventInstallationUpdated returns the union data inside the Event as a EventInstallationUpdated
func (t Event) AsEventInstallationUpdated() (EventInstallationUpdated, error) {
var body EventInstallationUpdated
err := json.Unmarshal(t.union, &body)
return body, err
}
// FromEventInstallationUpdated overwrites any union data inside the Event as the provided EventInstallationUpdated
func (t *Event) FromEventInstallationUpdated(v EventInstallationUpdated) error {
v.Type = "installation.updated"
b, err := json.Marshal(v)
t.union = b
return err
}
// MergeEventInstallationUpdated performs a merge with any union data inside the Event, using the provided EventInstallationUpdated
func (t *Event) MergeEventInstallationUpdated(v EventInstallationUpdated) error {
v.Type = "installation.updated"
b, err := json.Marshal(v)
if err != nil {
return err
}
merged, err := runtime.JSONMerge(t.union, b)
t.union = merged
return err
}
// AsEventSessionUpdated returns the union data inside the Event as a EventSessionUpdated
func (t Event) AsEventSessionUpdated() (EventSessionUpdated, error) {
var body EventSessionUpdated
+2 -5
View File
@@ -7,7 +7,6 @@ import theme from "toolbeam-docs-theme"
import { rehypeHeadingIds } from "@astrojs/markdown-remark"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
const discord = "https://discord.gg/sst"
const github = "https://github.com/sst/opencode"
// https://astro.build/config
@@ -28,10 +27,9 @@ export default defineConfig({
integrations: [
solidJs(),
starlight({
title: "OpenCode",
title: "opencode",
expressiveCode: { themes: ["github-light", "github-dark"] },
social: [
{ icon: "discord", label: "Discord", href: discord },
{ icon: "github", label: "GitHub", href: github },
],
editLink: {
@@ -52,8 +50,7 @@ export default defineConfig({
"docs/config",
"docs/models",
"docs/themes",
"docs/shortcuts",
"docs/lsp-servers",
"docs/keybinds",
"docs/mcp-servers",
],
components: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

+69 -9
View File
@@ -3,6 +3,7 @@ import config from 'virtual:starlight/user-config';
import { Icon } from '@astrojs/starlight/components';
import { HeaderLinks } from 'toolbeam-docs-theme/components';
import Default from 'toolbeam-docs-theme/overrides/Header.astro';
import SocialIcons from 'virtual:starlight/components/SocialIcons';
import SiteTitle from '@astrojs/starlight/components/SiteTitle.astro';
const path = Astro.url.pathname;
@@ -11,19 +12,32 @@ const links = config.social || [];
---
{ path.startsWith("/s")
? <div class="header sl-flex">
<div class="title-wrapper sl-flex">
<SiteTitle {...Astro.props} />
</div>
<div class="middle-group sl-flex">
<HeaderLinks {...Astro.props} />
</div>
</div>
? <div class="header sl-flex">
<div class="title-wrapper sl-flex">
<SiteTitle {...Astro.props} />
</div>
<div class="middle-group sl-flex">
<HeaderLinks {...Astro.props} />
</div>
<div class="sl-hidden md:sl-flex right-group">
{
links.length > 0 && (
<div class="sl-flex social-icons">
{links.map(({ href, icon }) => (
<a {href} rel="me" target="_blank">
<Icon name={icon} size="1rem" />
</a>
))}
</div>
)
}
</div>
</div>
: <Default {...Astro.props}><slot /></Default>
}
<style>
.header {
gap: var(--sl-nav-gap);
justify-content: space-between;
align-items: center;
height: 100%;
@@ -53,4 +67,50 @@ const links = config.social || [];
display: flex;
}
}
.right-group,
.social-icons {
gap: 1rem;
align-items: center;
a {
line-height: 1;
svg {
color: var(--sl-color-text-dimmed);
}
}
}
@media (min-width: 50rem) {
:global(:root[data-has-sidebar]) {
--__sidebar-pad: calc(2 * var(--sl-nav-pad-x));
}
:global(:root:not([data-has-toc])) {
--__toc-width: 0rem;
}
.header {
--__sidebar-width: max(0rem, var(--sl-content-inline-start, 0rem) - var(--sl-nav-pad-x));
--__main-column-fr: calc(
(
100% + var(--__sidebar-pad, 0rem) - var(--__toc-width, var(--sl-sidebar-width)) -
(2 * var(--__toc-width, var(--sl-nav-pad-x))) - var(--sl-content-inline-start, 0rem) -
var(--sl-content-width)
) / 2
);
display: grid;
grid-template-columns:
/* 1 (site title): runs up until the main content columns left edge or the width of the title, whichever is the largest */
minmax(
calc(var(--__sidebar-width) + max(0rem, var(--__main-column-fr) - var(--sl-nav-gap))),
auto
)
/* 2 (search box): all free space that is available. */
1fr
/* 3 (right items): use the space that these need. */
auto;
align-content: center;
}
}
</style>
+98 -20
View File
@@ -5,6 +5,7 @@ import type { Props } from '@astrojs/starlight/props';
import CopyIcon from "../assets/lander/copy.svg";
import CheckIcon from "../assets/lander/check.svg";
import Screenshot from "../assets/themes/tokyonight.png";
const { data } = Astro.locals.starlightRoute.entry;
const { title = data.title, tagline, image, actions = [] } = data.hero || {};
@@ -19,7 +20,7 @@ const imageAttrs = {
const github = config.social.filter(s => s.icon === 'github')[0];
const command = "npm i -g";
const pkg = "opencode";
const pkg = "opencode-ai";
let darkImage: ImageMetadata | undefined;
let lightImage: ImageMetadata | undefined;
@@ -68,15 +69,22 @@ if (image) {
<section class="content">
<ul>
<li><b>Native TUI</b>: A native terminal UI for a smoother, snappier experience.</li>
<li><b>LSP enabled</b>: Loads the right LSPs for your codebase. Helps the LLM make fewer mistakes.</li>
<li><b>Multi-session</b>: Start multiple conversations in a project to have agents working in parallel.</li>
<li><b>Use any model</b>: Supports all the models from OpenAI, Anthropic, Google, OpenRouter, and more.</li>
<li><b>Change tracking</b>: View the file changes from the current conversation in the sidebar.</li>
<li><b>Edit with Vim</b>: Use Vim as an external editor to compose longer messages.</li>
<li><b>Native TUI</b>: A responsive, native, themeable terminal UI.</li>
<li><b>LSP enabled</b>: Automatically loads the right LSPs for the LLM.</li>
<li><b>Multi-session</b>: Start multiple agents in parallel on the same project.</li>
<li><b>Shareable links</b>: Share a link to any sessions for reference or to debug.</li>
<li><b>Claude Pro</b>: Log in with Anthropic to use your Claude Pro or Max account.</li>
<li><b>Use any model</b>: Supports 75+ LLM providers through <a href="https://models.dev">Models.dev</a>, including local models.</li>
</ul>
</section>
<section class="images">
<div>
<p>opencode TUI with the tokyonight theme</p>
<Image width={600} src={Screenshot} alt="opencode TUI with the tokyonight theme" />
</div>
</section>
<section class="footer">
<div class="col1">
<span>Version: Beta</span>
@@ -94,7 +102,7 @@ if (image) {
--heading-font-size: var(--sl-text-3xl);
margin: 1rem;
border: 2px solid var(--sl-color-white);
border: 2px solid var(--sl-color-border);
}
@media (max-width: 30rem) {
.hero {
@@ -126,26 +134,41 @@ section.cta {
display: flex;
flex-direction: row;
justify-content: space-between;
border-top: 2px solid var(--sl-color-white);
border-top: 2px solid var(--sl-color-border);
@media (max-width: 40rem) {
flex-direction: column;
}
& > div {
flex: 1;
line-height: 1.4;
padding: calc(var(--padding) / 2) 0.5rem;
@media (max-width: 40rem) {
padding-bottom: calc(var(--padding) / 2 + 4px);
}
a {
font-size: 1rem;
}
}
& > div.col2 {
@media (max-width: 50rem) {
flex: 0 0 auto;
}
}
& > div:not(.col2) {
text-align: center;
text-transform: uppercase;
}
@media (max-width: 30rem) {
& > div {
padding-bottom: calc(var(--padding) / 2 + 4px);
}
}
& > div + div {
border-left: 2px solid var(--sl-color-white);
border-left: 2px solid var(--sl-color-border);
@media (max-width: 40rem) {
border-left: none;
border-top: 2px solid var(--sl-color-border);
}
}
.command {
@@ -195,7 +218,7 @@ section.cta {
}
section.content {
border-top: 2px solid var(--sl-color-white);
border-top: 2px solid var(--sl-color-border);
padding: var(--padding);
ul {
@@ -211,8 +234,63 @@ section.content {
}
}
section.images {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: space-between;
border-top: 2px solid var(--sl-color-border);
& > div {
flex: 1;
display: flex;
flex-direction: column;
gap: calc(var(--padding) / 4);
padding: calc(var(--padding) / 2);
border-width: 0;
border-style: solid;
border-color: var(--sl-color-border);
& > div, p {
flex: 1;
display: flex;
align-items: center;
}
}
p {
letter-spacing: -0.03125rem;
text-transform: uppercase;
color: var(--sl-color-text-dimmed);
@media (max-width: 30rem) {
font-size: 0.75rem;
}
}
img {
align-self: center;
width: 100%;
max-width: 600px;
height: auto;
}
& > div + div {
border-width: 0 0 0 2px;
}
@media (max-width: 30rem) {
& {
flex-direction: column;
}
& > div + div {
border-width: 2px 0 0 0;
}
}
}
section.approach {
border-top: 2px solid var(--sl-color-white);
border-top: 2px solid var(--sl-color-border);
padding: var(--padding);
p + p {
@@ -221,7 +299,7 @@ section.approach {
}
section.footer {
border-top: 2px solid var(--sl-color-white);
border-top: 2px solid var(--sl-color-border);
display: flex;
flex-direction: row;
@@ -233,7 +311,7 @@ section.footer {
}
& > div + div {
border-left: 2px solid var(--sl-color-white);
border-left: 2px solid var(--sl-color-border);
}
}
</style>
+16 -14
View File
@@ -801,7 +801,9 @@ export default function Share(props: {
<div data-stat-icon title="opencode">
<IconOpencode width={16} height={16} />
</div>
<span>v${store.info?.version}</span>
<Show when={store.info?.version} fallback="v0.0.1">
<span>v{store.info?.version}</span>
</Show>
</li>
</ul>
</Show>
@@ -859,7 +861,7 @@ export default function Share(props: {
(partIndex() > 0 || !msg.metadata?.assistant)) ||
(msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_todoread")
part.toolInvocation.toolName === "todoread")
)
return null
@@ -1072,7 +1074,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_grep" &&
part.toolInvocation.toolName === "grep" &&
part
}
>
@@ -1175,7 +1177,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_glob" &&
part.toolInvocation.toolName === "glob" &&
part
}
>
@@ -1253,7 +1255,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_list" &&
part.toolInvocation.toolName === "list" &&
part
}
>
@@ -1261,9 +1263,9 @@ export default function Share(props: {
const path = createMemo(() =>
toolData()?.args.path !== data().rootDir
? stripWorkingDirectory(
toolData()?.args.path,
data().rootDir,
)
toolData()?.args.path,
data().rootDir,
)
: toolData()?.args.path,
)
@@ -1322,7 +1324,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_read" &&
part.toolInvocation.toolName === "read" &&
part
}
>
@@ -1417,7 +1419,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_write" &&
part.toolInvocation.toolName === "write" &&
part
}
>
@@ -1503,7 +1505,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_edit" &&
part.toolInvocation.toolName === "edit" &&
part
}
>
@@ -1577,7 +1579,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_bash" &&
part.toolInvocation.toolName === "bash" &&
part
}
>
@@ -1620,7 +1622,7 @@ export default function Share(props: {
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName ===
"opencode_todowrite" &&
"todowrite" &&
part
}
>
@@ -1686,7 +1688,7 @@ export default function Share(props: {
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName ===
"opencode_webfetch" &&
"webfetch" &&
part
}
>
@@ -194,6 +194,7 @@
border-radius: 7px;
white-space: nowrap;
z-index: 1;
opacity: 0;
visibility: hidden;
+100 -69
View File
@@ -2,88 +2,119 @@
title: CLI
---
Once installed you can run the OpenCode CLI.
Running the opencode CLI starts it for the current directory.
```bash
opencode
```
Or pass in flags. For example, to start with debug logging:
Or you can start it for a specific working directory.
```bash
opencode -d
opencode /path/to/project
```
Or start with a specific working directory.
---
## Commands
The opencode CLI also has the following commands.
### run
Run opencode in non-interactive mode by passing a prompt directly.
```bash
opencode -c /path/to/project
opencode run [message..]
```
This is useful for scripting, automation, or when you want a quick answer without launching the full TUI. For example.
```bash "opencode run"
opencode run Explain the use of context in Go
```
#### Flags
| Flag | Short | Description |
| ----------------- | ----- | --------------------- |
| `--continue` | `-c` | Continue the last session |
| `--session` | `-s` | Session ID to continue |
| `--share` | | Share the session |
| `--model` | `-m` | Mode to use in the form of provider/model |
---
### auth
Command to manage credentials and login for providers.
```bash
opencode auth [command]
```
#### login
Logs you into a provider and saves them in the credentials file in `~/.local/share/opencode/auth.json`.
```bash
opencode auth login
```
When opencode starts up it will loads the providers from the credentials file. And if there are any keys defined in your environments or a `.env` file in your project.
#### list
Lists all the authenticated providers as stored in the credentials file.
```bash
opencode auth list
```
Or the short version.
```bash
opencode auth ls
```
#### logout
Logs you out of a provider by clearing it from the credentials file.
```bash
opencode auth logout
```
---
### upgrade
Updates opencode to the latest version or a specific version.
```bash
opencode upgrade [target]
```
To upgrade to the latest version.
```bash
opencode upgrade
```
To upgrade to a specific version.
```bash
opencode upgrade v0.1.48
```
---
## Flags
The OpenCode CLI takes the following flags.
The opencode CLI takes the following flags.
| Flag | Short | Description |
| ----------------- | ----- | -------------------------------------------------------- |
| `--help` | `-h` | Display help |
| `--debug` | `-d` | Enable debug mode |
| `--cwd` | `-c` | Set current working directory |
| `--prompt` | `-p` | Run a single prompt in non-interactive mode |
| `--output-format` | `-f` | Output format for non-interactive mode, `text` or `json` |
| `--quiet` | `-q` | Hide spinner in non-interactive mode |
| `--verbose` | | Display logs to stderr in non-interactive mode |
| `--allowedTools` | | Restrict the agent to only use specified tools |
| `--excludedTools` | | Prevent the agent from using specified tools |
## Non-interactive
By default, OpenCode runs in interactive mode.
But you can also run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI.
For example, to run a single prompt use the `-p` flag.
```bash "-p"
opencode -p "Explain the use of context in Go"
```
If you want to run without showing the spinner, use `-q`.
```bash "-q"
opencode -p "Explain the use of context in Go" -q
```
In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All **permissions are auto-approved** for the session.
#### Tool restrictions
You can control which tools the AI assistant has access to in non-interactive mode.
- `--allowedTools`
A comma-separated list of tools that the agent is allowed to use. Only these tools will be available.
```bash "--allowedTools"
opencode -p "Explain the use of context in Go" --allowedTools=view,ls,glob
```
- `--excludedTools`
Comma-separated list of tools that the agent is not allowed to use. All other tools will be available.
```bash "--excludedTools"
opencode -p "Explain the use of context in Go" --excludedTools=bash,edit
```
These flags are mutually exclusive. So you can either use `--allowedTools` or `--excludedTools`, but not both.
#### Output formats
In non-interactive mode, you can also set the CLI to return as JSON using `-f`.
```bash "-f json"
opencode -p "Explain the use of context in Go" -f json
```
By default, this is set to `text`, to return plain text.
| Flag | Short | Description |
| ----------------- | ----- | --------------------- |
| `--help` | `-h` | Display help |
| `--version` | | Print version number |
| `--print-logs` | | Print logs to stderr |
+79 -75
View File
@@ -2,86 +2,90 @@
title: Config
---
You can configure OpenCode using the OpenCode config. It can be places in:
You can configure opencode using a JSON config file that can be placed in:
- `$HOME/.opencode.json`
- `$XDG_CONFIG_HOME/opencode/.opencode.json`
- Globally under `~/.config/opencode/config.json`.
- Your project root under `opencode.json`. This is safe to be checked into Git and uses the same schema as the global one.
Or in the current directory, `./.opencode.json`.
## OpenCode config
The config file has the following structure.
```json title=".opencode.json"
```json
{
"data": {
"directory": ".opencode"
},
"providers": {
"openai": {
"apiKey": "your-api-key",
"disabled": false
},
"anthropic": {
"apiKey": "your-api-key",
"disabled": false
},
"groq": {
"apiKey": "your-api-key",
"disabled": false
},
"openrouter": {
"apiKey": "your-api-key",
"disabled": false
}
},
"agents": {
"primary": {
"model": "claude-3.7-sonnet",
"maxTokens": 5000
},
"task": {
"model": "claude-3.7-sonnet",
"maxTokens": 5000
},
"title": {
"model": "claude-3.7-sonnet",
"maxTokens": 80
}
},
"mcpServers": {
"example": {
"type": "stdio",
"command": "path/to/mcp-server",
"env": [],
"args": []
}
},
"lsp": {
"go": {
"disabled": false,
"command": "gopls"
}
},
"debug": false,
"debugLSP": false
"$schema": "https://opencode.ai/config.json",
"theme": "opencode",
"model": "anthropic/claude-sonnet-4-20250514",
"autoshare": false,
"autoupdate": true
}
```
## Environment variables
In most cases, you'll want to use the global config for things like themes, providers, or keybinds. Having a config per project is useful if you are using different providers for your company.
For the providers, you can also specify the keys using environment variables.
When opencode starts up, it looks for a config file in the current directory or traverse up to the nearest Git directory.
| Environment Variable | Models |
| -------------------------- | ------------------------------------------ |
| `ANTHROPIC_API_KEY` | Claude |
| `OPENAI_API_KEY` | OpenAI |
| `GEMINI_API_KEY` | Google Gemini |
| `GROQ_API_KEY` | Groq |
| `AWS_ACCESS_KEY_ID` | Amazon Bedrock |
| `AWS_SECRET_ACCESS_KEY` | Amazon Bedrock |
| `AWS_REGION` | Amazon Bedrock |
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI |
| `AZURE_OPENAI_API_KEY` | Azure OpenAI, optional when using Entra ID |
| `AZURE_OPENAI_API_VERSION` | Azure OpenAI |
---
## Schema
The config file has a schema that's defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
Your editor should be able to validate and autocomplete based on the schema.
---
### Models
You can configure the providers and models you want to use in your opencode config through the `provider` and `model` options.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": { },
"model": ""
}
```
[Learn more here](/docs/models).
---
### Themes
You can configure the theme you want to use in your opencode config through the `theme` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"theme": ""
}
```
[Learn more here](/docs/themes).
---
### Keybinds
You can customize your keybinds through the `keybinds` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"keybinds": { }
}
```
[Learn more here](/docs/keybinds).
---
### MCP servers
You can configure MCP servers you want to use through the `mcp` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"mcp": { }
}
```
[Learn more here](/docs/mcp-servers).
+65 -31
View File
@@ -2,57 +2,91 @@
title: Intro
---
OpenCode is an AI coding agent built natively for the terminal. It features:
import { Tabs, TabItem } from '@astrojs/starlight/components';
- Native TUI for a smoother, snappier experience
- Uses LSPs to help the LLM make fewer mistakes
- Opening multiple conversations with the same project
- Use of any model through the AI SDK
- Tracks and visualizes all the file changes
- Editing longer messages with Vim
[**opencode**](/) is an AI coding agent built for the terminal. It features:
## Installation
- A responsive, native, themeable terminal UI.
- Automatically loads the right LSPs, so the LLMs make fewer mistakes.
- Have multiple agents working in parallel on the same project.
- Create shareable links to any session for reference or to debug.
- Log in with Anthropic to use your Claude Pro or Claude Max account.
- Supports 75+ LLM providers through [Models.dev](https://models.dev), including local models.
```bash
npm i -g opencode
```
![opencode TUI with the opencode theme](../../../assets/themes/opencode.png)
If you don't have NPM installed, you can also install the OpenCode binary through the following.
---
#### Using the install script
## Install
<Tabs>
<TabItem label="npm">
```bash
npm install -g opencode-ai
```
</TabItem>
<TabItem label="Bun">
```bash
bun install -g opencode-ai
```
</TabItem>
<TabItem label="pnpm">
```bash
pnpm install -g opencode-ai
```
</TabItem>
<TabItem label="Yarn">
```bash
yarn global add opencode-ai
```
</TabItem>
</Tabs>
You can also install the opencode binary through the following.
##### Using the install script
```bash
curl -fsSL https://opencode.ai/install | bash
```
Or install a specific version.
```bash
curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash
```
#### Using Homebrew on macOS and Linux
##### Using Homebrew on macOS
```bash
brew install sst/tap/opencode
```
#### Using AUR in Arch Linux
With yay.
```bash
yay -S opencode-bin
```
Or with paru.
##### Using Paru on Arch Linux
```bash
paru -S opencode-bin
```
#### Using Go
---
## Providers
We recommend signing up for Claude Pro or Max, running `opencode auth login` and selecting Anthropic. It's the most cost-effective way to use opencode.
```bash
go install github.com/sst/opencode@latest
$ opencode auth login
┌ Add credential
◆ Select provider
│ ● Anthropic (recommended)
│ ○ OpenAI
│ ○ Google
│ ○ Amazon Bedrock
│ ○ Azure
│ ○ DeepSeek
│ ○ Groq
│ ...
```
opencode is powered by the provider list at [Models.dev](https://models.dev), so you can use `opencode auth login` to configure API keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`.
The Models.dev dataset is also used to detect common environment variables like `OPENAI_API_KEY` to autoload that provider.
If there are additional providers you want to use you can submit a PR to the [Models.dev repo](https://github.com/sst/models.dev). You can also [add them to your config](/docs/config) for yourself.
@@ -0,0 +1,48 @@
---
title: Keybinds
---
opencode has a list of keybinds that you can customize through the opencode config.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"keybinds": {
"leader": "ctrl+x",
"help": "<leader>h",
"editor_open": "<leader>e",
"session_new": "<leader>n",
"session_list": "<leader>l",
"session_share": "<leader>s",
"session_interrupt": "esc",
"session_compact": "<leader>c",
"tool_details": "<leader>d",
"model_list": "<leader>m",
"theme_list": "<leader>t",
"project_init": "<leader>i",
"input_clear": "ctrl+c",
"input_paste": "ctrl+v",
"input_submit": "enter",
"input_newline": "shift+enter,ctrl+j",
"history_previous": "up",
"history_next": "down",
"messages_page_up": "pgup",
"messages_page_down": "pgdown",
"messages_half_page_up": "ctrl+alt+u",
"messages_half_page_down": "ctrl+alt+d",
"messages_previous": "ctrl+alt+k",
"messages_next": "ctrl+alt+j",
"messages_first": "ctrl+g",
"messages_last": "ctrl+alt+g",
"app_exit": "ctrl+c,<leader>q"
}
}
```
## Leader key
opencode uses a `leader` key for most keybinds. This avoids conflicts in your terminal.
By default, `ctrl+x` is the leader key and most actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`.
You don't need to use a leader key for your keybinds but we recommend doing so.
@@ -2,7 +2,7 @@
title: LSP servers
---
OpenCode integrates with _Language Server Protocol_, or LSP to improve how the LLM interacts with your codebase.
opencode integrates with _Language Server Protocol_, or LSP to improve how the LLM interacts with your codebase.
LSP servers for different languages give the LLM:
@@ -11,13 +11,13 @@ LSP servers for different languages give the LLM:
## Auto-detection
By default, OpenCode will **automatically detect** the languages used in your project and add the right LSP servers.
By default, opencode will **automatically detect** the languages used in your project and add the right LSP servers.
## Manual configuration
You can also manually configure LSP servers by adding them under the `lsp` section in your OpenCode config.
You can also manually configure LSP servers by adding them under the `lsp` section in your opencode config.
```json title=".opencode.json"
```json title="opencode.json"
{
"lsp": {
"go": {
@@ -2,27 +2,33 @@
title: MCP servers
---
You can add external tools to OpenCode using the _Model Context Protocol_, or MCP. OpenCode supports both:
You can add external tools to opencode using the _Model Context Protocol_, or MCP. opencode supports both:
- Local servers that use standard input/output, `stdio`
- Remote servers that use server-sent events `sse`
- Local servers
- And remote servers
## Add MCP servers
Once added, MCP tools are automatically available to the LLM alongside built-in tools.
You can define MCP servers in your OpenCode config under the `mcpServers` section:
---
## Configure
You can define MCP servers in your opencode config under `mcp`.
### Local
To add a local or `stdio` MCP server.
Add a local MCP servers under `mcp.localmcp`.
```json title=".opencode.json" {4}
```json title="opencode.json"
{
"mcpServers": {
"local-example": {
"type": "stdio",
"command": "path/to/mcp-server",
"env": [],
"args": []
"$schema": "https://opencode.ai/config.json",
"mcp": {
"localmcp": {
"type": "local",
"command": ["bun", "x", "my-mcp-command"],
"environment": {
"MY_ENV_VAR": "my_env_var_value"
}
}
}
}
@@ -30,22 +36,16 @@ To add a local or `stdio` MCP server.
### Remote
To add a remote or `sse` MCP server.
Add a remote MCP servers under `mcp.remotemcp`.
```json title=".opencode.json" {4}
```json title="opencode.json"
{
"mcpServers": {
"remote-example": {
"type": "sse",
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer token"
}
"$schema": "https://opencode.ai/config.json",
"mcp": {
"remotemcp": {
"type": "remote",
"url": "https://my-mcp-server.com"
}
}
}
```
## Usage
Once added, MCP tools are automatically available to the LLM alongside built-in tools. They follow the same permission model; requiring user approval before execution.
+74 -19
View File
@@ -2,33 +2,88 @@
title: Models
---
OpenCode uses the [AI SDK](https://ai-sdk.dev/) to have the support for **all the AI models**.
opencode uses the [AI SDK](https://ai-sdk.dev/) and [Models.dev](https://models.dev) to support for **75+ LLM providers** and it supports running local models.
Start by setting the [keys for the providers](/docs/config) you want to use in your OpenCode config.
---
## Model select
## Providers
You can now select the model you want from the menu by hitting `Ctrl+O`.
You can configure providers in your opencode config under the `provider` section.
## Multiple models
### Defaults
You can also use specific models for specific tasks. For example, you can use a smaller model to generate the title of the conversation or to run a sub task.
Most popular providers are preloaded by default. If you've added the credentials for a provider through `opencode auth login`, they'll be available when you start opencode.
```json title=".opencode.json"
### Custom
You can add custom providers by specifying the npm package for the provider and the models you want to use.
```json title="opencode.json" {5,9-11}
{
"agents": {
"primary": {
"model": "gpt-4",
"maxTokens": 5000
},
"task": {
"model": "gpt-3.5-turbo",
"maxTokens": 5000
},
"title": {
"model": "gpt-3.5-turbo",
"maxTokens": 80
"$schema": "https://opencode.ai/config.json",
"provider": {
"openrouter": {
"npm": "@openrouter/ai-sdk-provider",
"name": "OpenRouter",
"options": {},
"models": {
"anthropic/claude-3.5-sonnet": {
"name": "Claude 3.5 Sonnet"
}
}
}
}
}
```
### Local
To configure a local model, specify the npm package to use and the `baseURL`.
```json title="opencode.json" {5,7}
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "http://localhost:11434/v1"
},
"models": {
"llama2": {}
}
}
}
}
```
---
## Select a model
If you have multiple models, you can select the model you want by typing in:
```bash frame="none"
/models
```
---
## Loading models
When opencode starts up, it checks for the following:
1. The model list in the opencode config.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"model": "anthropic/claude-sonnet-4-20250514"
}
```
The format here is `provider/model`.
2. The last used model.
3. The first model using an internal priority.
@@ -1,68 +0,0 @@
---
title: Keyboard shortcuts
sidebar:
label: Shortcuts
---
Below are a list of keyboard shortcuts that OpenCode supports.
## Global
| Shortcut | Action |
| -------- | ------------------------------------------------------- |
| `Ctrl+C` | Quit application |
| `Ctrl+?` | Toggle help dialog |
| `?` | Toggle help dialog (when not in editing mode) |
| `Ctrl+L` | View logs |
| `Ctrl+A` | Switch session |
| `Ctrl+K` | Command dialog |
| `Ctrl+O` | Toggle model selection dialog |
| `Esc` | Close current overlay/dialog or return to previous mode |
## Chat pane
| Shortcut | Action |
| -------- | --------------------------------------- |
| `Ctrl+N` | Create new session |
| `Ctrl+X` | Cancel current operation/generation |
| `i` | Focus editor (when not in writing mode) |
| `Esc` | Exit writing mode and focus messages |
## Editor view
| Shortcut | Action |
| ------------------- | ----------------------------------------- |
| `Ctrl+S` | Send message (when editor is focused) |
| `Enter` or `Ctrl+S` | Send message (when editor is not focused) |
| `Ctrl+E` | Open external editor |
| `Esc` | Blur editor and focus messages |
## Session dialog
| Shortcut | Action |
| ---------- | ---------------- |
| `↑` or `k` | Previous session |
| `↓` or `j` | Next session |
| `Enter` | Select session |
| `Esc` | Close dialog |
## Model dialog
| Shortcut | Action |
| ---------- | ----------------- |
| `↑` or `k` | Move up |
| `↓` or `j` | Move down |
| `←` or `h` | Previous provider |
| `→` or `l` | Next provider |
| `Esc` | Close dialog |
## Permission dialog
| Shortcut | Action |
| ----------------------- | ---------------------------- |
| `←` or `left` | Switch options left |
| `→` or `right` or `tab` | Switch options right |
| `Enter` or `space` | Confirm selection |
| `a` | Allow permission |
| `A` | Allow permission for session |
| `d` | Deny permission |
+28 -55
View File
@@ -2,74 +2,47 @@
title: Themes
---
OpenCode supports most common terminal themes and you can create your own custom theme.
opencode will support most common terminal themes and you'll soon be able to create your own custom theme.
## Built-in themes
---
The following predefined themes are available:
## Built-in
The following built-in themes are available:
- `opencode`
- `catppuccin`
- `dracula`
- `flexoki`
- `gruvbox`
- `monokai`
- `onedark`
![opencode theme](../../../assets/themes/opencode.png)
- `ayu`
![ayu theme](../../../assets/themes/ayu.png)
- `everforest`
![everforest theme](../../../assets/themes/everforest.png)
- `tokyonight`
- `tron`
- `custom`
Where `opencode` is the default theme and `custom` let's you define your own theme.
![tokyonight theme](../../../assets/themes/tokyonight.png)
## Setting a theme
---
You can set your theme in your OpenCode config.
## Configure
```json title=".opencode.json"
{
"tui": {
"theme": "monokai"
}
}
To select a theme, type in:
```bash frame="none"
/themes
```
## Create a theme
Your selected theme will be used the next time you start opencode.
You can create your own custom theme by setting the `theme: custom` and providing color definitions through the `customTheme`.
You can also configure it in your opencode config.
```json title=".opencode.json"
```json title="opencode.json" {3}
{
"tui": {
"theme": "custom",
"customTheme": {
"primary": "#ffcc00",
"secondary": "#00ccff",
"accent": { "dark": "#aa00ff", "light": "#ddccff" },
"error": "#ff0000"
}
}
"$schema": "https://opencode.ai/config.json",
"theme": "tokyonight"
}
```
#### Color keys
You can define any of the following color keys in your `customTheme`.
| Type | Color keys |
| ----------------- | ------------------------------------------------------- |
| Base colors | `primary`, `secondary`, `accent` |
| Status colors | `error`, `warning`, `success`, `info` |
| Text colors | `text`, `textMuted` |
| Background colors | `background`, `backgroundSubtle`, `backgroundElement` |
| Border colors | `border`, `borderActive`, `borderSubtle` |
| Diff view colors | `diffAdded`, `diffRemoved`, `diffContext`, etc. |
You don't need to define all the color keys. Any undefined colors will fall back to the default `opencode` theme colors.
#### Color definitions
Color keys can take:
1. **Hex string**: A single hex color string, like `"#aabbcc"`, that'll be used for both light and dark terminal backgrounds.
2. **Light and dark colors**: An object with `dark` and `light` hex colors that'll be set based on the terminal's background.
+2 -2
View File
@@ -1,5 +1,5 @@
---
title: OpenCode
title: opencode
description: The AI coding agent built for the terminal.
template: splash
hero:
@@ -8,5 +8,5 @@ hero:
image:
dark: ../../assets/logo-dark.svg
light: ../../assets/logo-light.svg
alt: OpenCode logo
alt: opencode logo
---
+10 -5
View File
@@ -11,13 +11,12 @@ const { id } = Astro.params;
const res = await fetch(`${apiUrl}/share_data?id=${id}`);
const data = await res.json();
let cost = 0;
const models: Set<string> = new Set();
const version = `v${data.info.version}`;
const version = data.info.version ? `v${data.info.version}` : "v0.0.1";
Object.values(data.messages).forEach((d) => {
const assistant = d.metadata?.assistant;
if (assistant) {
cost += assistant.cost;
models.add(assistant.modelID);
}
});
@@ -31,9 +30,8 @@ const encodedTitle = encodeURIComponent(
)
)
);
const encodedCost = encodeURIComponent(`$${cost.toFixed(2)}`);
const ogImage = `https://social-cards.sst.dev/opencode-share/${encodedTitle}.png?cost=${encodedCost}&model=${Array.from(models).join(",")}&version=${version}&id=${id}`;
const ogImage = `https://social-cards.sst.dev/opencode-share/${encodedTitle}.png?model=${Array.from(models).join(",")}&version=${version}&id=${id}`;
---
<StarlightPage
@@ -44,6 +42,13 @@ const ogImage = `https://social-cards.sst.dev/opencode-share/${encodedTitle}.png
template: "splash",
tableOfContents: false,
head: [
{
tag: "meta",
attrs: {
name: "description",
content: "opencode - The AI coding agent built for the terminal.",
},
},
{
tag: "meta",
attrs: {
+4
View File
@@ -3,6 +3,10 @@
--sl-color-divider: var(--sl-color-gray-5);
}
.expressive-code .frame {
box-shadow: none;
}
@media (prefers-color-scheme: dark) {
.shiki,
.shiki span {
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Executable
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
if [ ! -d ".git" ]; then
exit 0
fi
mkdir -p .git/hooks
cat > .git/hooks/pre-push << 'EOF'
#!/bin/sh
bun run typecheck
EOF
chmod +x .git/hooks/pre-push
echo "✅ Pre-push hook installed"
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
set -e
goreleaser build --clean --snapshot --skip validate