Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b0329f67f | ||
|
|
311b9c74dd | ||
|
|
f7e8dd2ff8 | ||
|
|
40b1dd7ef2 | ||
|
|
261e76e0a3 | ||
|
|
a300bfaccb | ||
|
|
41dba0db08 | ||
|
|
6674c6083a | ||
|
|
f6afa2c6bb | ||
|
|
b2fb0508ea | ||
|
|
93f4252bb1 | ||
|
|
46ab9c16dd | ||
|
|
d869df4fee | ||
|
|
b99d4650ec | ||
|
|
261bb7f110 | ||
|
|
0515fbb260 | ||
|
|
88211d8c5b | ||
|
|
a812f95b9d | ||
|
|
3728a12bee | ||
|
|
af07e51213 | ||
|
|
3113788c92 | ||
|
|
efb5fe6d4e | ||
|
|
54dd6c644d | ||
|
|
39ad8f2667 | ||
|
|
c4a2c84e53 | ||
|
|
44fe012812 | ||
|
|
f5e7f079ea | ||
|
|
15a8936806 | ||
|
|
4e4cff49c0 | ||
|
|
5540503bee | ||
|
|
193718034b | ||
|
|
72108c0296 | ||
|
|
ec1c9f8cd1 | ||
|
|
a85b0a370e | ||
|
|
e7784d2864 | ||
|
|
97c4815444 | ||
|
|
7d1a1663c8 | ||
|
|
24c0ce6e53 | ||
|
|
4cdc86612c | ||
|
|
f1f3f8d12c | ||
|
|
e78d3b54bf | ||
|
|
f8a7cd372d | ||
|
|
f48eac638d | ||
|
|
e1f12f93eb | ||
|
|
7ca8334a8b | ||
|
|
f1a2b2eba4 | ||
|
|
4b132656df | ||
|
|
26bab00dab | ||
|
|
568c04753e | ||
|
|
4a06e164d2 | ||
|
|
c57b52c300 | ||
|
|
0b8f48f17f | ||
|
|
3862184ccb | ||
|
|
8619c50976 | ||
|
|
bb6b56b72a | ||
|
|
1252b65166 | ||
|
|
6840276dad |
@@ -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,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>
|
||||
|
||||
[](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.
|
||||
|
||||
[](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
|
||||
|
||||
@@ -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
@@ -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
@@ -1,5 +1,3 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {},
|
||||
"provider": {}
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
}
|
||||
|
||||
+3
-2
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -71,4 +71,8 @@ export namespace UI {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function error(message: string) {
|
||||
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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...,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ var Terminal *TerminalInfo
|
||||
|
||||
func init() {
|
||||
Terminal = &TerminalInfo{
|
||||
BackgroundIsDark: false,
|
||||
BackgroundIsDark: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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 column’s 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
```
|
||||

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

|
||||
|
||||
- `ayu`
|
||||
|
||||

|
||||
|
||||
- `everforest`
|
||||
|
||||

|
||||
|
||||
- `tokyonight`
|
||||
- `tron`
|
||||
- `custom`
|
||||
|
||||
Where `opencode` is the default theme and `custom` let's you define your own theme.
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB |
Executable
+15
@@ -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"
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
goreleaser build --clean --snapshot --skip validate
|
||||
Reference in New Issue
Block a user