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 | ||
|
|
bd8c3cd0f1 | ||
|
|
e5e9b3e3c0 | ||
|
|
1e8a681de9 | ||
|
|
a834bedc17 | ||
|
|
6a3392385e | ||
|
|
6a00e063c4 | ||
|
|
73a0ce2b7d | ||
|
|
4d1afd01fa | ||
|
|
801d5f47bd | ||
|
|
b6caae9708 | ||
|
|
183ca64ef9 | ||
|
|
8c32cfe829 | ||
|
|
73dcc88da1 | ||
|
|
14bded65dc | ||
|
|
87d1d3fb62 | ||
|
|
e054454109 | ||
|
|
a6142cf975 | ||
|
|
69332e5fa3 | ||
|
|
20201ba3c4 | ||
|
|
658067186a | ||
|
|
ac777b77cf | ||
|
|
5944ae2023 | ||
|
|
2f10961ba8 | ||
|
|
fae97978a3 | ||
|
|
3423415e49 | ||
|
|
1d0bfc2b2a | ||
|
|
bd46cf0f86 | ||
|
|
d4157d9a96 | ||
|
|
6e4ef585d8 | ||
|
|
e05c3b7a76 | ||
|
|
f99904bc1c | ||
|
|
b796d6763f | ||
|
|
c1250abdf8 | ||
|
|
ebe51534a1 | ||
|
|
b8bbee4718 | ||
|
|
8f852b396f | ||
|
|
ae4d089c06 | ||
|
|
5110fbdaf9 | ||
|
|
e6ddb474fc | ||
|
|
0dc71774ce | ||
|
|
b470466e30 | ||
|
|
d1f9311931 | ||
|
|
1c58023df9 | ||
|
|
4e0aa58b7e | ||
|
|
23ee34b35f | ||
|
|
674c9a5220 | ||
|
|
54c86ed43a | ||
|
|
676d75ee75 | ||
|
|
70dc0a12f2 | ||
|
|
d579c5e8aa | ||
|
|
ee91f31313 | ||
|
|
57b3051024 | ||
|
|
ae5cf3cc23 | ||
|
|
68e1b3c46c | ||
|
|
2d68814abc | ||
|
|
a5da5127fa | ||
|
|
b5a4439704 | ||
|
|
9c5616521d | ||
|
|
3fe163416d | ||
|
|
d054f88130 | ||
|
|
b929b4f4b9 | ||
|
|
4c0c83b02d | ||
|
|
d6d45bdc63 | ||
|
|
13a83721b0 | ||
|
|
f0edffbae9 | ||
|
|
8131bee49a | ||
|
|
b5f44ae13f | ||
|
|
0d23f2a7fd | ||
|
|
ac096d84ad | ||
|
|
fcaf0e6dbf | ||
|
|
19e259d90d | ||
|
|
2c9fd1e776 | ||
|
|
63996c4189 | ||
|
|
c7bb7ce4de | ||
|
|
c8eb1b24c3 | ||
|
|
b9f894f1e9 | ||
|
|
7c0d10a4ce |
@@ -3,7 +3,8 @@ name: deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dontlook
|
||||
- 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 }}
|
||||
|
||||
@@ -5,5 +5,3 @@ node_modules
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
app.log
|
||||
gopls.log
|
||||
@@ -1,8 +1,22 @@
|
||||
[](https://github.com/sst/opencode)
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai">
|
||||
<picture>
|
||||
<source srcset="packages/web/src/assets/logo-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="packages/web/src/assets/logo-light.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="packages/web/src/assets/logo-light.svg" alt="opencode logo">
|
||||
</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>
|
||||
|
||||
AI coding agent, built for the terminal.
|
||||
[](https://opencode.ai)
|
||||
|
||||
⚠️ **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 📚
|
||||
---
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -16,119 +30,40 @@ brew install sst/tap/opencode # macOS
|
||||
paru -S opencode-bin # Arch Linux
|
||||
```
|
||||
|
||||
> **Note:** Remove previous versions < 0.1.x first if installed
|
||||
> **Note:** Remove versions older than 0.1.x before installing
|
||||
|
||||
### Providers
|
||||
### Documentation
|
||||
|
||||
The recommended approach is to sign up for claude pro or max and do `opencode auth login` and select Anthropic. It is the most cost-effective way to use this tool.
|
||||
|
||||
Additionally, 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
|
||||
|
||||
### Project Config
|
||||
|
||||
Project configuration is optional. You can place an `opencode.json` file in the root of your repo, and it will be loaded.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "http://opencode.ai/config.json"
|
||||
}
|
||||
```
|
||||
|
||||
#### 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). Use the npm package name as the key in your config.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"@ai-sdk/openai-compatible": {
|
||||
"name": "ollama",
|
||||
"options": {
|
||||
"baseURL": "http://localhost:11434/v1"
|
||||
},
|
||||
"models": {
|
||||
"llama2": {
|
||||
"name": "llama2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
For more info on how to configure opencode [**head over to our docs**](https://opencode.ai/docs).
|
||||
|
||||
### Contributing
|
||||
|
||||
To run opencode locally you need
|
||||
To run opencode locally you need.
|
||||
|
||||
- bun
|
||||
- golang 1.24.x
|
||||
- Bun
|
||||
- Golang 1.24.x
|
||||
|
||||
To run
|
||||
And run.
|
||||
|
||||
```
|
||||
```bash
|
||||
$ bun install
|
||||
$ cd packages/opencode
|
||||
$ bun run src/index.ts
|
||||
$ bun run packages/opencode/src/index.ts
|
||||
```
|
||||
|
||||
### FAQ
|
||||
|
||||
#### How do I use this with OpenRouter
|
||||
#### How do I use this with OpenRouter?
|
||||
|
||||
OpenRouter is not yet in the models.dev database, but you can configure it manually.
|
||||
OpenRouter is not in the Models.dev database yet, but you can configure it manually.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"@openrouter/ai-sdk-provider": {
|
||||
"openrouter": {
|
||||
"npm": "@openrouter/ai-sdk-provider",
|
||||
"name": "OpenRouter",
|
||||
"options": {
|
||||
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
},
|
||||
"options": {},
|
||||
"models": {
|
||||
"anthropic/claude-3.5-sonnet": {
|
||||
"name": "Claude 3.5 Sonnet"
|
||||
@@ -139,15 +74,25 @@ OpenRouter is not yet in the models.dev database, but you can configure it manua
|
||||
}
|
||||
```
|
||||
|
||||
#### How is this different than claude code?
|
||||
And then to configure an api key you can do `opencode auth login` and select "Other -> 'openrouter'"
|
||||
|
||||
It is very similar to claude code in terms of capability - here are the key differences:
|
||||
#### How is this different than Claude Code?
|
||||
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although anthropic is recommended opencode can be used with openai, google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider agnostic is important.
|
||||
- TUI focus - opencode is built by neovim users and the creators of https://terminal.shop - we are going to push the limits of what's possible in the terminal
|
||||
- client/server architecture - this means the tui frontend is just the first of many. For example, opencode can run on your computer and you can drive it remotely from a mobile app
|
||||
- Not coupled to any provider. Although Anthropic is recommended, opencode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider agnostic is important.
|
||||
- A focus on TUI. opencode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
||||
- A client/server architecture. This for example can allow opencode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
|
||||
|
||||
#### Windows Support
|
||||
#### What about Windows support?
|
||||
|
||||
There are some minor problems blocking opencode from working on windows. We will fix them soon - would need to use wsl for now.
|
||||
There are some minor problems blocking opencode from working on windows. We are working on on them now. You'll need to use WSL for now.
|
||||
|
||||
#### What's the other repo?
|
||||
|
||||
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466).
|
||||
|
||||
---
|
||||
|
||||
**Join our community** [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"name": "opencode",
|
||||
"devDependencies": {
|
||||
"prettier": "3.5.3",
|
||||
"sst": "3.17.4",
|
||||
"sst": "3.17.6",
|
||||
},
|
||||
},
|
||||
"packages/function": {
|
||||
@@ -43,8 +43,10 @@
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
"zod-openapi": "4.2.4",
|
||||
"zod-validation-error": "3.5.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/anthropic": "1.2.12",
|
||||
"@tsconfig/bun": "1.0.7",
|
||||
"@types/bun": "latest",
|
||||
"@types/turndown": "5.0.5",
|
||||
@@ -74,10 +76,11 @@
|
||||
"sharp": "0.32.5",
|
||||
"shiki": "3.4.2",
|
||||
"solid-js": "1.9.7",
|
||||
"toolbeam-docs-theme": "0.2.4",
|
||||
"toolbeam-docs-theme": "0.3.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"opencode": "workspace:*",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -86,6 +89,9 @@
|
||||
"sharp",
|
||||
"esbuild",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"ai@4.3.16": "patches/ai@4.3.16.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"zod": "3.24.2",
|
||||
},
|
||||
@@ -96,6 +102,8 @@
|
||||
"zod": "3.24.2",
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
|
||||
@@ -1418,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=="],
|
||||
|
||||
@@ -1488,7 +1496,7 @@
|
||||
|
||||
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
|
||||
|
||||
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.2.4", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-W5mdbcgRpTBDFyEdcU81USs3MFZoXMInpSznc/AFZCwqz8atk4iBNDIlhvihpGHY54Nf5crKmZwJjxVojkHFvA=="],
|
||||
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.3.0", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-qlBkKRp8HVYV7p7jaG9lT2lvQY7c8b9czZ0tnsJUrN2TBTtEyFJymCdkhhpZNC9U4oGZ7lLk0glRJHrndWvVsg=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
@@ -1648,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
-13
@@ -23,25 +23,15 @@ 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"],
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// new sst.cloudflare.StaticSite("Web", {
|
||||
// path: "packages/web",
|
||||
// domain,
|
||||
// environment: {
|
||||
// VITE_API_URL: api.url,
|
||||
// },
|
||||
// build: {
|
||||
// command: "bun run build",
|
||||
// output: "dist",
|
||||
// },
|
||||
// })
|
||||
new sst.cloudflare.x.Astro("Web", {
|
||||
domain,
|
||||
path: "packages/web",
|
||||
|
||||
+1
-14
@@ -1,16 +1,3 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"@ai-sdk/openai-compatible": {
|
||||
"name": "ollama",
|
||||
"options": {
|
||||
"baseURL": "http://localhost:11434/v1"
|
||||
},
|
||||
"models": {
|
||||
"qwen3": {},
|
||||
"deepseek-r1": {},
|
||||
"llama2": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
}
|
||||
|
||||
+7
-3
@@ -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",
|
||||
@@ -37,5 +38,8 @@
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
"sharp"
|
||||
]
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"ai@4.3.16": "patches/ai@4.3.16.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ export class SyncServer extends DurableObject<Env> {
|
||||
this.ctx.acceptWebSocket(server)
|
||||
|
||||
const data = await this.ctx.storage.list()
|
||||
for (const [key, content] of data.entries()) {
|
||||
server.send(JSON.stringify({ key, content }))
|
||||
}
|
||||
Array.from(data.entries())
|
||||
.filter(([key, _]) => key.startsWith("session/"))
|
||||
.map(([key, content]) => server.send(JSON.stringify({ key, content })))
|
||||
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
@@ -71,11 +71,9 @@ export class SyncServer extends DurableObject<Env> {
|
||||
|
||||
public async getData() {
|
||||
const data = await this.ctx.storage.list()
|
||||
const messages = []
|
||||
for (const [key, content] of data.entries()) {
|
||||
messages.push({ key, content })
|
||||
}
|
||||
return messages
|
||||
return Array.from(data.entries())
|
||||
.filter(([key, _]) => key.startsWith("session/"))
|
||||
.map(([key, content]) => ({ key, content }))
|
||||
}
|
||||
|
||||
private async getSecret() {
|
||||
@@ -122,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,13 +2,155 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "JSON schema reference for configuration validation"
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"description": "Theme name to use for the interface"
|
||||
},
|
||||
"keybinds": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"leader": {
|
||||
"type": "string",
|
||||
"description": "Leader key for keybind combinations"
|
||||
},
|
||||
"help": {
|
||||
"type": "string",
|
||||
"description": "Show help dialog"
|
||||
},
|
||||
"editor_open": {
|
||||
"type": "string",
|
||||
"description": "Open external editor"
|
||||
},
|
||||
"session_new": {
|
||||
"type": "string",
|
||||
"description": "Create a new session"
|
||||
},
|
||||
"session_list": {
|
||||
"type": "string",
|
||||
"description": "List all sessions"
|
||||
},
|
||||
"session_share": {
|
||||
"type": "string",
|
||||
"description": "Share current session"
|
||||
},
|
||||
"session_interrupt": {
|
||||
"type": "string",
|
||||
"description": "Interrupt current session"
|
||||
},
|
||||
"session_compact": {
|
||||
"type": "string",
|
||||
"description": "Toggle compact mode for session"
|
||||
},
|
||||
"tool_details": {
|
||||
"type": "string",
|
||||
"description": "Show tool details"
|
||||
},
|
||||
"model_list": {
|
||||
"type": "string",
|
||||
"description": "List available models"
|
||||
},
|
||||
"theme_list": {
|
||||
"type": "string",
|
||||
"description": "List available themes"
|
||||
},
|
||||
"project_init": {
|
||||
"type": "string",
|
||||
"description": "Initialize project configuration"
|
||||
},
|
||||
"input_clear": {
|
||||
"type": "string",
|
||||
"description": "Clear input field"
|
||||
},
|
||||
"input_paste": {
|
||||
"type": "string",
|
||||
"description": "Paste from clipboard"
|
||||
},
|
||||
"input_submit": {
|
||||
"type": "string",
|
||||
"description": "Submit input"
|
||||
},
|
||||
"input_newline": {
|
||||
"type": "string",
|
||||
"description": "Insert newline in input"
|
||||
},
|
||||
"history_previous": {
|
||||
"type": "string",
|
||||
"description": "Navigate to previous history item"
|
||||
},
|
||||
"history_next": {
|
||||
"type": "string",
|
||||
"description": "Navigate to next history item"
|
||||
},
|
||||
"messages_page_up": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages up by one page"
|
||||
},
|
||||
"messages_page_down": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages down by one page"
|
||||
},
|
||||
"messages_half_page_up": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages up by half page"
|
||||
},
|
||||
"messages_half_page_down": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages down by half page"
|
||||
},
|
||||
"messages_previous": {
|
||||
"type": "string",
|
||||
"description": "Navigate to previous message"
|
||||
},
|
||||
"messages_next": {
|
||||
"type": "string",
|
||||
"description": "Navigate to next message"
|
||||
},
|
||||
"messages_first": {
|
||||
"type": "string",
|
||||
"description": "Navigate to first message"
|
||||
},
|
||||
"messages_last": {
|
||||
"type": "string",
|
||||
"description": "Navigate to last message"
|
||||
},
|
||||
"app_exit": {
|
||||
"type": "string",
|
||||
"description": "Exit the application"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Custom keybind configurations"
|
||||
},
|
||||
"autoshare": {
|
||||
"type": "boolean",
|
||||
"description": "Share newly created sessions automatically"
|
||||
},
|
||||
"autoupdate": {
|
||||
"type": "boolean",
|
||||
"description": "Automatically update to the latest version"
|
||||
},
|
||||
"disabled_providers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Disable providers that are loaded automatically"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model to use in the format of provider/model, eg anthropic/claude-2"
|
||||
},
|
||||
"provider": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -21,6 +163,9 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"npm": {
|
||||
"type": "string"
|
||||
},
|
||||
"models": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -47,18 +192,16 @@
|
||||
"output": {
|
||||
"type": "number"
|
||||
},
|
||||
"inputCached": {
|
||||
"cache_read": {
|
||||
"type": "number"
|
||||
},
|
||||
"outputCached": {
|
||||
"cache_write": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input",
|
||||
"output",
|
||||
"inputCached",
|
||||
"outputCached"
|
||||
"output"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -94,7 +237,8 @@
|
||||
"models"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"description": "Custom provider configurations and model overrides"
|
||||
},
|
||||
"mcp": {
|
||||
"type": "object",
|
||||
@@ -105,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": [
|
||||
@@ -131,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": [
|
||||
@@ -144,7 +293,8 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "MCP (Model Context Protocol) server configurations"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
"dev": "bun run ./src/index.ts"
|
||||
},
|
||||
"exports": {
|
||||
"./*": [
|
||||
"./src/*.ts",
|
||||
"./src/*/index.ts"
|
||||
]
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "1.0.7",
|
||||
@@ -20,7 +17,8 @@
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"typescript": "catalog:",
|
||||
"zod-to-json-schema": "3.24.5"
|
||||
"zod-to-json-schema": "3.24.5",
|
||||
"@ai-sdk/anthropic": "1.2.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "0.11.0",
|
||||
@@ -44,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const targets = [
|
||||
["linux", "x64"],
|
||||
["darwin", "x64"],
|
||||
["darwin", "arm64"],
|
||||
["windows", "x64"],
|
||||
// ["windows", "x64"],
|
||||
]
|
||||
|
||||
await $`rm -rf dist`
|
||||
|
||||
@@ -18,6 +18,7 @@ export namespace App {
|
||||
data: z.string(),
|
||||
root: z.string(),
|
||||
cwd: z.string(),
|
||||
state: z.string(),
|
||||
}),
|
||||
time: z.object({
|
||||
initialized: z.number().optional(),
|
||||
@@ -32,7 +33,7 @@ export namespace App {
|
||||
|
||||
const APP_JSON = "app.json"
|
||||
|
||||
async function create(input: { cwd: string; version: string }) {
|
||||
async function create(input: { cwd: string }) {
|
||||
log.info("creating", {
|
||||
cwd: input.cwd,
|
||||
})
|
||||
@@ -49,9 +50,7 @@ export namespace App {
|
||||
const stateFile = Bun.file(path.join(data, APP_JSON))
|
||||
const state = (await stateFile.json().catch(() => ({}))) as {
|
||||
initialized: number
|
||||
version: string
|
||||
}
|
||||
state.version = input.version
|
||||
await stateFile.write(JSON.stringify(state))
|
||||
|
||||
const services = new Map<
|
||||
@@ -70,13 +69,13 @@ export namespace App {
|
||||
git: git !== undefined,
|
||||
path: {
|
||||
config: Global.Path.config,
|
||||
state: Global.Path.state,
|
||||
data,
|
||||
root: git ?? input.cwd,
|
||||
cwd: input.cwd,
|
||||
},
|
||||
}
|
||||
const result = {
|
||||
version: input.version,
|
||||
services,
|
||||
info,
|
||||
}
|
||||
@@ -108,7 +107,7 @@ export namespace App {
|
||||
}
|
||||
|
||||
export async function provide<T>(
|
||||
input: { cwd: string; version: string },
|
||||
input: { cwd: string },
|
||||
cb: (app: Info) => Promise<T>,
|
||||
) {
|
||||
const app = await create(input)
|
||||
@@ -124,12 +123,11 @@ export namespace App {
|
||||
}
|
||||
|
||||
export async function initialize() {
|
||||
const { info, version } = ctx.use()
|
||||
const { info } = ctx.use()
|
||||
info.time.initialized = Date.now()
|
||||
await Bun.write(
|
||||
path.join(info.path.data, APP_JSON),
|
||||
JSON.stringify({
|
||||
version,
|
||||
initialized: Date.now(),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { generatePKCE } from "@openauthjs/openauth/pkce"
|
||||
import fs from "fs/promises"
|
||||
import { Auth } from "./index"
|
||||
|
||||
export namespace AuthAnthropic {
|
||||
@@ -49,6 +48,7 @@ export namespace AuthAnthropic {
|
||||
await Auth.set("anthropic", {
|
||||
type: "oauth",
|
||||
refresh: json.refresh_token as string,
|
||||
access: json.access_token as string,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
})
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export namespace AuthAnthropic {
|
||||
export async function access() {
|
||||
const info = await Auth.get("anthropic")
|
||||
if (!info || info.type !== "oauth") return
|
||||
if (info.access && info.expires > Date.now()) return info.access
|
||||
const response = await fetch(
|
||||
"https://console.anthropic.com/v1/oauth/token",
|
||||
{
|
||||
@@ -75,6 +76,7 @@ export namespace AuthAnthropic {
|
||||
await Auth.set("anthropic", {
|
||||
type: "oauth",
|
||||
refresh: json.refresh_token as string,
|
||||
access: json.access_token as string,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
})
|
||||
return json.access_token as string
|
||||
|
||||
@@ -7,6 +7,7 @@ export namespace Auth {
|
||||
export const Oauth = z.object({
|
||||
type: z.literal("oauth"),
|
||||
refresh: z.string(),
|
||||
access: z.string(),
|
||||
expires: z.number(),
|
||||
})
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export namespace BunProc {
|
||||
version: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export async function install(pkg: string, version = "latest") {
|
||||
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
||||
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
|
||||
|
||||
@@ -5,18 +5,18 @@ import * as prompts from "@clack/prompts"
|
||||
import open from "open"
|
||||
import { UI } from "../ui"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
import { map, pipe, sort, sortBy, values } from "remeda"
|
||||
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(args) {},
|
||||
async handler() { },
|
||||
})
|
||||
|
||||
export const AuthListCommand = cmd({
|
||||
@@ -78,9 +78,16 @@ export const AuthLoginCommand = cmd({
|
||||
|
||||
if (provider === "other") {
|
||||
provider = await prompts.text({
|
||||
message: "Enter provider - must match @ai-sdk/<provider>",
|
||||
message: "Enter provider id",
|
||||
validate: (x) =>
|
||||
x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only",
|
||||
})
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
provider = provider.replace(/^@ai-sdk\//, "")
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
prompts.log.warn(
|
||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "amazon-bedrock") {
|
||||
@@ -115,7 +122,9 @@ export const AuthLoginCommand = cmd({
|
||||
try {
|
||||
await open(url)
|
||||
} catch (e) {
|
||||
prompts.log.error("Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:")
|
||||
prompts.log.error(
|
||||
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
|
||||
)
|
||||
}
|
||||
prompts.log.info(url)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Server } from "../../server/server"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import type { CommandModule } from "yargs"
|
||||
import { Config } from "../../config/config"
|
||||
|
||||
export const GenerateCommand = {
|
||||
command: "generate",
|
||||
|
||||
@@ -6,68 +6,102 @@ import { Session } from "../../session"
|
||||
import { Share } from "../../share/share"
|
||||
import { Message } from "../../session/message"
|
||||
import { UI } from "../ui"
|
||||
import { VERSION } from "../version"
|
||||
|
||||
const COLOR = [
|
||||
UI.Style.TEXT_SUCCESS_BOLD,
|
||||
UI.Style.TEXT_INFO_BOLD,
|
||||
UI.Style.TEXT_HIGHLIGHT_BOLD,
|
||||
UI.Style.TEXT_WARNING_BOLD,
|
||||
]
|
||||
import { cmd } from "./cmd"
|
||||
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 = {
|
||||
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",
|
||||
})
|
||||
.option("model", {
|
||||
type: "string",
|
||||
alias: ["m"],
|
||||
describe: "Model to use in the format of provider/model",
|
||||
})
|
||||
},
|
||||
handler: async (args: {
|
||||
message: string[]
|
||||
session?: string
|
||||
printLogs?: boolean
|
||||
}) => {
|
||||
handler: async (args) => {
|
||||
const message = args.message.join(" ")
|
||||
await App.provide(
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
version: VERSION,
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
|
||||
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())
|
||||
UI.empty()
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
||||
UI.empty()
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD +
|
||||
"~ https://dev.opencode.ai/s/" +
|
||||
|
||||
const cfg = await Config.get()
|
||||
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
|
||||
await Session.share(session.id)
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD +
|
||||
"~ https://opencode.ai/s/" +
|
||||
session.id.slice(-8),
|
||||
)
|
||||
}
|
||||
UI.empty()
|
||||
|
||||
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}`,
|
||||
)
|
||||
UI.empty()
|
||||
|
||||
@@ -75,8 +109,8 @@ export const RunCommand = {
|
||||
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,
|
||||
)
|
||||
@@ -113,8 +147,6 @@ export const RunCommand = {
|
||||
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
||||
}
|
||||
})
|
||||
|
||||
const { providerID, modelID } = await Provider.defaultModel()
|
||||
await Session.chat({
|
||||
sessionID: session.id,
|
||||
providerID,
|
||||
@@ -130,4 +162,4 @@ export const RunCommand = {
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { App } from "../../app/app"
|
||||
import { LSP } from "../../lsp"
|
||||
import { VERSION } from "../version"
|
||||
import { cmd } from "./cmd"
|
||||
|
||||
export const ScrapCommand = cmd({
|
||||
@@ -8,9 +7,12 @@ export const ScrapCommand = cmd({
|
||||
builder: (yargs) =>
|
||||
yargs.positional("file", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await App.provide({ cwd: process.cwd(), version: VERSION }, async (app) => {
|
||||
await LSP.touchFile(args.file, true)
|
||||
console.log(await LSP.diagnostics())
|
||||
})
|
||||
await App.provide(
|
||||
{ cwd: process.cwd() },
|
||||
async () => {
|
||||
await LSP.touchFile(args.file, true)
|
||||
console.log(await LSP.diagnostics())
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,120 +1,14 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { UI } from "../ui"
|
||||
import { VERSION } from "../version"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { Global } from "../../global"
|
||||
|
||||
const API = "https://api.github.com/repos/sst/opencode"
|
||||
|
||||
interface Release {
|
||||
tag_name: string
|
||||
name: string
|
||||
assets: Array<{
|
||||
name: string
|
||||
browser_download_url: string
|
||||
}>
|
||||
}
|
||||
|
||||
function asset(): string {
|
||||
const platform = os.platform()
|
||||
const arch = os.arch()
|
||||
|
||||
if (platform === "darwin") {
|
||||
return arch === "arm64"
|
||||
? "opencode-darwin-arm64.zip"
|
||||
: "opencode-darwin-x64.zip"
|
||||
}
|
||||
if (platform === "linux") {
|
||||
return arch === "arm64"
|
||||
? "opencode-linux-arm64.zip"
|
||||
: "opencode-linux-x64.zip"
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return "opencode-windows-x64.zip"
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported platform: ${platform}-${arch}`)
|
||||
}
|
||||
|
||||
function compare(current: string, latest: string): number {
|
||||
const a = current.replace(/^v/, "")
|
||||
const b = latest.replace(/^v/, "")
|
||||
|
||||
const aParts = a.split(".").map(Number)
|
||||
const bParts = b.split(".").map(Number)
|
||||
|
||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||
const aPart = aParts[i] || 0
|
||||
const bPart = bParts[i] || 0
|
||||
|
||||
if (aPart < bPart) return -1
|
||||
if (aPart > bPart) return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
async function latest(): Promise<Release> {
|
||||
const response = await fetch(`${API}/releases/latest`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch latest release: ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function specific(version: string): Promise<Release> {
|
||||
const tag = version.startsWith("v") ? version : `v${version}`
|
||||
const response = await fetch(`${API}/releases/tags/${tag}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch release ${tag}: ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function download(url: string): Promise<string> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer()
|
||||
const temp = path.join(Global.Path.cache, `opencode-update-${Date.now()}.zip`)
|
||||
|
||||
await Bun.write(temp, buffer)
|
||||
|
||||
const extractDir = path.join(
|
||||
Global.Path.cache,
|
||||
`opencode-extract-${Date.now()}`,
|
||||
)
|
||||
await fs.mkdir(extractDir, { recursive: true })
|
||||
|
||||
const proc = Bun.spawn(["unzip", "-o", temp, "-d", extractDir], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const result = await proc.exited
|
||||
if (result !== 0) {
|
||||
throw new Error("Failed to extract update")
|
||||
}
|
||||
|
||||
await fs.unlink(temp)
|
||||
|
||||
const binary = path.join(extractDir, "opencode")
|
||||
await fs.chmod(binary, 0o755)
|
||||
|
||||
return binary
|
||||
}
|
||||
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",
|
||||
})
|
||||
},
|
||||
@@ -123,70 +17,28 @@ export const UpgradeCommand = {
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
prompts.intro("Upgrade")
|
||||
|
||||
if (!process.execPath.includes(path.join(".opencode", "bin")) && false) {
|
||||
const method = await Installation.method()
|
||||
if (method === "unknown") {
|
||||
prompts.log.error(
|
||||
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
|
||||
)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
const release = args.target
|
||||
? await specific(args.target).catch(() => {})
|
||||
: await latest().catch(() => {})
|
||||
if (!release) {
|
||||
prompts.log.error("Failed to fetch release information")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
const target = release.tag_name
|
||||
|
||||
if (VERSION !== "dev" && compare(VERSION, target) >= 0) {
|
||||
prompts.log.success(`Already up to date`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
prompts.log.info(`From ${VERSION} → ${target}`)
|
||||
|
||||
const name = asset()
|
||||
const found = release.assets.find((a) => a.name === name)
|
||||
|
||||
if (!found) {
|
||||
prompts.log.error(`No binary found for platform: ${name}`)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
const target = args.target ?? (await Installation.latest())
|
||||
prompts.log.info(`From ${Installation.VERSION} → ${target}`)
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Downloading update...")
|
||||
|
||||
const downloadPath = await download(found.browser_download_url).catch(
|
||||
() => {},
|
||||
)
|
||||
if (!downloadPath) {
|
||||
spinner.stop("Download failed")
|
||||
prompts.log.error("Download failed")
|
||||
spinner.start("Upgrading...")
|
||||
const err = await Installation.upgrade(method, target).catch((err) => err)
|
||||
if (err) {
|
||||
spinner.stop("Upgrade failed")
|
||||
if (err instanceof Installation.UpgradeFailedError)
|
||||
prompts.log.error(err.data.stderr)
|
||||
else if (err instanceof Error) prompts.log.error(err.message)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
spinner.stop("Download complete")
|
||||
|
||||
const renamed = await fs
|
||||
.rename(downloadPath, process.execPath)
|
||||
.catch(() => {})
|
||||
|
||||
if (renamed === undefined) {
|
||||
prompts.log.error("Install failed")
|
||||
await fs.unlink(downloadPath).catch(() => {})
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
prompts.log.success(`Successfully upgraded to ${target}`)
|
||||
spinner.stop("Upgrade complete")
|
||||
prompts.outro("Done")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
declare global {
|
||||
const OPENCODE_VERSION: string
|
||||
}
|
||||
|
||||
export const VERSION =
|
||||
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
|
||||
@@ -1,56 +1,159 @@
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
import { App } from "../app/app"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
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" })
|
||||
|
||||
export const state = App.state("config", async (app) => {
|
||||
let result: Info = {}
|
||||
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 = 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)
|
||||
return result
|
||||
})
|
||||
|
||||
export const McpLocal = z.object({
|
||||
type: z.literal("local"),
|
||||
command: z.string().array(),
|
||||
environment: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
export const McpLocal = z
|
||||
.object({
|
||||
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(),
|
||||
})
|
||||
export const McpRemote = z
|
||||
.object({
|
||||
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",
|
||||
})
|
||||
|
||||
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
||||
export type Mcp = z.infer<typeof Mcp>
|
||||
|
||||
export const Keybinds = z
|
||||
.object({
|
||||
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(),
|
||||
$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()
|
||||
.describe("Share newly created sessions automatically"),
|
||||
autoupdate: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Automatically update to the latest version"),
|
||||
disabled_providers: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Disable providers that are loaded automatically"),
|
||||
model: z
|
||||
.string()
|
||||
.describe(
|
||||
"Model to use in the format of provider/model, eg anthropic/claude-2",
|
||||
)
|
||||
.optional(),
|
||||
provider: z
|
||||
.record(
|
||||
ModelsDev.Provider.partial().extend({
|
||||
@@ -58,13 +161,72 @@ 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",
|
||||
})
|
||||
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
export const global = lazy(async () => {
|
||||
let result = await load(path.join(Global.Path.config, "config.json"))
|
||||
|
||||
await import(path.join(Global.Path.config, "config"), {
|
||||
with: {
|
||||
type: "toml",
|
||||
},
|
||||
})
|
||||
.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"),
|
||||
JSON.stringify(result, null, 2),
|
||||
)
|
||||
await fs.unlink(path.join(Global.Path.config, "config"))
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
import { App } from "../app/app"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export namespace File {
|
||||
const glob = new Bun.Glob("**/*")
|
||||
export async function search(path: string, query: string) {
|
||||
for await (const entry of glob.scan({
|
||||
cwd: path,
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from "fs/promises"
|
||||
import { xdgData, xdgCache, xdgConfig } from "xdg-basedir"
|
||||
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
|
||||
import path from "path"
|
||||
|
||||
const app = "opencode"
|
||||
@@ -7,18 +7,23 @@ const app = "opencode"
|
||||
const data = path.join(xdgData!, app)
|
||||
const cache = path.join(xdgCache!, app)
|
||||
const config = path.join(xdgConfig!, app)
|
||||
|
||||
await Promise.all([
|
||||
fs.mkdir(data, { recursive: true }),
|
||||
fs.mkdir(config, { recursive: true }),
|
||||
fs.mkdir(cache, { recursive: true }),
|
||||
])
|
||||
const state = path.join(xdgState!, app)
|
||||
|
||||
export namespace Global {
|
||||
export const Path = {
|
||||
data,
|
||||
bin: path.join(data, "bin"),
|
||||
providers: path.join(config, "providers"),
|
||||
cache,
|
||||
config,
|
||||
state,
|
||||
} as const
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fs.mkdir(Global.Path.data, { recursive: true }),
|
||||
fs.mkdir(Global.Path.config, { recursive: true }),
|
||||
fs.mkdir(Global.Path.cache, { recursive: true }),
|
||||
fs.mkdir(Global.Path.providers, { recursive: true }),
|
||||
fs.mkdir(Global.Path.state, { recursive: true }),
|
||||
])
|
||||
|
||||
@@ -9,17 +9,21 @@ import yargs from "yargs"
|
||||
import { hideBin } from "yargs/helpers"
|
||||
import { RunCommand } from "./cli/cmd/run"
|
||||
import { GenerateCommand } from "./cli/cmd/generate"
|
||||
import { VERSION } from "./cli/version"
|
||||
import { ScrapCommand } from "./cli/cmd/scrap"
|
||||
import { Log } from "./util/log"
|
||||
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
|
||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||
import { Provider } from "./provider/provider"
|
||||
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")
|
||||
.version(VERSION)
|
||||
.version(Installation.VERSION)
|
||||
.option("print-logs", {
|
||||
describe: "Print logs to stderr",
|
||||
type: "boolean",
|
||||
@@ -27,14 +31,14 @@ const cli = yargs(hideBin(process.argv))
|
||||
.middleware(async () => {
|
||||
await Log.init({ print: process.argv.includes("--print-logs") })
|
||||
Log.Default.info("opencode", {
|
||||
version: VERSION,
|
||||
version: Installation.VERSION,
|
||||
args: process.argv.slice(2),
|
||||
})
|
||||
})
|
||||
.usage("\n" + UI.logo())
|
||||
.command({
|
||||
command: "$0 [project]",
|
||||
describe: "Start opencode TUI",
|
||||
describe: "start opencode TUI",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("project", {
|
||||
type: "string",
|
||||
@@ -44,52 +48,65 @@ const cli = yargs(hideBin(process.argv))
|
||||
while (true) {
|
||||
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
||||
process.chdir(cwd)
|
||||
const result = await App.provide(
|
||||
{ cwd, version: VERSION },
|
||||
async (app) => {
|
||||
App.info().path.config
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return "needs_provider"
|
||||
const result = await App.provide({ cwd }, async (app) => {
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return "needs_provider"
|
||||
}
|
||||
|
||||
await Share.init()
|
||||
const server = Server.listen()
|
||||
|
||||
let cmd = ["go", "run", "./main.go"]
|
||||
let cwd = new URL("../../tui/cmd/opencode", import.meta.url).pathname
|
||||
if (Bun.embeddedFiles.length > 0) {
|
||||
const blob = Bun.embeddedFiles[0] as File
|
||||
const binary = path.join(Global.Path.cache, "tui", blob.name)
|
||||
const file = Bun.file(binary)
|
||||
if (!(await file.exists())) {
|
||||
await Bun.write(file, blob, { mode: 0o755 })
|
||||
await fs.chmod(binary, 0o755)
|
||||
}
|
||||
cwd = process.cwd()
|
||||
cmd = [binary]
|
||||
}
|
||||
const proc = Bun.spawn({
|
||||
cmd: [...cmd, ...process.argv.slice(2)],
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
OPENCODE_APP_INFO: JSON.stringify(app),
|
||||
},
|
||||
onExit: () => {
|
||||
server.stop()
|
||||
},
|
||||
})
|
||||
|
||||
await Share.init()
|
||||
const server = Server.listen()
|
||||
;(async () => {
|
||||
if (Installation.VERSION === "dev") return
|
||||
if (Installation.isSnapshot()) return
|
||||
const config = await Config.global()
|
||||
if (config.autoupdate === false) return
|
||||
const latest = await Installation.latest()
|
||||
if (Installation.VERSION === latest) return
|
||||
const method = await Installation.method()
|
||||
if (method === "unknown") return
|
||||
await Installation.upgrade(method, latest)
|
||||
.then(() => {
|
||||
Bus.publish(Installation.Event.Updated, { version: latest })
|
||||
})
|
||||
.catch(() => {})
|
||||
})()
|
||||
|
||||
let cmd = ["go", "run", "./main.go"]
|
||||
let cwd = new URL("../../tui/cmd/opencode", import.meta.url)
|
||||
.pathname
|
||||
if (Bun.embeddedFiles.length > 0) {
|
||||
const blob = Bun.embeddedFiles[0] as File
|
||||
const binary = path.join(Global.Path.cache, "tui", blob.name)
|
||||
const file = Bun.file(binary)
|
||||
if (!(await file.exists())) {
|
||||
await Bun.write(file, blob, { mode: 0o755 })
|
||||
await fs.chmod(binary, 0o755)
|
||||
}
|
||||
cwd = process.cwd()
|
||||
cmd = [binary]
|
||||
}
|
||||
const proc = Bun.spawn({
|
||||
cmd: [...cmd, ...process.argv.slice(2)],
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
},
|
||||
onExit: () => {
|
||||
server.stop()
|
||||
},
|
||||
})
|
||||
await proc.exited
|
||||
await server.stop()
|
||||
await proc.exited
|
||||
server.stop()
|
||||
|
||||
return "done"
|
||||
},
|
||||
)
|
||||
return "done"
|
||||
})
|
||||
if (result === "done") break
|
||||
if (result === "needs_provider") {
|
||||
UI.empty()
|
||||
@@ -105,21 +122,38 @@ const cli = yargs(hideBin(process.argv))
|
||||
.command(ScrapCommand)
|
||||
.command(AuthCommand)
|
||||
.command(UpgradeCommand)
|
||||
.fail((msg, err) => {
|
||||
.fail((msg) => {
|
||||
if (
|
||||
msg.startsWith("Unknown argument") ||
|
||||
msg.startsWith("Not enough non-option arguments")
|
||||
) {
|
||||
cli.showHelp("log")
|
||||
}
|
||||
Log.Default.error(msg, {
|
||||
err,
|
||||
})
|
||||
})
|
||||
.strict()
|
||||
|
||||
try {
|
||||
await cli.parse()
|
||||
} catch (e) {
|
||||
Log.Default.error(e)
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import path from "path"
|
||||
import { $ } from "bun"
|
||||
import { z } from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Bus } from "../bus"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_VERSION: string
|
||||
}
|
||||
|
||||
export namespace Installation {
|
||||
export type Method = Awaited<ReturnType<typeof method>>
|
||||
|
||||
export const Event = {
|
||||
Updated: Bus.event(
|
||||
"installation.updated",
|
||||
z.object({
|
||||
version: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
version: z.string(),
|
||||
latest: z.string(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "InstallationInfo",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export async function info() {
|
||||
return {
|
||||
version: VERSION,
|
||||
latest: await latest(),
|
||||
}
|
||||
}
|
||||
|
||||
export function isSnapshot() {
|
||||
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()
|
||||
|
||||
const checks = [
|
||||
{
|
||||
name: "npm" as const,
|
||||
command: () => $`npm list -g --depth=0`.throws(false).text(),
|
||||
},
|
||||
{
|
||||
name: "yarn" as const,
|
||||
command: () => $`yarn global list`.throws(false).text(),
|
||||
},
|
||||
{
|
||||
name: "pnpm" as const,
|
||||
command: () => $`pnpm list -g --depth=0`.throws(false).text(),
|
||||
},
|
||||
{
|
||||
name: "bun" as const,
|
||||
command: () => $`bun pm ls -g`.throws(false).text(),
|
||||
},
|
||||
]
|
||||
|
||||
checks.sort((a, b) => {
|
||||
const aMatches = exec.includes(a.name)
|
||||
const bMatches = exec.includes(b.name)
|
||||
if (aMatches && !bMatches) return -1
|
||||
if (!aMatches && bMatches) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
for (const check of checks) {
|
||||
const output = await check.command()
|
||||
if (output.includes("opencode-ai")) {
|
||||
return check.name
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
export const UpgradeFailedError = NamedError.create(
|
||||
"UpgradeFailedError",
|
||||
z.object({
|
||||
stderr: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export async function upgrade(method: Method, target: string) {
|
||||
const cmd = (() => {
|
||||
switch (method) {
|
||||
case "curl":
|
||||
return $`curl -fsSL https://opencode.ai/install | bash`
|
||||
case "npm":
|
||||
return $`npm install -g opencode-ai@${target}`
|
||||
case "pnpm":
|
||||
return $`pnpm install -g opencode-ai@${target}`
|
||||
case "bun":
|
||||
return $`bun install -g opencode-ai@${target}`
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`)
|
||||
}
|
||||
})()
|
||||
const result = await cmd.quiet().throws(false)
|
||||
if (result.exitCode !== 0)
|
||||
throw new UpgradeFailedError({
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
})
|
||||
}
|
||||
|
||||
export const VERSION =
|
||||
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
|
||||
|
||||
export async function latest() {
|
||||
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.tag_name.slice(1) as string)
|
||||
}
|
||||
}
|
||||
@@ -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 { Global } from "../global"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
@@ -17,8 +16,8 @@ export namespace ModelsDev {
|
||||
cost: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
inputCached: z.number(),
|
||||
outputCached: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
}),
|
||||
limit: z.object({
|
||||
context: z.number(),
|
||||
@@ -33,9 +32,11 @@ export namespace ModelsDev {
|
||||
|
||||
export const Provider = z
|
||||
.object({
|
||||
api: z.string().optional(),
|
||||
name: z.string(),
|
||||
env: z.array(z.string()),
|
||||
id: z.string(),
|
||||
npm: z.string().optional(),
|
||||
models: z.record(Model),
|
||||
})
|
||||
.openapi({
|
||||
@@ -63,30 +64,4 @@ export namespace ModelsDev {
|
||||
throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
|
||||
await Bun.write(file, result)
|
||||
}
|
||||
|
||||
const aisdk = lazy(async () => {
|
||||
log.info("fetching ai-sdk")
|
||||
const response = await fetch(
|
||||
"https://registry.npmjs.org/-/v1/search?text=scope:@ai-sdk",
|
||||
)
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Failed to fetch ai-sdk information: ${response.statusText}`,
|
||||
)
|
||||
const result = await response.json()
|
||||
log.info("found ai-sdk", result.objects.length)
|
||||
return result.objects
|
||||
.filter((obj: any) => obj.package.name.startsWith("@ai-sdk/"))
|
||||
.reduce((acc: any, obj: any) => {
|
||||
acc[obj.package.name] = obj
|
||||
return acc
|
||||
}, {})
|
||||
})
|
||||
|
||||
export async function pkg(providerID: string): Promise<[string, string]> {
|
||||
const packages = await aisdk()
|
||||
const match = packages[`@ai-sdk/${providerID}`]
|
||||
if (match) return [match.package.name, "latest"]
|
||||
return [providerID, "latest"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,16 +40,23 @@ export namespace Provider {
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
input: 0,
|
||||
inputCached: 0,
|
||||
output: 0,
|
||||
outputCached: 0,
|
||||
}
|
||||
}
|
||||
return {
|
||||
apiKey: "",
|
||||
headers: {
|
||||
authorization: `Bearer ${access}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
async fetch(input: any, init: any) {
|
||||
const access = await AuthAnthropic.access()
|
||||
const headers = {
|
||||
...init.headers,
|
||||
authorization: `Bearer ${access}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
}
|
||||
delete headers["x-api-key"]
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -91,9 +98,12 @@ export namespace Provider {
|
||||
) {
|
||||
const provider = providers[id]
|
||||
if (!provider) {
|
||||
const info = database[id]
|
||||
if (!info) return
|
||||
if (info.api) options["baseURL"] = info.api
|
||||
providers[id] = {
|
||||
source,
|
||||
info: database[id],
|
||||
info,
|
||||
options,
|
||||
}
|
||||
return
|
||||
@@ -102,12 +112,13 @@ export namespace Provider {
|
||||
provider.source = source
|
||||
}
|
||||
|
||||
for (const [providerID, provider] of Object.entries(
|
||||
config.provider ?? {},
|
||||
)) {
|
||||
const configProviders = Object.entries(config.provider ?? {})
|
||||
|
||||
for (const [providerID, provider] of configProviders) {
|
||||
const existing = database[providerID]
|
||||
const parsed: ModelsDev.Provider = {
|
||||
id: providerID,
|
||||
npm: provider.npm ?? existing?.npm,
|
||||
name: provider.name ?? existing?.name ?? providerID,
|
||||
env: provider.env ?? existing?.env ?? [],
|
||||
models: existing?.models ?? {},
|
||||
@@ -139,8 +150,12 @@ export namespace Provider {
|
||||
database[providerID] = parsed
|
||||
}
|
||||
|
||||
const disabled = await Config.get().then(
|
||||
(cfg) => new Set(cfg.disabled_providers ?? []),
|
||||
)
|
||||
// load env
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
if (disabled.has(providerID)) continue
|
||||
if (provider.env.some((item) => process.env[item])) {
|
||||
mergeProvider(providerID, {}, "env")
|
||||
}
|
||||
@@ -148,6 +163,7 @@ export namespace Provider {
|
||||
|
||||
// load apikeys
|
||||
for (const [providerID, provider] of Object.entries(await Auth.all())) {
|
||||
if (disabled.has(providerID)) continue
|
||||
if (provider.type === "api") {
|
||||
mergeProvider(providerID, { apiKey: provider.key }, "api")
|
||||
}
|
||||
@@ -155,18 +171,21 @@ export namespace Provider {
|
||||
|
||||
// load custom
|
||||
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||
if (disabled.has(providerID)) continue
|
||||
const result = await fn(database[providerID])
|
||||
if (result) mergeProvider(providerID, result, "custom")
|
||||
}
|
||||
|
||||
// load config
|
||||
for (const [providerID, provider] of Object.entries(
|
||||
config.provider ?? {},
|
||||
)) {
|
||||
for (const [providerID, provider] of configProviders) {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -181,22 +200,22 @@ export namespace Provider {
|
||||
return state().then((state) => state.providers)
|
||||
}
|
||||
|
||||
async function getSDK(providerID: string) {
|
||||
async function getSDK(provider: ModelsDev.Provider) {
|
||||
return (async () => {
|
||||
using _ = log.time("getSDK", {
|
||||
providerID,
|
||||
providerID: provider.id,
|
||||
})
|
||||
const s = await state()
|
||||
const existing = s.sdk.get(providerID)
|
||||
const existing = s.sdk.get(provider.id)
|
||||
if (existing) return existing
|
||||
const [pkg, version] = await ModelsDev.pkg(providerID)
|
||||
const mod = await import(await BunProc.install(pkg, version))
|
||||
const pkg = provider.npm ?? provider.id
|
||||
const mod = await import(await BunProc.install(pkg, "latest"))
|
||||
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
||||
const loaded = fn(s.providers[providerID]?.options)
|
||||
s.sdk.set(providerID, loaded)
|
||||
const loaded = fn(s.providers[provider.id]?.options)
|
||||
s.sdk.set(provider.id, loaded)
|
||||
return loaded as SDK
|
||||
})().catch((e) => {
|
||||
throw new InitError({ providerID: providerID }, { cause: e })
|
||||
throw new InitError({ providerID: provider.id }, { cause: e })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -214,8 +233,7 @@ export namespace Provider {
|
||||
if (!provider) throw new ModelNotFoundError({ providerID, modelID })
|
||||
const info = provider.info.models[modelID]
|
||||
if (!info) throw new ModelNotFoundError({ providerID, modelID })
|
||||
|
||||
const sdk = await getSDK(providerID)
|
||||
const sdk = await getSDK(provider.info)
|
||||
|
||||
try {
|
||||
const language =
|
||||
@@ -257,7 +275,15 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
export async function defaultModel() {
|
||||
const [provider] = await list().then((val) => Object.values(val))
|
||||
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 || 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")
|
||||
@@ -267,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,
|
||||
@@ -285,11 +319,20 @@ export namespace Provider {
|
||||
TaskTool,
|
||||
TodoReadTool,
|
||||
]
|
||||
|
||||
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
|
||||
anthropic: TOOLS.filter((t) => t.id !== "opencode.patch"),
|
||||
openai: TOOLS,
|
||||
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,
|
||||
}
|
||||
|
||||
export async function tools(providerID: string) {
|
||||
/*
|
||||
const cfg = await Config.get()
|
||||
@@ -301,6 +344,38 @@ export namespace Provider {
|
||||
return TOOL_MAPPING[providerID] ?? TOOLS
|
||||
}
|
||||
|
||||
function optionalToNullable(schema: z.ZodTypeAny): z.ZodTypeAny {
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const shape = schema.shape
|
||||
const newShape: Record<string, z.ZodTypeAny> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(shape)) {
|
||||
const zodValue = value as z.ZodTypeAny
|
||||
if (zodValue instanceof z.ZodOptional) {
|
||||
newShape[key] = zodValue.unwrap().nullable()
|
||||
} else {
|
||||
newShape[key] = optionalToNullable(zodValue)
|
||||
}
|
||||
}
|
||||
|
||||
return z.object(newShape)
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodArray) {
|
||||
return z.array(optionalToNullable(schema.element))
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodUnion) {
|
||||
return z.union(
|
||||
schema.options.map((option: z.ZodTypeAny) =>
|
||||
optionalToNullable(option),
|
||||
) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]],
|
||||
)
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
export const ModelNotFoundError = NamedError.create(
|
||||
"ProviderModelNotFoundError",
|
||||
z.object({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,10 @@ import { App } from "../app/app"
|
||||
import { Global } from "../global"
|
||||
import { mapValues } from "remeda"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Fzf } from "../external/fzf"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
import { Ripgrep } from "../external/ripgrep"
|
||||
import { Installation } from "../installation"
|
||||
import { Config } from "../config/config"
|
||||
|
||||
const ERRORS = {
|
||||
400: {
|
||||
@@ -140,6 +141,25 @@ export namespace Server {
|
||||
return c.json(App.info())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/config_get",
|
||||
describeRoute({
|
||||
description: "Get config info",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get config info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await Config.get())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/app_initialize",
|
||||
describeRoute({
|
||||
@@ -466,6 +486,25 @@ export namespace Server {
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"installation_info",
|
||||
describeRoute({
|
||||
description: "Get installation info",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get installation info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Installation.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(Installation.info())
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ import type { Tool } from "../tool/tool"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Flag } from "../flag/flag"
|
||||
import type { ModelsDev } from "../provider/models"
|
||||
import { Installation } from "../installation"
|
||||
import { Config } from "../config/config"
|
||||
import { ProviderTransform } from "../provider/transform"
|
||||
|
||||
export namespace Session {
|
||||
const log = Log.create({ service: "session" })
|
||||
@@ -40,11 +43,11 @@ export namespace Session {
|
||||
parentID: Identifier.schema("session").optional(),
|
||||
share: z
|
||||
.object({
|
||||
secret: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
title: z.string(),
|
||||
version: z.string(),
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
updated: z.number(),
|
||||
@@ -55,6 +58,12 @@ export namespace Session {
|
||||
})
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
export const ShareInfo = z.object({
|
||||
secret: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
export type ShareInfo = z.output<typeof ShareInfo>
|
||||
|
||||
export const Event = {
|
||||
Updated: Bus.event(
|
||||
"session.updated",
|
||||
@@ -70,19 +79,30 @@ export namespace Session {
|
||||
),
|
||||
}
|
||||
|
||||
const state = App.state("session", () => {
|
||||
const sessions = new Map<string, Info>()
|
||||
const messages = new Map<string, Message.Info[]>()
|
||||
const state = App.state(
|
||||
"session",
|
||||
() => {
|
||||
const sessions = new Map<string, Info>()
|
||||
const messages = new Map<string, Message.Info[]>()
|
||||
const pending = new Map<string, AbortController>()
|
||||
|
||||
return {
|
||||
sessions,
|
||||
messages,
|
||||
}
|
||||
})
|
||||
return {
|
||||
sessions,
|
||||
messages,
|
||||
pending,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
for (const [_, controller] of state.pending) {
|
||||
controller.abort()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export async function create(parentID?: string) {
|
||||
const result: Info = {
|
||||
id: Identifier.descending("session"),
|
||||
version: Installation.VERSION,
|
||||
parentID,
|
||||
title:
|
||||
(parentID ? "Child session - " : "New Session - ") +
|
||||
@@ -95,7 +115,8 @@ export namespace Session {
|
||||
log.info("created", result)
|
||||
state().sessions.set(result.id, result)
|
||||
await Storage.writeJSON("session/info/" + result.id, result)
|
||||
if (!result.parentID && Flag.OPENCODE_AUTO_SHARE)
|
||||
const cfg = await Config.get()
|
||||
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.autoshare))
|
||||
share(result.id).then((share) => {
|
||||
update(result.id, (draft) => {
|
||||
draft.share = share
|
||||
@@ -117,13 +138,21 @@ export namespace Session {
|
||||
return read as Info
|
||||
}
|
||||
|
||||
export async function getShare(id: string) {
|
||||
return Storage.readJSON<ShareInfo>("session/share/" + id)
|
||||
}
|
||||
|
||||
export async function share(id: string) {
|
||||
const session = await get(id)
|
||||
if (session.share) return session.share
|
||||
const share = await Share.create(id)
|
||||
await update(id, (draft) => {
|
||||
draft.share = share
|
||||
draft.share = {
|
||||
url: share.url,
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
@@ -169,10 +198,10 @@ export namespace Session {
|
||||
}
|
||||
|
||||
export function abort(sessionID: string) {
|
||||
const controller = pending.get(sessionID)
|
||||
const controller = state().pending.get(sessionID)
|
||||
if (!controller) return false
|
||||
controller.abort()
|
||||
pending.delete(sessionID)
|
||||
state().pending.delete(sessionID)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -208,8 +237,9 @@ export namespace Session {
|
||||
previous.metadata.assistant.tokens.cache.write +
|
||||
previous.metadata.assistant.tokens.output
|
||||
if (
|
||||
model.info.limit.context &&
|
||||
tokens >
|
||||
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
|
||||
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
|
||||
) {
|
||||
await summarize({
|
||||
sessionID: input.sessionID,
|
||||
@@ -237,15 +267,6 @@ export namespace Session {
|
||||
(x): CoreMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
providerOptions: {
|
||||
...(input.providerID === "anthropic"
|
||||
? {
|
||||
anthropic: {
|
||||
cacheControl: { type: "ephemeral" },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
...convertToCoreMessages([
|
||||
@@ -255,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) => {
|
||||
@@ -328,6 +351,16 @@ export namespace Session {
|
||||
sessionID: input.sessionID,
|
||||
abort: abort.signal,
|
||||
messageID: next.id,
|
||||
metadata: async (val) => {
|
||||
next.metadata.tool[opts.toolCallId] = {
|
||||
...val,
|
||||
time: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
}
|
||||
await updateMessage(next)
|
||||
},
|
||||
})
|
||||
next.metadata!.tool![opts.toolCallId] = {
|
||||
...result.metadata,
|
||||
@@ -412,9 +445,7 @@ export namespace Session {
|
||||
)
|
||||
const result = streamText({
|
||||
onStepFinish: async (step) => {
|
||||
log.info("step finish", {
|
||||
finishReason: step.finishReason,
|
||||
})
|
||||
log.info("step finish", { finishReason: step.finishReason })
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(model.info, step.usage, step.providerMetadata)
|
||||
assistant.cost += usage.cost
|
||||
@@ -430,6 +461,9 @@ export namespace Session {
|
||||
text = undefined
|
||||
},
|
||||
async onFinish(input) {
|
||||
log.info("message finish", {
|
||||
reason: input.finishReason,
|
||||
})
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(model.info, input.usage, input.providerMetadata)
|
||||
assistant.cost = usage.cost
|
||||
@@ -478,22 +512,15 @@ export namespace Session {
|
||||
(x): CoreMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
providerOptions: {
|
||||
...(input.providerID === "anthropic"
|
||||
? {
|
||||
anthropic: {
|
||||
cacheControl: { type: "ephemeral" },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
...convertToCoreMessages(
|
||||
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
|
||||
),
|
||||
],
|
||||
temperature: model.info.id === "codex-mini-latest" ? undefined : 0,
|
||||
].map((msg, i) =>
|
||||
ProviderTransform.message(msg, i, input.providerID, input.modelID),
|
||||
),
|
||||
temperature: model.info.temperature ? 0 : undefined,
|
||||
tools: {
|
||||
...tools,
|
||||
},
|
||||
@@ -557,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
|
||||
@@ -592,6 +619,7 @@ export namespace Session {
|
||||
l.info("unhandled", {
|
||||
type: value.type,
|
||||
})
|
||||
continue
|
||||
}
|
||||
await updateMessage(next)
|
||||
}
|
||||
@@ -720,17 +748,16 @@ export namespace Session {
|
||||
await updateMessage(next)
|
||||
}
|
||||
|
||||
const pending = new Map<string, AbortController>()
|
||||
function lock(sessionID: string) {
|
||||
log.info("locking", { sessionID })
|
||||
if (pending.has(sessionID)) throw new BusyError(sessionID)
|
||||
if (state().pending.has(sessionID)) throw new BusyError(sessionID)
|
||||
const controller = new AbortController()
|
||||
pending.set(sessionID, controller)
|
||||
state().pending.set(sessionID, controller)
|
||||
return {
|
||||
signal: controller.signal,
|
||||
[Symbol.dispose]() {
|
||||
log.info("unlocking", { sessionID })
|
||||
pending.delete(sessionID)
|
||||
state().pending.delete(sessionID)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -755,6 +782,16 @@ export namespace Session {
|
||||
cost: new Decimal(0)
|
||||
.add(new Decimal(tokens.input).mul(model.cost.input).div(1_000_000))
|
||||
.add(new Decimal(tokens.output).mul(model.cost.output).div(1_000_000))
|
||||
.add(
|
||||
new Decimal(tokens.cache.read)
|
||||
.mul(model.cost.cache_read ?? 0)
|
||||
.div(1_000_000),
|
||||
)
|
||||
.add(
|
||||
new Decimal(tokens.cache.write)
|
||||
.mul(model.cost.cache_write ?? 0)
|
||||
.div(1_000_000),
|
||||
)
|
||||
.toNumber(),
|
||||
tokens,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
you will generate a short title based on the first message a user begins a conversation with
|
||||
You will generate a short title based on the first message a user begins a conversation with
|
||||
- ensure it is not more than 50 characters long
|
||||
- the title should be a summary of the user's message
|
||||
- it should be one line long
|
||||
|
||||
@@ -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"
|
||||
@@ -19,10 +20,11 @@ export namespace Share {
|
||||
export async function sync(key: string, content: any) {
|
||||
const [root, ...splits] = key.split("/")
|
||||
if (root !== "session") return
|
||||
const [, sessionID] = splits
|
||||
const session = await Session.get(sessionID)
|
||||
if (!session.share) return
|
||||
const { secret } = session.share
|
||||
const [sub, sessionID] = splits
|
||||
if (sub === "share") return
|
||||
const share = await Session.getShare(sessionID).catch(() => {})
|
||||
if (!share) return
|
||||
const { secret } = share
|
||||
pending.set(key, content)
|
||||
queue = queue
|
||||
.then(async () => {
|
||||
@@ -55,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`, {
|
||||
|
||||
@@ -24,8 +24,6 @@ export namespace Storage {
|
||||
}
|
||||
})
|
||||
|
||||
const locks = new Map<string, Promise<void>>()
|
||||
|
||||
export async function readJSON<T>(key: string) {
|
||||
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
@@ -35,7 +35,7 @@ export const BashTool = Tool.define({
|
||||
.min(0)
|
||||
.max(MAX_TIMEOUT)
|
||||
.describe("Optional timeout in milliseconds")
|
||||
.nullable(),
|
||||
.optional(),
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
|
||||
@@ -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"),
|
||||
@@ -21,7 +21,7 @@ export const EditTool = Tool.define({
|
||||
),
|
||||
replaceAll: z
|
||||
.boolean()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Replace all occurences of old_string (default false)"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
@@ -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: {
|
||||
@@ -87,7 +87,9 @@ export const EditTool = Tool.define({
|
||||
await file.write(contentNew)
|
||||
})()
|
||||
|
||||
const diff = createTwoFilesPatch(filepath, filepath, contentOld, contentNew)
|
||||
const diff = trimDiff(
|
||||
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
|
||||
)
|
||||
|
||||
FileTimes.read(ctx.sessionID, filepath)
|
||||
|
||||
@@ -113,3 +115,39 @@ export const EditTool = Tool.define({
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function trimDiff(diff: string): string {
|
||||
const lines = diff.split("\n")
|
||||
const contentLines = lines.filter(
|
||||
(line) =>
|
||||
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
|
||||
!line.startsWith("---") &&
|
||||
!line.startsWith("+++"),
|
||||
)
|
||||
|
||||
if (contentLines.length === 0) return diff
|
||||
|
||||
let min = Infinity
|
||||
for (const line of contentLines) {
|
||||
const content = line.slice(1)
|
||||
if (content.trim().length > 0) {
|
||||
const match = content.match(/^(\s*)/)
|
||||
if (match) min = Math.min(min, match[1].length)
|
||||
}
|
||||
}
|
||||
if (min === Infinity || min === 0) return diff
|
||||
const trimmedLines = lines.map((line) => {
|
||||
if (
|
||||
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
|
||||
!line.startsWith("---") &&
|
||||
!line.startsWith("+++")
|
||||
) {
|
||||
const prefix = line[0]
|
||||
const content = line.slice(1)
|
||||
return prefix + content.slice(min)
|
||||
}
|
||||
return line
|
||||
})
|
||||
|
||||
return trimmedLines.join("\n")
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ 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"),
|
||||
path: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
@@ -14,13 +14,13 @@ export const GrepTool = Tool.define({
|
||||
.describe("The regex pattern to search for in file contents"),
|
||||
path: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"The directory to search in. Defaults to the current working directory.",
|
||||
),
|
||||
include: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
|
||||
),
|
||||
|
||||
@@ -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
|
||||
@@ -29,11 +29,11 @@ export const ListTool = Tool.define({
|
||||
.describe(
|
||||
"The absolute path to the directory to list (must be absolute, not relative)",
|
||||
)
|
||||
.nullable(),
|
||||
.optional(),
|
||||
ignore: z
|
||||
.array(z.string())
|
||||
.describe("List of glob patterns to ignore")
|
||||
.nullable(),
|
||||
.optional(),
|
||||
}),
|
||||
async execute(params) {
|
||||
const app = App.info()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as fs from "fs/promises"
|
||||
import { Tool } from "./tool"
|
||||
import { FileTimes } from "./util/file-times"
|
||||
import DESCRIPTION from "./patch.txt"
|
||||
import { App } from "../app/app"
|
||||
|
||||
const PatchParams = z.object({
|
||||
patchText: z
|
||||
@@ -233,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,18 +12,18 @@ 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"),
|
||||
offset: z
|
||||
.number()
|
||||
.describe("The line number to start reading from (0-based)")
|
||||
.nullable(),
|
||||
.optional(),
|
||||
limit: z
|
||||
.number()
|
||||
.describe("The number of lines to read (defaults to 2000)")
|
||||
.nullable(),
|
||||
.optional(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
let filePath = params.filePath
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./task.txt"
|
||||
import { z } from "zod"
|
||||
import { Session } from "../session"
|
||||
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
|
||||
@@ -17,6 +19,31 @@ export const TaskTool = Tool.define({
|
||||
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
|
||||
const metadata = msg.metadata.assistant!
|
||||
|
||||
function summary(input: Message.Info) {
|
||||
const result = []
|
||||
|
||||
for (const part of input.parts) {
|
||||
if (part.type === "tool-invocation") {
|
||||
result.push({
|
||||
toolInvocation: part.toolInvocation,
|
||||
metadata: input.metadata.tool[part.toolInvocation.toolCallId],
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const unsub = Bus.subscribe(Message.Event.Updated, async (evt) => {
|
||||
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,
|
||||
@@ -28,10 +55,11 @@ export const TaskTool = Tool.define({
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
unsub()
|
||||
return {
|
||||
metadata: {
|
||||
title: params.description,
|
||||
summary: summary(result),
|
||||
},
|
||||
output: result.parts.findLast((x) => x.type === "text")!.text,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,10 +5,11 @@ export namespace Tool {
|
||||
title: string
|
||||
[key: string]: any
|
||||
}
|
||||
export type Context = {
|
||||
export type Context<M extends Metadata = Metadata> = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
abort: AbortSignal
|
||||
metadata(meta: M): void
|
||||
}
|
||||
export interface Info<
|
||||
Parameters extends StandardSchemaV1 = StandardSchemaV1,
|
||||
|
||||
@@ -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"),
|
||||
@@ -22,7 +22,7 @@ export const WebFetchTool = Tool.define({
|
||||
.min(0)
|
||||
.max(MAX_TIMEOUT / 1000)
|
||||
.describe("Optional timeout in seconds (max 120)")
|
||||
.nullable(),
|
||||
.optional(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
// Validate URL
|
||||
@@ -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
|
||||
@@ -11,15 +11,16 @@ export abstract class NamedError extends Error {
|
||||
name: Name,
|
||||
data: Data,
|
||||
) {
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.literal(name),
|
||||
data,
|
||||
})
|
||||
.openapi({
|
||||
ref: name,
|
||||
})
|
||||
const result = class extends NamedError {
|
||||
public static readonly Schema = z
|
||||
.object({
|
||||
name: z.literal(name),
|
||||
data: data,
|
||||
})
|
||||
.openapi({
|
||||
ref: name,
|
||||
})
|
||||
public static readonly Schema = schema
|
||||
|
||||
public readonly name = name as Name
|
||||
|
||||
@@ -29,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> {
|
||||
@@ -40,7 +37,7 @@ export abstract class NamedError extends Error {
|
||||
}
|
||||
|
||||
schema() {
|
||||
return result.Schema
|
||||
return schema
|
||||
}
|
||||
|
||||
toObject() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,21 +3,33 @@ import { App } from "../../src/app/app"
|
||||
import { GlobTool } from "../../src/tool/glob"
|
||||
import { ListTool } from "../../src/tool/ls"
|
||||
|
||||
const ctx = {
|
||||
sessionID: "test",
|
||||
messageID: "",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
}
|
||||
describe("tool.glob", () => {
|
||||
test("truncate", async () => {
|
||||
await App.provide({ cwd: process.cwd(), version: "test" }, async () => {
|
||||
await App.provide({ cwd: process.cwd() }, async () => {
|
||||
let result = await GlobTool.execute(
|
||||
{ pattern: "./node_modules/**/*" },
|
||||
{ sessionID: "test" },
|
||||
{
|
||||
pattern: "./node_modules/**/*",
|
||||
path: undefined,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.truncated).toBe(true)
|
||||
})
|
||||
})
|
||||
test("basic", async () => {
|
||||
await App.provide({ cwd: process.cwd(), version: "test" }, async () => {
|
||||
await App.provide({ cwd: process.cwd() }, async () => {
|
||||
let result = await GlobTool.execute(
|
||||
{ pattern: "*.json" },
|
||||
{ sessionID: "test" },
|
||||
{
|
||||
pattern: "*.json",
|
||||
path: undefined,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata).toMatchObject({
|
||||
truncated: false,
|
||||
@@ -29,15 +41,12 @@ describe("tool.glob", () => {
|
||||
|
||||
describe("tool.ls", () => {
|
||||
test("basic", async () => {
|
||||
const result = await App.provide(
|
||||
{ cwd: process.cwd(), version: "test" },
|
||||
async () => {
|
||||
return await ListTool.execute(
|
||||
{ path: "./example" },
|
||||
{ sessionID: "test" },
|
||||
)
|
||||
},
|
||||
)
|
||||
const result = await App.provide({ cwd: process.cwd() }, async () => {
|
||||
return await ListTool.execute(
|
||||
{ path: "./example", ignore: [".git"] },
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
expect(result.output).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -16,7 +17,34 @@ import (
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
version := Version
|
||||
if version != "dev" && !strings.HasPrefix(Version, "v") {
|
||||
version = "v" + Version
|
||||
}
|
||||
|
||||
url := os.Getenv("OPENCODE_SERVER")
|
||||
|
||||
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
|
||||
var appInfo client.AppInfo
|
||||
json.Unmarshal([]byte(appInfoStr), &appInfo)
|
||||
|
||||
logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log")
|
||||
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(filepath.Dir(logfile), 0755)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log directory", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
file, err := os.Create(logfile)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log file", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer file.Close()
|
||||
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
httpClient, err := client.NewClientWithResponses(url)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create client", "error", err)
|
||||
@@ -27,11 +55,7 @@ func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
version := Version
|
||||
if version != "dev" && !strings.HasPrefix(Version, "v") {
|
||||
version = "v" + Version
|
||||
}
|
||||
app_, err := app.New(ctx, version, httpClient)
|
||||
app_, err := app.New(ctx, version, appInfo, httpClient)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -40,7 +64,7 @@ func main() {
|
||||
tui.NewModel(app_),
|
||||
tea.WithAltScreen(),
|
||||
tea.WithKeyboardEnhancements(),
|
||||
// tea.WithMouseCellMotion(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
eventClient, err := client.NewClient(url)
|
||||
@@ -61,33 +85,10 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
paths, err := httpClient.PostPathGetWithResponse(context.Background())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
logfile := filepath.Join(paths.JSON200.Data, "log", "tui.log")
|
||||
|
||||
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(filepath.Dir(logfile), 0755)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log directory", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
file, err := os.Create(logfile)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create log file", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer file.Close()
|
||||
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// Run the TUI
|
||||
result, err := program.Run()
|
||||
if err != nil {
|
||||
slog.Error("TUI error", "error", err)
|
||||
// return fmt.Errorf("TUI error: %v", err)
|
||||
}
|
||||
|
||||
slog.Info("TUI exited", "result", result)
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ require (
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/x/ansi v0.8.0
|
||||
github.com/lithammer/fuzzysearch v1.1.8
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||
|
||||
+2
-2
@@ -34,8 +34,8 @@ github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 h1:D9AJJuYTN5pvz6mpIGO1ijLKpfTYSHOtKGgwoTQ4Gog=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 h1:iGrflaL5jQW6crML+pZx/ulWAVZQR3CQoRGvFsr2Tyg=
|
||||
|
||||
@@ -5,116 +5,168 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"log/slog"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
var RootPath string
|
||||
|
||||
type App struct {
|
||||
ConfigPath string
|
||||
Config *config.Config
|
||||
Client *client.ClientWithResponses
|
||||
Provider *client.ProviderInfo
|
||||
Model *client.ModelInfo
|
||||
Session *client.SessionInfo
|
||||
Messages []client.MessageInfo
|
||||
Commands commands.Registry
|
||||
Info client.AppInfo
|
||||
Version string
|
||||
StatePath string
|
||||
Config *client.ConfigInfo
|
||||
Client *client.ClientWithResponses
|
||||
State *config.State
|
||||
Provider *client.ProviderInfo
|
||||
Model *client.ModelInfo
|
||||
Session *client.SessionInfo
|
||||
Messages []client.MessageInfo
|
||||
Commands commands.CommandRegistry
|
||||
}
|
||||
|
||||
type AppInfo struct {
|
||||
client.AppInfo
|
||||
Version string
|
||||
type SessionSelectedMsg = *client.SessionInfo
|
||||
type ModelSelectedMsg struct {
|
||||
Provider client.ProviderInfo
|
||||
Model client.ModelInfo
|
||||
}
|
||||
type SessionClearedMsg struct{}
|
||||
type CompactSessionMsg struct{}
|
||||
type SendMsg struct {
|
||||
Text string
|
||||
Attachments []Attachment
|
||||
}
|
||||
type CompletionDialogTriggerdMsg struct {
|
||||
InitialValue string
|
||||
}
|
||||
|
||||
var Info AppInfo
|
||||
func New(
|
||||
ctx context.Context,
|
||||
version string,
|
||||
appInfo client.AppInfo,
|
||||
httpClient *client.ClientWithResponses,
|
||||
) (*App, error) {
|
||||
RootPath = appInfo.Path.Root
|
||||
|
||||
func New(ctx context.Context, version string, httpClient *client.ClientWithResponses) (*App, error) {
|
||||
appInfoResponse, _ := httpClient.PostAppInfoWithResponse(ctx)
|
||||
appInfo := appInfoResponse.JSON200
|
||||
Info = AppInfo{
|
||||
AppInfo: *appInfo,
|
||||
Version: version,
|
||||
}
|
||||
|
||||
providersResponse, err := httpClient.PostProviderListWithResponse(ctx)
|
||||
configResponse, err := httpClient.PostConfigGetWithResponse(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
providers := []client.ProviderInfo{}
|
||||
var defaultProvider *client.ProviderInfo
|
||||
var defaultModel *client.ModelInfo
|
||||
|
||||
var anthropic *client.ProviderInfo
|
||||
for _, provider := range providersResponse.JSON200.Providers {
|
||||
if provider.Id == "anthropic" {
|
||||
anthropic = &provider
|
||||
configInfo := configResponse.JSON200
|
||||
if configInfo.Keybinds == nil {
|
||||
leader := "ctrl+x"
|
||||
keybinds := client.ConfigKeybinds{
|
||||
Leader: &leader,
|
||||
}
|
||||
configInfo.Keybinds = &keybinds
|
||||
}
|
||||
|
||||
// default to anthropic if available
|
||||
if anthropic != nil {
|
||||
defaultProvider = anthropic
|
||||
defaultModel = getDefaultModel(providersResponse, *anthropic)
|
||||
}
|
||||
|
||||
for _, provider := range providersResponse.JSON200.Providers {
|
||||
if defaultProvider == nil || defaultModel == nil {
|
||||
defaultProvider = &provider
|
||||
defaultModel = getDefaultModel(providersResponse, provider)
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return nil, fmt.Errorf("no providers found")
|
||||
}
|
||||
|
||||
appConfigPath := filepath.Join(Info.Path.Config, "config")
|
||||
appConfig, err := config.LoadConfig(appConfigPath)
|
||||
appStatePath := filepath.Join(appInfo.Path.State, "tui")
|
||||
appState, err := config.LoadState(appStatePath)
|
||||
if err != nil {
|
||||
slog.Info("No TUI config found, using default values", "error", err)
|
||||
appConfig = config.NewConfig("opencode", defaultProvider.Id, defaultModel.Id)
|
||||
config.SaveConfig(appConfigPath, appConfig)
|
||||
appState = config.NewState()
|
||||
config.SaveState(appStatePath, appState)
|
||||
}
|
||||
|
||||
var currentProvider *client.ProviderInfo
|
||||
var currentModel *client.ModelInfo
|
||||
for _, provider := range providers {
|
||||
if provider.Id == appConfig.Provider {
|
||||
currentProvider = &provider
|
||||
if configInfo.Theme != nil {
|
||||
appState.Theme = *configInfo.Theme
|
||||
}
|
||||
if configInfo.Model != nil {
|
||||
splits := strings.Split(*configInfo.Model, "/")
|
||||
appState.Provider = splits[0]
|
||||
appState.Model = strings.Join(splits[1:], "/")
|
||||
}
|
||||
|
||||
for _, model := range provider.Models {
|
||||
if model.Id == appConfig.Model {
|
||||
currentModel = &model
|
||||
if appState.Theme != "" {
|
||||
theme.SetTheme(appState.Theme)
|
||||
}
|
||||
|
||||
slog.Debug("Loaded config", "config", configInfo)
|
||||
|
||||
app := &App{
|
||||
Info: appInfo,
|
||||
Version: version,
|
||||
StatePath: appStatePath,
|
||||
Config: configInfo,
|
||||
State: appState,
|
||||
Client: httpClient,
|
||||
Session: &client.SessionInfo{},
|
||||
Messages: []client.MessageInfo{},
|
||||
Commands: commands.LoadFromConfig(configInfo),
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (a *App) InitializeProvider() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
providersResponse, err := a.Client.PostProviderListWithResponse(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("Failed to list providers", "error", err)
|
||||
// TODO: notify user
|
||||
return nil
|
||||
}
|
||||
providers := []client.ProviderInfo{}
|
||||
var defaultProvider *client.ProviderInfo
|
||||
var defaultModel *client.ModelInfo
|
||||
|
||||
var anthropic *client.ProviderInfo
|
||||
for _, provider := range providersResponse.JSON200.Providers {
|
||||
if provider.Id == "anthropic" {
|
||||
anthropic = &provider
|
||||
}
|
||||
}
|
||||
|
||||
// default to anthropic if available
|
||||
if anthropic != nil {
|
||||
defaultProvider = anthropic
|
||||
defaultModel = getDefaultModel(providersResponse, *anthropic)
|
||||
}
|
||||
|
||||
for _, provider := range providersResponse.JSON200.Providers {
|
||||
if defaultProvider == nil || defaultModel == nil {
|
||||
defaultProvider = &provider
|
||||
defaultModel = getDefaultModel(providersResponse, provider)
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
slog.Error("No providers configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
var currentProvider *client.ProviderInfo
|
||||
var currentModel *client.ModelInfo
|
||||
for _, provider := range providers {
|
||||
if provider.Id == a.State.Provider {
|
||||
currentProvider = &provider
|
||||
|
||||
for _, model := range provider.Models {
|
||||
if model.Id == a.State.Model {
|
||||
currentModel = &model
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentProvider == nil || currentModel == nil {
|
||||
currentProvider = defaultProvider
|
||||
currentModel = defaultModel
|
||||
}
|
||||
if currentProvider == nil || currentModel == nil {
|
||||
currentProvider = defaultProvider
|
||||
currentModel = defaultModel
|
||||
}
|
||||
|
||||
app := &App{
|
||||
ConfigPath: appConfigPath,
|
||||
Config: appConfig,
|
||||
Client: httpClient,
|
||||
Provider: currentProvider,
|
||||
Model: currentModel,
|
||||
Session: &client.SessionInfo{},
|
||||
Messages: []client.MessageInfo{},
|
||||
Commands: commands.NewCommandRegistry(),
|
||||
// TODO: handle no provider or model setup, yet
|
||||
return ModelSelectedMsg{
|
||||
Provider: *currentProvider,
|
||||
Model: *currentModel,
|
||||
}
|
||||
}
|
||||
|
||||
theme.SetTheme(appConfig.Theme)
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func getDefaultModel(response *client.PostProviderListResponse, provider client.ProviderInfo) *client.ModelInfo {
|
||||
@@ -145,8 +197,11 @@ func (a *App) IsBusy() bool {
|
||||
return lastMessage.Metadata.Time.Completed == nil
|
||||
}
|
||||
|
||||
func (a *App) SaveConfig() {
|
||||
config.SaveConfig(a.ConfigPath, a.Config)
|
||||
func (a *App) SaveState() {
|
||||
err := config.SaveState(a.StatePath, a.State)
|
||||
if err != nil {
|
||||
slog.Error("Failed to save state", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
||||
@@ -159,19 +214,20 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
||||
}
|
||||
|
||||
a.Session = session
|
||||
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
|
||||
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||
|
||||
go func() {
|
||||
// TODO: Handle no provider or model setup, yet
|
||||
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
|
||||
SessionID: a.Session.Id,
|
||||
ProviderID: a.Provider.Id,
|
||||
ModelID: a.Model.Id,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize project", "error", err)
|
||||
// status.Error(err.Error())
|
||||
}
|
||||
if response != nil && response.StatusCode != 200 {
|
||||
slog.Error("Failed to initialize project", "error", response.StatusCode)
|
||||
// status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
|
||||
}
|
||||
}()
|
||||
@@ -179,6 +235,21 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
|
||||
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
|
||||
SessionID: a.Session.Id,
|
||||
ProviderID: a.Provider.Id,
|
||||
ModelID: a.Model.Id,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Failed to compact session", "error", err)
|
||||
}
|
||||
if response != nil && response.StatusCode() != 200 {
|
||||
slog.Error("Failed to compact session", "error", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) MarkProjectInitialized(ctx context.Context) error {
|
||||
response, err := a.Client.PostAppInitialize(ctx)
|
||||
if err != nil {
|
||||
@@ -212,7 +283,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
||||
return nil
|
||||
}
|
||||
a.Session = session
|
||||
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
|
||||
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||
}
|
||||
|
||||
// TODO: Handle attachments when API supports them
|
||||
@@ -317,3 +388,7 @@ func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error)
|
||||
providers := *resp.JSON200
|
||||
return providers.Providers, nil
|
||||
}
|
||||
|
||||
// func (a *App) loadCustomKeybinds() {
|
||||
//
|
||||
// }
|
||||
|
||||
@@ -1,71 +1,281 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
// Command represents a user-triggerable action.
|
||||
type ExecuteCommandMsg Command
|
||||
type ExecuteCommandsMsg []Command
|
||||
type CommandExecutedMsg Command
|
||||
|
||||
type Keybinding struct {
|
||||
RequiresLeader bool
|
||||
Key string
|
||||
}
|
||||
|
||||
func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool {
|
||||
key := k.Key
|
||||
key = strings.TrimSpace(key)
|
||||
return key == msg.String() && (k.RequiresLeader == leader)
|
||||
}
|
||||
|
||||
type CommandName string
|
||||
type Command struct {
|
||||
// Name is the identifier used for slash commands (e.g., "new").
|
||||
Name string
|
||||
// Description is a short explanation of what the command does.
|
||||
Name CommandName
|
||||
Description string
|
||||
// KeyBinding is the keyboard shortcut to trigger this command.
|
||||
KeyBinding key.Binding
|
||||
Keybindings []Keybinding
|
||||
Trigger string
|
||||
}
|
||||
|
||||
// Registry holds all the available commands.
|
||||
type Registry map[string]Command
|
||||
|
||||
// ExecuteCommandMsg is a message sent when a command should be executed.
|
||||
type ExecuteCommandMsg struct {
|
||||
Name string
|
||||
func (c Command) Keys() []string {
|
||||
var keys []string
|
||||
for _, k := range c.Keybindings {
|
||||
keys = append(keys, k.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func NewCommandRegistry() Registry {
|
||||
return Registry{
|
||||
"help": {
|
||||
Name: "help",
|
||||
type CommandRegistry map[CommandName]Command
|
||||
|
||||
func (r CommandRegistry) Sorted() []Command {
|
||||
var commands []Command
|
||||
for _, command := range r {
|
||||
commands = append(commands, command)
|
||||
}
|
||||
slices.SortFunc(commands, func(a, b Command) int {
|
||||
if a.Name == AppExitCommand {
|
||||
return 1
|
||||
}
|
||||
if b.Name == AppExitCommand {
|
||||
return -1
|
||||
}
|
||||
return strings.Compare(string(a.Name), string(b.Name))
|
||||
})
|
||||
return commands
|
||||
}
|
||||
|
||||
func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
|
||||
var matched []Command
|
||||
for _, command := range r.Sorted() {
|
||||
if command.Matches(msg, leader) {
|
||||
matched = append(matched, command)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
const (
|
||||
AppHelpCommand CommandName = "app_help"
|
||||
EditorOpenCommand CommandName = "editor_open"
|
||||
SessionNewCommand CommandName = "session_new"
|
||||
SessionListCommand CommandName = "session_list"
|
||||
SessionShareCommand CommandName = "session_share"
|
||||
SessionInterruptCommand CommandName = "session_interrupt"
|
||||
SessionCompactCommand CommandName = "session_compact"
|
||||
ToolDetailsCommand CommandName = "tool_details"
|
||||
ModelListCommand CommandName = "model_list"
|
||||
ThemeListCommand CommandName = "theme_list"
|
||||
ProjectInitCommand CommandName = "project_init"
|
||||
InputClearCommand CommandName = "input_clear"
|
||||
InputPasteCommand CommandName = "input_paste"
|
||||
InputSubmitCommand CommandName = "input_submit"
|
||||
InputNewlineCommand CommandName = "input_newline"
|
||||
HistoryPreviousCommand CommandName = "history_previous"
|
||||
HistoryNextCommand CommandName = "history_next"
|
||||
MessagesPageUpCommand CommandName = "messages_page_up"
|
||||
MessagesPageDownCommand CommandName = "messages_page_down"
|
||||
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
|
||||
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
|
||||
MessagesPreviousCommand CommandName = "messages_previous"
|
||||
MessagesNextCommand CommandName = "messages_next"
|
||||
MessagesFirstCommand CommandName = "messages_first"
|
||||
MessagesLastCommand CommandName = "messages_last"
|
||||
AppExitCommand CommandName = "app_exit"
|
||||
)
|
||||
|
||||
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
|
||||
for _, binding := range k.Keybindings {
|
||||
if binding.Matches(msg, leader) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseBindings(bindings ...string) []Keybinding {
|
||||
var parsedBindings []Keybinding
|
||||
for _, binding := range bindings {
|
||||
for p := range strings.SplitSeq(binding, ",") {
|
||||
requireLeader := strings.HasPrefix(p, "<leader>")
|
||||
keybinding := strings.ReplaceAll(p, "<leader>", "")
|
||||
keybinding = strings.TrimSpace(keybinding)
|
||||
parsedBindings = append(parsedBindings, Keybinding{
|
||||
RequiresLeader: requireLeader,
|
||||
Key: keybinding,
|
||||
})
|
||||
}
|
||||
}
|
||||
return parsedBindings
|
||||
}
|
||||
|
||||
func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
|
||||
defaults := []Command{
|
||||
{
|
||||
Name: AppHelpCommand,
|
||||
Description: "show help",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f1", "super+/", "super+h"),
|
||||
),
|
||||
Keybindings: parseBindings("<leader>h"),
|
||||
Trigger: "help",
|
||||
},
|
||||
"new": {
|
||||
Name: "new",
|
||||
{
|
||||
Name: EditorOpenCommand,
|
||||
Description: "open editor",
|
||||
Keybindings: parseBindings("<leader>e"),
|
||||
Trigger: "editor",
|
||||
},
|
||||
{
|
||||
Name: SessionNewCommand,
|
||||
Description: "new session",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f2", "super+n"),
|
||||
),
|
||||
Keybindings: parseBindings("<leader>n"),
|
||||
Trigger: "new",
|
||||
},
|
||||
"sessions": {
|
||||
Name: "sessions",
|
||||
Description: "switch session",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f3", "super+s"),
|
||||
),
|
||||
{
|
||||
Name: SessionListCommand,
|
||||
Description: "list sessions",
|
||||
Keybindings: parseBindings("<leader>l"),
|
||||
Trigger: "sessions",
|
||||
},
|
||||
"model": {
|
||||
Name: "model",
|
||||
Description: "switch model",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f4", "super+m"),
|
||||
),
|
||||
{
|
||||
Name: SessionShareCommand,
|
||||
Description: "share session",
|
||||
Keybindings: parseBindings("<leader>s"),
|
||||
Trigger: "share",
|
||||
},
|
||||
"theme": {
|
||||
Name: "theme",
|
||||
Description: "switch theme",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f5", "super+t"),
|
||||
),
|
||||
{
|
||||
Name: SessionInterruptCommand,
|
||||
Description: "interrupt session",
|
||||
Keybindings: parseBindings("esc"),
|
||||
},
|
||||
"quit": {
|
||||
Name: "quit",
|
||||
Description: "quit",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f10", "ctrl+c", "super+q"),
|
||||
),
|
||||
{
|
||||
Name: SessionCompactCommand,
|
||||
Description: "compact the session",
|
||||
Keybindings: parseBindings("<leader>c"),
|
||||
Trigger: "compact",
|
||||
},
|
||||
{
|
||||
Name: ToolDetailsCommand,
|
||||
Description: "toggle tool details",
|
||||
Keybindings: parseBindings("<leader>d"),
|
||||
Trigger: "details",
|
||||
},
|
||||
{
|
||||
Name: ModelListCommand,
|
||||
Description: "list models",
|
||||
Keybindings: parseBindings("<leader>m"),
|
||||
Trigger: "models",
|
||||
},
|
||||
{
|
||||
Name: ThemeListCommand,
|
||||
Description: "list themes",
|
||||
Keybindings: parseBindings("<leader>t"),
|
||||
Trigger: "themes",
|
||||
},
|
||||
{
|
||||
Name: ProjectInitCommand,
|
||||
Description: "create/update AGENTS.md",
|
||||
Keybindings: parseBindings("<leader>i"),
|
||||
Trigger: "init",
|
||||
},
|
||||
{
|
||||
Name: InputClearCommand,
|
||||
Description: "clear input",
|
||||
Keybindings: parseBindings("ctrl+c"),
|
||||
},
|
||||
{
|
||||
Name: InputPasteCommand,
|
||||
Description: "paste content",
|
||||
Keybindings: parseBindings("ctrl+v"),
|
||||
},
|
||||
{
|
||||
Name: InputSubmitCommand,
|
||||
Description: "submit message",
|
||||
Keybindings: parseBindings("enter"),
|
||||
},
|
||||
{
|
||||
Name: InputNewlineCommand,
|
||||
Description: "insert newline",
|
||||
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",
|
||||
Keybindings: parseBindings("pgup"),
|
||||
},
|
||||
{
|
||||
Name: MessagesPageDownCommand,
|
||||
Description: "page down",
|
||||
Keybindings: parseBindings("pgdown"),
|
||||
},
|
||||
{
|
||||
Name: MessagesHalfPageUpCommand,
|
||||
Description: "half page up",
|
||||
Keybindings: parseBindings("ctrl+alt+u"),
|
||||
},
|
||||
{
|
||||
Name: MessagesHalfPageDownCommand,
|
||||
Description: "half page down",
|
||||
Keybindings: parseBindings("ctrl+alt+d"),
|
||||
},
|
||||
{
|
||||
Name: MessagesPreviousCommand,
|
||||
Description: "previous message",
|
||||
Keybindings: parseBindings("ctrl+alt+k"),
|
||||
},
|
||||
{
|
||||
Name: MessagesNextCommand,
|
||||
Description: "next message",
|
||||
Keybindings: parseBindings("ctrl+alt+j"),
|
||||
},
|
||||
{
|
||||
Name: MessagesFirstCommand,
|
||||
Description: "first message",
|
||||
Keybindings: parseBindings("ctrl+g"),
|
||||
},
|
||||
{
|
||||
Name: MessagesLastCommand,
|
||||
Description: "last message",
|
||||
Keybindings: parseBindings("ctrl+alt+g"),
|
||||
},
|
||||
{
|
||||
Name: AppExitCommand,
|
||||
Description: "exit the app",
|
||||
Keybindings: parseBindings("ctrl+c", "<leader>q"),
|
||||
Trigger: "exit",
|
||||
},
|
||||
}
|
||||
registry := make(CommandRegistry)
|
||||
keybinds := map[string]string{}
|
||||
marshalled, _ := json.Marshal(*config.Keybinds)
|
||||
json.Unmarshal(marshalled, &keybinds)
|
||||
for _, command := range defaults {
|
||||
if keybind, ok := keybinds[string(command.Name)]; ok {
|
||||
command.Keybindings = parseBindings(keybind)
|
||||
}
|
||||
registry[command.Name] = command
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@ package completions
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type CommandCompletionProvider struct {
|
||||
@@ -27,15 +31,42 @@ func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetEmptyMessage() string {
|
||||
return "no matching commands"
|
||||
}
|
||||
|
||||
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)
|
||||
return dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: title,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
space = lipgloss.Width(cmd.Trigger)
|
||||
}
|
||||
}
|
||||
space += 2
|
||||
|
||||
sorted := commands.Sorted()
|
||||
if query == "" {
|
||||
// If no query, return all commands
|
||||
items := []dialog.CompletionItemI{}
|
||||
for _, cmd := range c.app.Commands {
|
||||
items = append(items, dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: " /" + cmd.Name,
|
||||
Value: "/" + cmd.Name,
|
||||
}))
|
||||
for _, cmd := range sorted {
|
||||
if cmd.Trigger == "" {
|
||||
continue
|
||||
}
|
||||
space := space - lipgloss.Width(cmd.Trigger)
|
||||
items = append(items, getCommandCompletionItem(cmd, space, t))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -44,12 +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 {
|
||||
commandNames = append(commandNames, cmd.Name)
|
||||
commandMap[cmd.Name] = dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: " /" + cmd.Name,
|
||||
Value: "/" + cmd.Name,
|
||||
})
|
||||
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, t)
|
||||
}
|
||||
|
||||
// Find fuzzy matches
|
||||
@@ -65,7 +97,5 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
|
||||
})
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
|
||||
return "no matching files"
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
|
||||
response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{
|
||||
Query: query,
|
||||
|
||||
@@ -20,10 +20,13 @@ func NewCompletionManager(app *app.App) *CompletionManager {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CompletionManager) DefaultProvider() dialog.CompletionProvider {
|
||||
return m.providers["commands"]
|
||||
}
|
||||
|
||||
func (m *CompletionManager) GetProvider(input string) dialog.CompletionProvider {
|
||||
if strings.HasPrefix(input, "/") {
|
||||
return m.providers["commands"]
|
||||
}
|
||||
return m.providers["files"]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type SendMsg struct {
|
||||
Text string
|
||||
Attachments []app.Attachment
|
||||
}
|
||||
|
||||
func repo(width int) string {
|
||||
repo := "github.com/sst/opencode"
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
return styles.BaseStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Width(width).
|
||||
Render(repo)
|
||||
}
|
||||
@@ -3,18 +3,15 @@ package chat
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"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"
|
||||
@@ -22,75 +19,32 @@ import (
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
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)
|
||||
Paste() (tea.Model, tea.Cmd)
|
||||
Newline() (tea.Model, tea.Cmd)
|
||||
Previous() (tea.Model, tea.Cmd)
|
||||
Next() (tea.Model, tea.Cmd)
|
||||
}
|
||||
|
||||
type editorComponent struct {
|
||||
width int
|
||||
height int
|
||||
app *app.App
|
||||
width, height int
|
||||
textarea textarea.Model
|
||||
attachments []app.Attachment
|
||||
deleteMode bool
|
||||
history []string
|
||||
historyIndex int
|
||||
currentMessage string
|
||||
spinner spinner.Model
|
||||
}
|
||||
|
||||
type EditorKeyMaps struct {
|
||||
Send key.Binding
|
||||
OpenEditor key.Binding
|
||||
Paste key.Binding
|
||||
HistoryUp key.Binding
|
||||
HistoryDown key.Binding
|
||||
}
|
||||
|
||||
type DeleteAttachmentKeyMaps struct {
|
||||
AttachmentDeleteMode key.Binding
|
||||
Escape key.Binding
|
||||
DeleteAllAttachments key.Binding
|
||||
}
|
||||
|
||||
var editorMaps = EditorKeyMaps{
|
||||
Send: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "send message"),
|
||||
),
|
||||
OpenEditor: key.NewBinding(
|
||||
key.WithKeys("f12"),
|
||||
key.WithHelp("f12", "open editor"),
|
||||
),
|
||||
Paste: key.NewBinding(
|
||||
key.WithKeys("ctrl+v"),
|
||||
key.WithHelp("ctrl+v", "paste content"),
|
||||
),
|
||||
HistoryUp: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithHelp("up", "previous message"),
|
||||
),
|
||||
HistoryDown: key.NewBinding(
|
||||
key.WithKeys("down"),
|
||||
key.WithHelp("down", "next message"),
|
||||
),
|
||||
}
|
||||
|
||||
var DeleteKeyMaps = DeleteAttachmentKeyMaps{
|
||||
AttachmentDeleteMode: key.NewBinding(
|
||||
key.WithKeys("ctrl+r"),
|
||||
key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
|
||||
),
|
||||
Escape: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "cancel delete mode"),
|
||||
),
|
||||
DeleteAllAttachments: key.NewBinding(
|
||||
key.WithKeys("r"),
|
||||
key.WithHelp("ctrl+r+r", "delete all attachments"),
|
||||
),
|
||||
}
|
||||
|
||||
const (
|
||||
maxAttachments = 5
|
||||
)
|
||||
|
||||
func (m *editorComponent) Init() tea.Cmd {
|
||||
return tea.Batch(textarea.Blink, m.spinner.Tick, tea.EnableReportFocus)
|
||||
}
|
||||
@@ -98,152 +52,33 @@ 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 dialog.ThemeChangedMsg:
|
||||
case tea.KeyPressMsg:
|
||||
// Maximize editor responsiveness for printable characters
|
||||
if msg.Text != "" {
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
case dialog.ThemeSelectedMsg:
|
||||
m.textarea = createTextArea(&m.textarea)
|
||||
m.spinner = createSpinner()
|
||||
return m, tea.Batch(m.spinner.Tick, textarea.Blink)
|
||||
case dialog.CompletionSelectedMsg:
|
||||
if msg.IsCommand {
|
||||
// Execute the command directly
|
||||
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
|
||||
m.textarea.Reset()
|
||||
return m, util.CmdHandler(commands.ExecuteCommandMsg{Name: 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 {
|
||||
// For files, replace the text in the editor
|
||||
existingValue := m.textarea.Value()
|
||||
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
|
||||
m.textarea.SetValue(modifiedValue)
|
||||
m.textarea.SetValue(modifiedValue + " ")
|
||||
return m, nil
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
if m.textarea.Value() != "" {
|
||||
m.textarea.Reset()
|
||||
return m, func() tea.Msg {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
case "shift+enter":
|
||||
value := m.textarea.Value()
|
||||
m.textarea.SetValue(value + "\n")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
|
||||
m.deleteMode = true
|
||||
return m, nil
|
||||
}
|
||||
if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
|
||||
m.deleteMode = false
|
||||
m.attachments = nil
|
||||
return m, nil
|
||||
}
|
||||
// if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
|
||||
// num := int(msg.Runes[0] - '0')
|
||||
// m.deleteMode = false
|
||||
// if num < 10 && len(m.attachments) > num {
|
||||
// if num == 0 {
|
||||
// m.attachments = m.attachments[num+1:]
|
||||
// } else {
|
||||
// m.attachments = slices.Delete(m.attachments, num, num+1)
|
||||
// }
|
||||
// return m, nil
|
||||
// }
|
||||
// }
|
||||
if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
|
||||
key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
|
||||
return m, nil
|
||||
}
|
||||
if key.Matches(msg, editorMaps.OpenEditor) {
|
||||
if m.app.IsBusy() {
|
||||
// status.Warn("Agent is working, please wait...")
|
||||
return m, nil
|
||||
}
|
||||
value := m.textarea.Value()
|
||||
m.textarea.Reset()
|
||||
return m, m.openEditor(value)
|
||||
}
|
||||
if key.Matches(msg, DeleteKeyMaps.Escape) {
|
||||
m.deleteMode = false
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if key.Matches(msg, editorMaps.Paste) {
|
||||
imageBytes, text, err := image.GetImageFromClipboard()
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
return m, cmd
|
||||
}
|
||||
if len(imageBytes) != 0 {
|
||||
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
|
||||
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
|
||||
m.attachments = append(m.attachments, attachment)
|
||||
} else {
|
||||
m.textarea.SetValue(m.textarea.Value() + text)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Handle history navigation with up/down arrow keys
|
||||
// Only handle history navigation if the filepicker is not open and completion dialog is not open
|
||||
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) {
|
||||
// TODO: fix this
|
||||
// && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
|
||||
// Get the current line number
|
||||
currentLine := m.textarea.Line()
|
||||
|
||||
// Only navigate history if we're at the first line
|
||||
if currentLine == 0 && len(m.history) > 0 {
|
||||
// Save current message if we're just starting to navigate
|
||||
if m.historyIndex == len(m.history) {
|
||||
m.currentMessage = m.textarea.Value()
|
||||
}
|
||||
|
||||
// Go to previous message in history
|
||||
if m.historyIndex > 0 {
|
||||
m.historyIndex--
|
||||
m.textarea.SetValue(m.history[m.historyIndex])
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) {
|
||||
// TODO: fix this
|
||||
// && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
|
||||
// Get the current line number and total lines
|
||||
currentLine := m.textarea.Line()
|
||||
value := m.textarea.Value()
|
||||
lines := strings.Split(value, "\n")
|
||||
totalLines := len(lines)
|
||||
|
||||
// Only navigate history if we're at the last line
|
||||
if currentLine == totalLines-1 {
|
||||
if m.historyIndex < len(m.history)-1 {
|
||||
// Go to next message in history
|
||||
m.historyIndex++
|
||||
m.textarea.SetValue(m.history[m.historyIndex])
|
||||
} else if m.historyIndex == len(m.history)-1 {
|
||||
// Return to the current message being composed
|
||||
m.historyIndex = len(m.history)
|
||||
m.textarea.SetValue(m.currentMessage)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Enter key
|
||||
if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
|
||||
value := m.textarea.Value()
|
||||
if len(value) > 0 && value[len(value)-1] == '\\' {
|
||||
// If the last character is a backslash, remove it and add a newline
|
||||
m.textarea.SetValue(value[:len(value)-1] + "\n")
|
||||
return m, nil
|
||||
} else {
|
||||
// Otherwise, send the message
|
||||
return m, m.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
@@ -255,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
|
||||
@@ -275,9 +110,6 @@ func (m *editorComponent) View() string {
|
||||
PaddingTop(1).
|
||||
PaddingBottom(1).
|
||||
Background(t.BackgroundElement()).
|
||||
Border(lipgloss.ThickBorder(), false, true).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderBackground(t.Background()).
|
||||
Render(textarea)
|
||||
|
||||
hint := base("enter") + muted(" send ")
|
||||
@@ -287,7 +119,7 @@ func (m *editorComponent) View() string {
|
||||
|
||||
model := ""
|
||||
if m.app.Model != nil {
|
||||
model = base(m.app.Model.Name) + muted(" • /model")
|
||||
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
|
||||
}
|
||||
|
||||
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
|
||||
@@ -297,66 +129,52 @@ func (m *editorComponent) View() string {
|
||||
info = styles.Padded().Background(t.Background()).Render(info)
|
||||
|
||||
content := strings.Join([]string{"", textarea, info}, "\n")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) GetSize() (int, int) {
|
||||
return m.width, m.height
|
||||
func (m *editorComponent) Lines() int {
|
||||
return m.textarea.LineCount()
|
||||
}
|
||||
|
||||
func (m *editorComponent) openEditor(value string) tea.Cmd {
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
editor = "nvim"
|
||||
}
|
||||
|
||||
tmpfile, err := os.CreateTemp("", "msg_*.md")
|
||||
tmpfile.WriteString(value)
|
||||
if err != nil {
|
||||
// status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
tmpfile.Close()
|
||||
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
|
||||
c.Stdin = os.Stdin
|
||||
c.Stdout = os.Stdout
|
||||
c.Stderr = os.Stderr
|
||||
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
if err != nil {
|
||||
// status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
content, err := os.ReadFile(tmpfile.Name())
|
||||
if err != nil {
|
||||
// status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
if len(content) == 0 {
|
||||
// status.Warn("Message is empty")
|
||||
return nil
|
||||
}
|
||||
os.Remove(tmpfile.Name())
|
||||
attachments := m.attachments
|
||||
m.attachments = nil
|
||||
return SendMsg{
|
||||
Text: string(content),
|
||||
Attachments: attachments,
|
||||
}
|
||||
})
|
||||
func (m *editorComponent) Value() string {
|
||||
return m.textarea.Value()
|
||||
}
|
||||
|
||||
func (m *editorComponent) send() tea.Cmd {
|
||||
value := strings.TrimSpace(m.textarea.Value())
|
||||
m.textarea.Reset()
|
||||
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
||||
value := strings.TrimSpace(m.Value())
|
||||
if value == "" {
|
||||
return m, nil
|
||||
}
|
||||
if len(value) > 0 && value[len(value)-1] == '\\' {
|
||||
// If the last character is a backslash, remove it and add a newline
|
||||
m.textarea.SetValue(value[:len(value)-1] + "\n")
|
||||
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
|
||||
@@ -369,24 +187,77 @@ func (m *editorComponent) send() tea.Cmd {
|
||||
}
|
||||
|
||||
m.attachments = nil
|
||||
if value == "" {
|
||||
return nil
|
||||
|
||||
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
|
||||
m.textarea.Reset()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
|
||||
imageBytes, text, err := image.GetImageFromClipboard()
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
return m, nil
|
||||
}
|
||||
if len(imageBytes) != 0 {
|
||||
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
|
||||
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
|
||||
m.attachments = append(m.attachments, attachment)
|
||||
} else {
|
||||
m.textarea.SetValue(m.textarea.Value() + text)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Check for slash command
|
||||
// if strings.HasPrefix(value, "/") {
|
||||
// commandName := strings.TrimPrefix(value, "/")
|
||||
// if _, ok := m.app.Commands[commandName]; ok {
|
||||
// return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
|
||||
// }
|
||||
// }
|
||||
func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
|
||||
m.textarea.Newline()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return tea.Batch(
|
||||
util.CmdHandler(SendMsg{
|
||||
Text: value,
|
||||
Attachments: attachments,
|
||||
}),
|
||||
)
|
||||
func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
|
||||
currentLine := m.textarea.Line()
|
||||
|
||||
// Only navigate history if we're at the first line
|
||||
if currentLine == 0 && len(m.history) > 0 {
|
||||
// Save current message if we're just starting to navigate
|
||||
if m.historyIndex == len(m.history) {
|
||||
m.currentMessage = m.textarea.Value()
|
||||
}
|
||||
|
||||
// Go to previous message in history
|
||||
if m.historyIndex > 0 {
|
||||
m.historyIndex--
|
||||
m.textarea.SetValue(m.history[m.historyIndex])
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
|
||||
currentLine := m.textarea.Line()
|
||||
value := m.textarea.Value()
|
||||
lines := strings.Split(value, "\n")
|
||||
totalLines := len(lines)
|
||||
|
||||
// Only navigate history if we're at the last line
|
||||
if currentLine == totalLines-1 {
|
||||
if m.historyIndex < len(m.history)-1 {
|
||||
// Go to next message in history
|
||||
m.historyIndex++
|
||||
m.textarea.SetValue(m.history[m.historyIndex])
|
||||
} else if m.historyIndex == len(m.history)-1 {
|
||||
// Return to the current message being composed
|
||||
m.historyIndex = len(m.history)
|
||||
m.textarea.SetValue(m.currentMessage)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func createTextArea(existing *textarea.Model) textarea.Model {
|
||||
@@ -421,12 +292,8 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
||||
return ta
|
||||
}
|
||||
|
||||
func (m *editorComponent) GetValue() string {
|
||||
return m.textarea.Value()
|
||||
}
|
||||
|
||||
func NewEditorComponent(app *app.App) layout.ModelWithView {
|
||||
s := spinner.New(
|
||||
func createSpinner() spinner.Model {
|
||||
return spinner.New(
|
||||
spinner.WithSpinner(spinner.Ellipsis),
|
||||
spinner.WithStyle(
|
||||
styles.
|
||||
@@ -434,6 +301,10 @@ func NewEditorComponent(app *app.App) layout.ModelWithView {
|
||||
Background(theme.CurrentTheme().Background()).
|
||||
Width(3)),
|
||||
)
|
||||
}
|
||||
|
||||
func NewEditorComponent(app *app.App) EditorComponent {
|
||||
s := createSpinner()
|
||||
ta := createTextArea(nil)
|
||||
|
||||
return &editorComponent{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -23,7 +24,7 @@ import (
|
||||
|
||||
func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
|
||||
r := styles.GetMarkdownRenderer(width, backgroundColor)
|
||||
content = strings.ReplaceAll(content, app.Info.Path.Root+"/", "")
|
||||
content = strings.ReplaceAll(content, app.RootPath+"/", "")
|
||||
rendered, _ := r.Render(content)
|
||||
lines := strings.Split(rendered, "\n")
|
||||
|
||||
@@ -201,17 +202,20 @@ func renderContentBlock(content string, options ...renderingOption) string {
|
||||
return content
|
||||
}
|
||||
|
||||
func calculatePadding() int {
|
||||
if layout.Current.Viewport.Width < 80 {
|
||||
return 5
|
||||
} else if layout.Current.Viewport.Width < 120 {
|
||||
return 15
|
||||
} else {
|
||||
return 20
|
||||
}
|
||||
}
|
||||
|
||||
func renderText(message client.MessageInfo, text string, author string) string {
|
||||
t := theme.CurrentTheme()
|
||||
width := layout.Current.Container.Width
|
||||
padding := 0
|
||||
if layout.Current.Viewport.Width < 80 {
|
||||
padding = 5
|
||||
} else if layout.Current.Viewport.Width < 120 {
|
||||
padding = 15
|
||||
} else {
|
||||
padding = 20
|
||||
}
|
||||
padding := calculatePadding()
|
||||
|
||||
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
|
||||
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
|
||||
@@ -222,9 +226,11 @@ func renderText(message client.MessageInfo, text string, author string) string {
|
||||
|
||||
textWidth := max(lipgloss.Width(text), lipgloss.Width(info))
|
||||
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
|
||||
if message.Role == client.Assistant {
|
||||
markdownWidth = width - padding - 4
|
||||
}
|
||||
content := toMarkdown(text, markdownWidth, t.BackgroundSubtle())
|
||||
content = strings.Join([]string{content, info}, "\n")
|
||||
// content = lipgloss.JoinVertical(align, content, info)
|
||||
|
||||
switch message.Role {
|
||||
case client.User:
|
||||
@@ -245,9 +251,11 @@ func renderToolInvocation(
|
||||
toolCall client.MessageToolInvocationToolCall,
|
||||
result *string,
|
||||
metadata client.MessageInfo_Metadata_Tool_AdditionalProperties,
|
||||
showResult bool,
|
||||
showDetails bool,
|
||||
isLast bool,
|
||||
contentOnly bool,
|
||||
) string {
|
||||
ignoredTools := []string{"opencode_todoread"}
|
||||
ignoredTools := []string{"todoread"}
|
||||
if slices.Contains(ignoredTools, toolCall.ToolName) {
|
||||
return ""
|
||||
}
|
||||
@@ -256,7 +264,7 @@ func renderToolInvocation(
|
||||
innerWidth := outerWidth - 6
|
||||
paddingTop := 0
|
||||
paddingBottom := 0
|
||||
if showResult {
|
||||
if showDetails {
|
||||
paddingTop = 1
|
||||
if result == nil || *result == "" {
|
||||
paddingBottom = 1
|
||||
@@ -278,22 +286,40 @@ func renderToolInvocation(
|
||||
BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
if toolCall.State == "partial-call" {
|
||||
title := renderToolAction(toolCall.ToolName)
|
||||
if !showDetails {
|
||||
title = "∟ " + title
|
||||
padding := calculatePadding()
|
||||
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundSubtle())
|
||||
return renderContentBlock(style.Render(title),
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(t.Accent()),
|
||||
WithPaddingTop(0),
|
||||
WithPaddingBottom(1),
|
||||
)
|
||||
}
|
||||
|
||||
style = style.Foreground(t.TextMuted())
|
||||
return style.Render(renderToolAction(toolCall.ToolName))
|
||||
return style.Render(title)
|
||||
}
|
||||
|
||||
toolArgs := ""
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -318,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
|
||||
@@ -364,7 +379,7 @@ func renderToolInvocation(
|
||||
BorderRight(true).
|
||||
Render(formattedDiff)
|
||||
|
||||
if showResult {
|
||||
if showDetails {
|
||||
style = style.Width(lipgloss.Width(formattedDiff))
|
||||
title += "\n"
|
||||
}
|
||||
@@ -380,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)
|
||||
@@ -398,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("Fetching: %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("Planning %s", elapsed)
|
||||
case "todowrite":
|
||||
title = fmt.Sprintf("PLAN")
|
||||
|
||||
if to, ok := metadata.Get("todos"); ok && finished {
|
||||
todos := to.([]any)
|
||||
@@ -429,14 +446,76 @@ 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()
|
||||
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundSubtle())
|
||||
paddingBottom := 0
|
||||
if isLast {
|
||||
paddingBottom = 1
|
||||
}
|
||||
return renderContentBlock(style.Render(title),
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(t.Accent()),
|
||||
WithPaddingTop(0),
|
||||
WithPaddingBottom(paddingBottom),
|
||||
)
|
||||
}
|
||||
|
||||
if body == "" && error == "" {
|
||||
body = *result
|
||||
body = truncateHeight(body, 10)
|
||||
@@ -450,10 +529,10 @@ func renderToolInvocation(
|
||||
content,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
if showResult && body != "" && error == "" {
|
||||
if showDetails && body != "" && error == "" {
|
||||
content += "\n" + body
|
||||
}
|
||||
if showResult && error != "" {
|
||||
if showDetails && error != "" {
|
||||
content += "\n" + error
|
||||
}
|
||||
return content
|
||||
@@ -461,22 +540,18 @@ func renderToolInvocation(
|
||||
|
||||
func renderToolName(name string) string {
|
||||
switch name {
|
||||
// case agent.AgentToolName:
|
||||
// return "Task"
|
||||
case "opencode_ls":
|
||||
return "List"
|
||||
case "opencode_webfetch":
|
||||
return "Fetch"
|
||||
case "opencode_todoread":
|
||||
return "Planning"
|
||||
case "opencode_todowrite":
|
||||
return "Planning"
|
||||
case "list":
|
||||
return "LIST"
|
||||
case "webfetch":
|
||||
return "FETCH"
|
||||
case "todowrite":
|
||||
return "PLAN"
|
||||
default:
|
||||
normalizedName := name
|
||||
if strings.HasPrefix(name, "opencode_") {
|
||||
normalizedName = strings.TrimPrefix(name, "opencode_")
|
||||
}
|
||||
return cases.Title(language.Und).String(normalizedName)
|
||||
return cases.Upper(language.Und).String(normalizedName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,27 +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_patch":
|
||||
case "todowrite", "todoread":
|
||||
return "Planning..."
|
||||
case "patch":
|
||||
return "Preparing patch..."
|
||||
case "opencode_batch":
|
||||
case "batch":
|
||||
return "Running batch operations..."
|
||||
}
|
||||
return "Working..."
|
||||
@@ -554,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
|
||||
}
|
||||
@@ -584,7 +669,7 @@ func truncateHeight(content string, height int) string {
|
||||
}
|
||||
|
||||
func relative(path string) string {
|
||||
return strings.TrimPrefix(path, app.Info.Path.Root+"/")
|
||||
return strings.TrimPrefix(path, app.RootPath+"/")
|
||||
}
|
||||
|
||||
func extension(path string) string {
|
||||
|
||||
@@ -1,116 +1,105 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||
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/state"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
type MessagesComponent interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
PageUp() (tea.Model, tea.Cmd)
|
||||
PageDown() (tea.Model, tea.Cmd)
|
||||
HalfPageUp() (tea.Model, tea.Cmd)
|
||||
HalfPageDown() (tea.Model, tea.Cmd)
|
||||
First() (tea.Model, tea.Cmd)
|
||||
Last() (tea.Model, tea.Cmd)
|
||||
// Previous() (tea.Model, tea.Cmd)
|
||||
// Next() (tea.Model, tea.Cmd)
|
||||
ToolDetailsVisible() bool
|
||||
}
|
||||
|
||||
type messagesComponent struct {
|
||||
app *app.App
|
||||
width, height int
|
||||
app *app.App
|
||||
viewport viewport.Model
|
||||
spinner spinner.Model
|
||||
rendering bool
|
||||
attachments viewport.Model
|
||||
showToolResults bool
|
||||
commands commands.CommandsComponent
|
||||
cache *MessageCache
|
||||
rendering bool
|
||||
showToolDetails bool
|
||||
tail bool
|
||||
}
|
||||
type renderFinishedMsg struct{}
|
||||
type ToggleToolMessagesMsg struct{}
|
||||
|
||||
type MessageKeys struct {
|
||||
PageDown key.Binding
|
||||
PageUp key.Binding
|
||||
HalfPageUp key.Binding
|
||||
HalfPageDown key.Binding
|
||||
}
|
||||
|
||||
var messageKeys = MessageKeys{
|
||||
PageDown: key.NewBinding(
|
||||
key.WithKeys("pgdown"),
|
||||
key.WithHelp("f/pgdn", "page down"),
|
||||
),
|
||||
PageUp: key.NewBinding(
|
||||
key.WithKeys("pgup"),
|
||||
key.WithHelp("b/pgup", "page up"),
|
||||
),
|
||||
HalfPageUp: key.NewBinding(
|
||||
key.WithKeys("ctrl+u"),
|
||||
key.WithHelp("ctrl+u", "½ page up"),
|
||||
),
|
||||
HalfPageDown: key.NewBinding(
|
||||
key.WithKeys("ctrl+d", "ctrl+d"),
|
||||
key.WithHelp("ctrl+d", "½ page down"),
|
||||
),
|
||||
}
|
||||
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) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case SendMsg:
|
||||
switch msg.(type) {
|
||||
case app.SendMsg:
|
||||
m.viewport.GotoBottom()
|
||||
m.tail = true
|
||||
return m, nil
|
||||
case dialog.ThemeChangedMsg:
|
||||
case dialog.ThemeSelectedMsg:
|
||||
m.cache.Clear()
|
||||
m.renderView()
|
||||
return m, nil
|
||||
case ToggleToolMessagesMsg:
|
||||
m.showToolResults = !m.showToolResults
|
||||
case ToggleToolDetailsMsg:
|
||||
m.showToolDetails = !m.showToolDetails
|
||||
m.renderView()
|
||||
return m, nil
|
||||
case state.SessionSelectedMsg:
|
||||
case app.SessionSelectedMsg:
|
||||
m.cache.Clear()
|
||||
cmd := m.Reload()
|
||||
m.viewport.GotoBottom()
|
||||
return m, cmd
|
||||
case state.SessionClearedMsg:
|
||||
case app.SessionClearedMsg:
|
||||
m.cache.Clear()
|
||||
cmd := m.Reload()
|
||||
return m, cmd
|
||||
case tea.KeyMsg:
|
||||
if key.Matches(msg, messageKeys.PageUp) ||
|
||||
key.Matches(msg, messageKeys.PageDown) ||
|
||||
key.Matches(msg, messageKeys.HalfPageUp) ||
|
||||
key.Matches(msg, messageKeys.HalfPageDown) {
|
||||
u, cmd := m.viewport.Update(msg)
|
||||
m.viewport = u
|
||||
m.tail = m.viewport.AtBottom()
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
case renderFinishedMsg:
|
||||
m.rendering = false
|
||||
if m.tail {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
case state.StateUpdatedMsg:
|
||||
case client.EventSessionUpdated, client.EventMessageUpdated:
|
||||
m.renderView()
|
||||
if m.tail {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
}
|
||||
|
||||
viewport, cmd := m.viewport.Update(msg)
|
||||
m.viewport = viewport
|
||||
m.tail = m.viewport.AtBottom()
|
||||
cmds = append(cmds, 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...)
|
||||
}
|
||||
|
||||
@@ -135,16 +124,27 @@ func (m *messagesComponent) renderView() {
|
||||
for _, message := range m.app.Messages {
|
||||
var content string
|
||||
var cached bool
|
||||
lastToolIndex := 0
|
||||
lastToolIndices := []int{}
|
||||
for i, p := range message.Parts {
|
||||
part, _ := p.ValueByDiscriminator()
|
||||
switch part.(type) {
|
||||
case client.MessagePartText:
|
||||
lastToolIndices = append(lastToolIndices, lastToolIndex)
|
||||
case client.MessagePartToolInvocation:
|
||||
lastToolIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
author := ""
|
||||
switch message.Role {
|
||||
case client.User:
|
||||
author = app.Info.User
|
||||
author = m.app.Info.User
|
||||
case client.Assistant:
|
||||
author = message.Metadata.Assistant.ModelID
|
||||
}
|
||||
|
||||
for _, p := range message.Parts {
|
||||
for i, p := range message.Parts {
|
||||
part, err := p.ValueByDiscriminator()
|
||||
if err != nil {
|
||||
continue //TODO: handle error?
|
||||
@@ -171,6 +171,7 @@ func (m *messagesComponent) renderView() {
|
||||
previousBlockType = assistantTextBlock
|
||||
}
|
||||
case client.MessagePartToolInvocation:
|
||||
isLastToolInvocation := slices.Contains(lastToolIndices, i)
|
||||
toolInvocationPart := part.(client.MessagePartToolInvocation)
|
||||
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
|
||||
metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{}
|
||||
@@ -186,20 +187,34 @@ func (m *messagesComponent) renderView() {
|
||||
if toolCall.State == "result" {
|
||||
key := m.cache.GenerateKey(message.Id,
|
||||
toolCall.ToolCallId,
|
||||
m.showToolResults,
|
||||
m.showToolDetails,
|
||||
layout.Current.Viewport.Width,
|
||||
)
|
||||
content, cached = m.cache.Get(key)
|
||||
if !cached {
|
||||
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
|
||||
content = renderToolInvocation(
|
||||
toolCall,
|
||||
result,
|
||||
metadata,
|
||||
m.showToolDetails,
|
||||
isLastToolInvocation,
|
||||
false,
|
||||
)
|
||||
m.cache.Set(key, content)
|
||||
}
|
||||
} else {
|
||||
// if the tool call isn't finished, never cache
|
||||
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
|
||||
// if the tool call isn't finished, don't cache
|
||||
content = renderToolInvocation(
|
||||
toolCall,
|
||||
result,
|
||||
metadata,
|
||||
m.showToolDetails,
|
||||
isLastToolInvocation,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
if previousBlockType != toolInvocationBlock {
|
||||
if previousBlockType != toolInvocationBlock && m.showToolDetails {
|
||||
blocks = append(blocks, "")
|
||||
}
|
||||
blocks = append(blocks, content)
|
||||
@@ -261,7 +276,7 @@ func (m *messagesComponent) header() string {
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderForeground(t.BackgroundElement()).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
Render(header)
|
||||
|
||||
@@ -273,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(
|
||||
@@ -311,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(app.Info.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())),
|
||||
)
|
||||
}
|
||||
@@ -373,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
|
||||
}
|
||||
@@ -389,26 +403,67 @@ func (m *messagesComponent) Reload() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func NewMessagesComponent(app *app.App) layout.ModelWithView {
|
||||
func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) {
|
||||
m.viewport.ViewUp()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) {
|
||||
m.viewport.ViewDown()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) {
|
||||
m.viewport.HalfViewUp()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
|
||||
m.viewport.HalfViewDown()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
|
||||
m.viewport.GotoTop()
|
||||
m.tail = false
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
|
||||
m.viewport.GotoBottom()
|
||||
m.tail = true
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *messagesComponent) ToolDetailsVisible() bool {
|
||||
return m.showToolDetails
|
||||
}
|
||||
|
||||
func NewMessagesComponent(app *app.App) MessagesComponent {
|
||||
customSpinner := spinner.Spinner{
|
||||
Frames: []string{" ", "┃", "┃"},
|
||||
FPS: time.Second / 3,
|
||||
}
|
||||
s := spinner.New(spinner.WithSpinner(customSpinner))
|
||||
|
||||
vp := viewport.New() //(0, 0)
|
||||
attachments := viewport.New() //(0, 0)
|
||||
vp.KeyMap.PageUp = messageKeys.PageUp
|
||||
vp.KeyMap.PageDown = messageKeys.PageDown
|
||||
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
|
||||
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
|
||||
vp := viewport.New()
|
||||
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,
|
||||
showToolResults: true,
|
||||
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
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"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/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type CompletionItem struct {
|
||||
title string
|
||||
Title string
|
||||
Value string
|
||||
}
|
||||
@@ -35,8 +35,7 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
|
||||
|
||||
if selected {
|
||||
itemStyle = itemStyle.
|
||||
Foreground(t.Primary()).
|
||||
Bold(true)
|
||||
Foreground(t.Primary())
|
||||
}
|
||||
|
||||
title := itemStyle.Render(
|
||||
@@ -62,6 +61,7 @@ type CompletionProvider interface {
|
||||
GetId() string
|
||||
GetEntry() CompletionItemI
|
||||
GetChildEntries(query string) ([]CompletionItemI, error)
|
||||
GetEmptyMessage() string
|
||||
}
|
||||
|
||||
type CompletionSelectedMsg struct {
|
||||
@@ -77,7 +77,8 @@ type CompletionDialogCompleteItemMsg struct {
|
||||
type CompletionDialogCloseMsg struct{}
|
||||
|
||||
type CompletionDialog interface {
|
||||
layout.ModelWithView
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
SetWidth(width int)
|
||||
IsEmpty() bool
|
||||
SetProvider(provider CompletionProvider)
|
||||
@@ -99,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"),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -110,37 +111,13 @@ func (c *completionDialogComponent) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
|
||||
value := c.pseudoSearchTextArea.Value()
|
||||
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is a command completion
|
||||
isCommand := c.completionProvider.GetId() == "commands"
|
||||
|
||||
return tea.Batch(
|
||||
util.CmdHandler(CompletionSelectedMsg{
|
||||
SearchString: value,
|
||||
CompletionValue: item.GetValue(),
|
||||
IsCommand: isCommand,
|
||||
}),
|
||||
c.close(),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) close() tea.Cmd {
|
||||
c.list.SetItems([]CompletionItemI{})
|
||||
c.pseudoSearchTextArea.Reset()
|
||||
c.pseudoSearchTextArea.Blur()
|
||||
|
||||
return util.CmdHandler(CompletionDialogCloseMsg{})
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case []CompletionItemI:
|
||||
c.list.SetItems(msg)
|
||||
case app.CompletionDialogTriggerdMsg:
|
||||
c.pseudoSearchTextArea.SetValue(msg.InitialValue)
|
||||
case tea.KeyMsg:
|
||||
if c.pseudoSearchTextArea.Focused() {
|
||||
if !key.Matches(msg, completionDialogKeys.Complete) {
|
||||
@@ -155,18 +132,19 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
if query != c.query {
|
||||
items, err := c.completionProvider.GetChildEntries(query)
|
||||
if err != nil {
|
||||
// status.Error(err.Error())
|
||||
}
|
||||
|
||||
c.list.SetItems(items)
|
||||
c.query = query
|
||||
cmd = func() tea.Msg {
|
||||
items, err := c.completionProvider.GetChildEntries(query)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
u, cmd := c.list.Update(msg)
|
||||
c.list = u.(list.List[CompletionItemI])
|
||||
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
@@ -186,14 +164,16 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
return c, tea.Batch(cmds...)
|
||||
} else {
|
||||
items, err := c.completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
// status.Error(err.Error())
|
||||
cmd := func() tea.Msg {
|
||||
items, err := c.completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
c.list.SetItems(items)
|
||||
c.pseudoSearchTextArea.SetValue(msg.String())
|
||||
return c, c.pseudoSearchTextArea.Focus()
|
||||
cmds = append(cmds, cmd)
|
||||
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
|
||||
return c, tea.Batch(cmds...)
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
c.width = msg.Width
|
||||
@@ -221,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())
|
||||
}
|
||||
@@ -243,29 +216,54 @@ func (c *completionDialogComponent) IsEmpty() bool {
|
||||
func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
|
||||
if c.completionProvider.GetId() != provider.GetId() {
|
||||
c.completionProvider = provider
|
||||
items, err := provider.GetChildEntries("")
|
||||
if err != nil {
|
||||
// status.Error(err.Error())
|
||||
}
|
||||
c.list.SetItems(items)
|
||||
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
|
||||
}
|
||||
|
||||
// Check if this is a command completion
|
||||
isCommand := c.completionProvider.GetId() == "commands"
|
||||
|
||||
return tea.Batch(
|
||||
util.CmdHandler(CompletionSelectedMsg{
|
||||
SearchString: value,
|
||||
CompletionValue: item.GetValue(),
|
||||
IsCommand: isCommand,
|
||||
}),
|
||||
c.close(),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) close() tea.Cmd {
|
||||
c.pseudoSearchTextArea.Reset()
|
||||
c.pseudoSearchTextArea.Blur()
|
||||
return util.CmdHandler(CompletionDialogCloseMsg{})
|
||||
}
|
||||
|
||||
func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
|
||||
ti := textarea.New()
|
||||
|
||||
items, err := completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
// status.Error(err.Error())
|
||||
}
|
||||
|
||||
li := list.NewListComponent(
|
||||
items,
|
||||
[]CompletionItemI{},
|
||||
7,
|
||||
"No matches",
|
||||
completionProvider.GetEmptyMessage(),
|
||||
false,
|
||||
)
|
||||
|
||||
go func() {
|
||||
items, err := completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
}
|
||||
li.SetItems(items)
|
||||
}()
|
||||
|
||||
return &completionDialogComponent{
|
||||
query: "",
|
||||
completionProvider: completionProvider,
|
||||
|
||||
@@ -3,9 +3,9 @@ package dialog
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
@@ -15,28 +15,9 @@ type helpDialog struct {
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
bindings []key.Binding
|
||||
commands []commands.Command
|
||||
}
|
||||
|
||||
// func (i bindingItem) Render(selected bool, width int) string {
|
||||
// t := theme.CurrentTheme()
|
||||
// baseStyle := styles.BaseStyle().
|
||||
// Width(width - 2).
|
||||
// Background(t.BackgroundElement())
|
||||
//
|
||||
// if selected {
|
||||
// baseStyle = baseStyle.
|
||||
// Background(t.Primary()).
|
||||
// Foreground(t.BackgroundElement()).
|
||||
// Bold(true)
|
||||
// } else {
|
||||
// baseStyle = baseStyle.
|
||||
// Foreground(t.Text())
|
||||
// }
|
||||
//
|
||||
// return baseStyle.Padding(0, 1).Render(i.binding.Help().Desc)
|
||||
// }
|
||||
|
||||
func (h *helpDialog) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
@@ -63,19 +44,24 @@ func (h *helpDialog) View() string {
|
||||
PaddingLeft(1).Background(t.BackgroundElement())
|
||||
|
||||
lines := []string{}
|
||||
for _, b := range h.bindings {
|
||||
content := keyStyle.Render(b.Help().Key)
|
||||
content += descStyle.Render(" " + b.Help().Desc)
|
||||
for i, key := range b.Keys() {
|
||||
if i == 0 {
|
||||
keyString := " (" + strings.ToUpper(key) + ")"
|
||||
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
|
||||
// spacer := strings.Repeat(" ", space)
|
||||
// content += descStyle.Render(spacer)
|
||||
content += descStyle.Render(keyString)
|
||||
}
|
||||
for _, b := range h.commands {
|
||||
// Only interested in slash commands
|
||||
if b.Trigger == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
content := keyStyle.Render("/" + b.Trigger)
|
||||
content += descStyle.Render(" " + b.Description)
|
||||
// for i, key := range b.Keybindings {
|
||||
// if i == 0 {
|
||||
// keyString := " (" + key.Key + ")"
|
||||
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
|
||||
// spacer := strings.Repeat(" ", space)
|
||||
// content += descStyle.Render(spacer)
|
||||
// content += descStyle.Render(keyString)
|
||||
// }
|
||||
// }
|
||||
|
||||
lines = append(lines, contentStyle.Render(content))
|
||||
}
|
||||
|
||||
@@ -94,9 +80,9 @@ type HelpDialog interface {
|
||||
layout.Modal
|
||||
}
|
||||
|
||||
func NewHelpDialog(bindings ...key.Binding) HelpDialog {
|
||||
func NewHelpDialog(commands []commands.Command) HelpDialog {
|
||||
return &helpDialog{
|
||||
bindings: bindings,
|
||||
modal: modal.New(),
|
||||
commands: commands,
|
||||
modal: modal.New(modal.WithTitle("Help")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
@@ -115,7 +114,7 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(
|
||||
state.ModelSelectedMsg{
|
||||
app.ModelSelectedMsg{
|
||||
Provider: m.provider,
|
||||
Model: models[m.selectedIdx],
|
||||
}),
|
||||
@@ -181,6 +180,7 @@ func (m *modelDialog) switchProvider(offset int) {
|
||||
|
||||
m.hScrollOffset = newOffset
|
||||
m.provider = m.availableProviders[m.hScrollOffset]
|
||||
m.modal.SetTitle(fmt.Sprintf("Select %s Model", m.provider.Name))
|
||||
m.setupModelsForProvider(m.provider.Id)
|
||||
}
|
||||
|
||||
@@ -190,14 +190,6 @@ func (m *modelDialog) View() string {
|
||||
Background(t.BackgroundElement()).
|
||||
Foreground(t.Text())
|
||||
|
||||
// Capitalize first letter of provider name
|
||||
title := baseStyle.
|
||||
Foreground(t.Primary()).
|
||||
Bold(true).
|
||||
Width(maxDialogWidth).
|
||||
Padding(0, 0, 1).
|
||||
Render(fmt.Sprintf("Select %s Model", m.provider.Name))
|
||||
|
||||
// Render visible models
|
||||
endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models))
|
||||
modelItems := make([]string, 0, endIdx-m.scrollOffset)
|
||||
@@ -218,8 +210,9 @@ func (m *modelDialog) View() string {
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
|
||||
baseStyle.
|
||||
Width(maxDialogWidth).
|
||||
Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
|
||||
scrollIndicator,
|
||||
)
|
||||
|
||||
@@ -239,12 +232,7 @@ func (m *modelDialog) getScrollIndicators(maxWidth int) string {
|
||||
}
|
||||
|
||||
if m.hScrollPossible {
|
||||
if m.hScrollOffset > 0 {
|
||||
indicator = "← " + indicator
|
||||
}
|
||||
if m.hScrollOffset < len(m.availableProviders)-1 {
|
||||
indicator += "→"
|
||||
}
|
||||
indicator = "← " + indicator + "→"
|
||||
}
|
||||
|
||||
if indicator == "" {
|
||||
@@ -314,6 +302,6 @@ func NewModelDialog(app *app.App) ModelDialog {
|
||||
hScrollOffset: 0,
|
||||
hScrollPossible: len(availableProviders) > 1,
|
||||
provider: availableProviders[0],
|
||||
modal: modal.New(),
|
||||
modal: modal.New(modal.WithTitle(fmt.Sprintf("Select %s Model", availableProviders[0].Name))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
@@ -30,7 +29,8 @@ type PermissionResponseMsg struct {
|
||||
|
||||
// PermissionDialogComponent interface for permission dialog component
|
||||
type PermissionDialogComponent interface {
|
||||
layout.ModelWithView
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
// SetPermissions(permission permission.PermissionRequest) tea.Cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
@@ -20,14 +19,12 @@ type SessionDialog interface {
|
||||
layout.Modal
|
||||
}
|
||||
|
||||
type sessionItem struct {
|
||||
session client.SessionInfo
|
||||
}
|
||||
type sessionItem client.SessionInfo
|
||||
|
||||
func (s sessionItem) Render(selected bool, width int) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle().
|
||||
Width(width - 2).
|
||||
Width(width - 4).
|
||||
Background(t.BackgroundElement())
|
||||
|
||||
if selected {
|
||||
@@ -40,7 +37,7 @@ func (s sessionItem) Render(selected bool, width int) string {
|
||||
Foreground(t.Text())
|
||||
}
|
||||
|
||||
return baseStyle.Padding(0, 1).Render(s.session.Title)
|
||||
return baseStyle.Padding(0, 1).Render(s.Title)
|
||||
}
|
||||
|
||||
type sessionDialog struct {
|
||||
@@ -61,15 +58,14 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.width = msg.Width
|
||||
s.height = msg.Height
|
||||
s.list.SetMaxWidth(layout.Current.Container.Width - 12)
|
||||
case tea.KeyMsg:
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if item, idx := s.list.GetSelectedItem(); idx >= 0 {
|
||||
selectedSession := item.session
|
||||
s.selectedSessionID = selectedSession.Id
|
||||
s.selectedSessionID = item.Id
|
||||
return s, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(state.SessionSelectedMsg(&selectedSession)),
|
||||
util.CmdHandler(app.SessionSelectedMsg(&item)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -95,7 +91,10 @@ func NewSessionDialog(app *app.App) SessionDialog {
|
||||
|
||||
var sessionItems []sessionItem
|
||||
for _, sess := range sessions {
|
||||
sessionItems = append(sessionItems, sessionItem{session: sess})
|
||||
if sess.ParentID != nil {
|
||||
continue
|
||||
}
|
||||
sessionItems = append(sessionItems, sessionItem(sess))
|
||||
}
|
||||
|
||||
list := list.NewListComponent(
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
// ThemeChangedMsg is sent when the theme is changed
|
||||
type ThemeChangedMsg struct {
|
||||
// ThemeSelectedMsg is sent when the theme is changed
|
||||
type ThemeSelectedMsg struct {
|
||||
ThemeName string
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return t, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
|
||||
util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
)
|
||||
|
||||
type ListItem interface {
|
||||
@@ -12,12 +12,14 @@ type ListItem interface {
|
||||
}
|
||||
|
||||
type List[T ListItem] interface {
|
||||
layout.ModelWithView
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
SetMaxWidth(maxWidth int)
|
||||
GetSelectedItem() (item T, idx int)
|
||||
SetItems(items []T)
|
||||
GetItems() []T
|
||||
SetSelectedIndex(idx int)
|
||||
SetEmptyMessage(msg string)
|
||||
IsEmpty() bool
|
||||
}
|
||||
|
||||
@@ -100,6 +102,10 @@ func (c *listComponent[T]) GetItems() []T {
|
||||
return c.items
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) SetEmptyMessage(msg string) {
|
||||
c.fallbackMsg = msg
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) IsEmpty() bool {
|
||||
return len(c.items) == 0
|
||||
}
|
||||
@@ -143,7 +149,7 @@ func (c *listComponent[T]) View() string {
|
||||
listItems = append(listItems, title)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, listItems...)
|
||||
return strings.Join(listItems, "\n")
|
||||
}
|
||||
|
||||
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package modal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
@@ -66,6 +68,10 @@ func New(opts ...ModalOption) *Modal {
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Modal) SetTitle(title string) {
|
||||
m.title = title
|
||||
}
|
||||
|
||||
// Render renders the modal centered on the screen
|
||||
func (m *Modal) Render(contentView string, background string) string {
|
||||
t := theme.CurrentTheme()
|
||||
@@ -95,15 +101,20 @@ func (m *Modal) Render(contentView string, background string) string {
|
||||
titleStyle := baseStyle.
|
||||
Foreground(t.Primary()).
|
||||
Bold(true).
|
||||
Width(innerWidth).
|
||||
Padding(0, 1)
|
||||
|
||||
titleView := titleStyle.Render(m.title)
|
||||
finalContent = lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
titleView,
|
||||
contentView,
|
||||
)
|
||||
escStyle := baseStyle.Foreground(t.TextMuted()).Bold(false)
|
||||
escText := escStyle.Render("esc")
|
||||
|
||||
// Calculate position for esc text
|
||||
titleWidth := lipgloss.Width(m.title)
|
||||
escWidth := lipgloss.Width(escText)
|
||||
spacesNeeded := max(0, innerWidth-titleWidth-escWidth-3)
|
||||
spacer := strings.Repeat(" ", spacesNeeded)
|
||||
titleLine := m.title + spacer + escText
|
||||
titleLine = titleStyle.Render(titleLine)
|
||||
|
||||
finalContent = strings.Join([]string{titleLine, contentView}, "\n") + "\n"
|
||||
} else {
|
||||
finalContent = contentView
|
||||
}
|
||||
@@ -112,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).
|
||||
@@ -139,5 +143,7 @@ func (m *Modal) Render(contentView string, background string) string {
|
||||
row,
|
||||
modalView,
|
||||
background,
|
||||
layout.WithOverlayBorder(),
|
||||
layout.WithOverlayBorderColor(t.Primary()),
|
||||
)
|
||||
}
|
||||
|
||||
+12
-8
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,13 +7,13 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type StatusComponent interface {
|
||||
layout.ModelWithView
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
}
|
||||
|
||||
type statusComponent struct {
|
||||
@@ -34,14 +34,14 @@ func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func logo() string {
|
||||
func (m statusComponent) logo() string {
|
||||
t := theme.CurrentTheme()
|
||||
base := lipgloss.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render
|
||||
emphasis := lipgloss.NewStyle().Bold(true).Background(t.BackgroundElement()).Foreground(t.Text()).Render
|
||||
|
||||
open := base("open")
|
||||
code := emphasis("code ")
|
||||
version := base(app.Info.Version)
|
||||
version := base(m.app.Version)
|
||||
return styles.Padded().
|
||||
Background(t.BackgroundElement()).
|
||||
Render(open + code + version)
|
||||
@@ -84,12 +84,12 @@ func (m statusComponent) View() string {
|
||||
Render("")
|
||||
}
|
||||
|
||||
logo := logo()
|
||||
logo := m.logo()
|
||||
|
||||
cwd := styles.Padded().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundSubtle()).
|
||||
Render(app.Info.Path.Cwd)
|
||||
Render(m.app.Info.Path.Cwd)
|
||||
|
||||
sessionInfo := ""
|
||||
if m.app.Session.Id != "" {
|
||||
@@ -102,7 +102,11 @@ func (m statusComponent) View() string {
|
||||
cost += message.Metadata.Assistant.Cost
|
||||
usage := message.Metadata.Assistant.Tokens
|
||||
if usage.Output > 0 {
|
||||
tokens = (usage.Input + usage.Output + usage.Reasoning)
|
||||
tokens = (usage.Input +
|
||||
usage.Cache.Write +
|
||||
usage.Cache.Read +
|
||||
usage.Output +
|
||||
usage.Reasoning)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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...,
|
||||
)
|
||||
}
|
||||
@@ -7,27 +7,31 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
type State struct {
|
||||
Theme string `toml:"theme"`
|
||||
Provider string `toml:"provider"`
|
||||
Model string `toml:"model"`
|
||||
}
|
||||
|
||||
// NewConfig creates a new Config instance with default values.
|
||||
// This can be useful for initializing a new configuration file.
|
||||
func NewConfig(theme, provider, model string) *Config {
|
||||
return &Config{
|
||||
Theme: theme,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
func NewState() *State {
|
||||
return &State{
|
||||
Theme: "opencode",
|
||||
}
|
||||
}
|
||||
|
||||
// SaveConfig writes the provided Config struct to the specified TOML file.
|
||||
func MergeState(state *State, config *client.ConfigInfo) *client.ConfigInfo {
|
||||
if config.Theme == nil {
|
||||
config.Theme = &state.Theme
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// SaveState writes the provided Config struct to the specified TOML file.
|
||||
// It will create the file if it doesn't exist, or overwrite it if it does.
|
||||
func SaveConfig(filePath string, config *Config) error {
|
||||
func SaveState(filePath string, state *State) error {
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create/open config file %s: %w", filePath, err)
|
||||
@@ -35,31 +39,27 @@ func SaveConfig(filePath string, config *Config) error {
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
|
||||
encoder := toml.NewEncoder(writer)
|
||||
if err := encoder.Encode(config); err != nil {
|
||||
return fmt.Errorf("failed to encode config to TOML file %s: %w", filePath, err)
|
||||
if err := encoder.Encode(state); err != nil {
|
||||
return fmt.Errorf("failed to encode state to TOML file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
if err := writer.Flush(); err != nil {
|
||||
return fmt.Errorf("failed to flush writer for config file %s: %w", filePath, err)
|
||||
return fmt.Errorf("failed to flush writer for state file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
slog.Debug("Configuration saved to file", "file", filePath)
|
||||
slog.Debug("State saved to file", "file", filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfig reads a Config struct from the specified TOML file.
|
||||
// It returns a pointer to the Config struct and an error if any issues occur.
|
||||
func LoadConfig(filePath string) (*Config, error) {
|
||||
var config Config
|
||||
|
||||
if _, err := toml.DecodeFile(filePath, &config); err != nil {
|
||||
// LoadState loads the state from the specified TOML file.
|
||||
// It returns a pointer to the State struct and an error if any issues occur.
|
||||
func LoadState(filePath string) (*State, error) {
|
||||
var state State
|
||||
if _, err := toml.DecodeFile(filePath, &state); err != nil {
|
||||
if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
|
||||
return nil, fmt.Errorf("config file not found at %s: %w", filePath, statErr)
|
||||
return nil, fmt.Errorf("state file not found at %s: %w", filePath, statErr)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
@@ -6,20 +6,12 @@ import (
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type ModelWithView interface {
|
||||
type Container interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
}
|
||||
|
||||
type Container interface {
|
||||
ModelWithView
|
||||
Sizeable
|
||||
Focus()
|
||||
Blur()
|
||||
MaxWidth() int
|
||||
Alignment() lipgloss.Position
|
||||
GetPosition() (x, y int)
|
||||
GetContent() ModelWithView
|
||||
Focusable
|
||||
Alignable
|
||||
}
|
||||
|
||||
type container struct {
|
||||
@@ -28,7 +20,7 @@ type container struct {
|
||||
x int
|
||||
y int
|
||||
|
||||
content ModelWithView
|
||||
content tea.ViewModel
|
||||
|
||||
paddingTop int
|
||||
paddingRight int
|
||||
@@ -48,13 +40,19 @@ type container struct {
|
||||
}
|
||||
|
||||
func (c *container) Init() tea.Cmd {
|
||||
return c.content.Init()
|
||||
if model, ok := c.content.(tea.Model); ok {
|
||||
return model.Init()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
u, cmd := c.content.Update(msg)
|
||||
c.content = u.(ModelWithView)
|
||||
return c, cmd
|
||||
if model, ok := c.content.(tea.Model); ok {
|
||||
u, cmd := model.Update(msg)
|
||||
c.content = u.(tea.ViewModel)
|
||||
return c, cmd
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *container) View() string {
|
||||
@@ -156,21 +154,28 @@ func (c *container) Alignment() lipgloss.Position {
|
||||
}
|
||||
|
||||
// Focus sets the container as focused
|
||||
func (c *container) Focus() {
|
||||
func (c *container) Focus() tea.Cmd {
|
||||
c.focused = true
|
||||
// Pass focus to content if it supports it
|
||||
if focusable, ok := c.content.(interface{ Focus() }); ok {
|
||||
focusable.Focus()
|
||||
if focusable, ok := c.content.(Focusable); ok {
|
||||
return focusable.Focus()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Blur removes focus from the container
|
||||
func (c *container) Blur() {
|
||||
func (c *container) Blur() tea.Cmd {
|
||||
c.focused = false
|
||||
// Remove focus from content if it supports it
|
||||
if blurable, ok := c.content.(interface{ Blur() }); ok {
|
||||
blurable.Blur()
|
||||
if blurable, ok := c.content.(Focusable); ok {
|
||||
return blurable.Blur()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *container) IsFocused() bool {
|
||||
if blurable, ok := c.content.(Focusable); ok {
|
||||
return blurable.IsFocused()
|
||||
}
|
||||
return c.focused
|
||||
}
|
||||
|
||||
// GetPosition returns the x, y coordinates of the container
|
||||
@@ -178,14 +183,14 @@ func (c *container) GetPosition() (x, y int) {
|
||||
return c.x, c.y
|
||||
}
|
||||
|
||||
// GetContent returns the content of the container
|
||||
func (c *container) GetContent() ModelWithView {
|
||||
return c.content
|
||||
func (c *container) SetPosition(x, y int) {
|
||||
c.x = x
|
||||
c.y = y
|
||||
}
|
||||
|
||||
type ContainerOption func(*container)
|
||||
|
||||
func NewContainer(content ModelWithView, options ...ContainerOption) Container {
|
||||
func NewContainer(content tea.ViewModel, options ...ContainerOption) Container {
|
||||
c := &container{
|
||||
content: content,
|
||||
borderStyle: lipgloss.NormalBorder(),
|
||||
|
||||
@@ -13,22 +13,22 @@ const (
|
||||
FlexDirectionVertical
|
||||
)
|
||||
|
||||
type FlexPaneSize struct {
|
||||
type FlexChildSize struct {
|
||||
Fixed bool
|
||||
Size int
|
||||
}
|
||||
|
||||
var FlexPaneSizeGrow = FlexPaneSize{Fixed: false}
|
||||
var FlexChildSizeGrow = FlexChildSize{Fixed: false}
|
||||
|
||||
func FlexPaneSizeFixed(size int) FlexPaneSize {
|
||||
return FlexPaneSize{Fixed: true, Size: size}
|
||||
func FlexChildSizeFixed(size int) FlexChildSize {
|
||||
return FlexChildSize{Fixed: true, Size: size}
|
||||
}
|
||||
|
||||
type FlexLayout interface {
|
||||
ModelWithView
|
||||
tea.ViewModel
|
||||
Sizeable
|
||||
SetPanes(panes []Container) tea.Cmd
|
||||
SetPaneSizes(sizes []FlexPaneSize) tea.Cmd
|
||||
SetChildren(panes []tea.ViewModel) tea.Cmd
|
||||
SetSizes(sizes []FlexChildSize) tea.Cmd
|
||||
SetDirection(direction FlexDirection) tea.Cmd
|
||||
}
|
||||
|
||||
@@ -36,94 +36,69 @@ type flexLayout struct {
|
||||
width int
|
||||
height int
|
||||
direction FlexDirection
|
||||
panes []Container
|
||||
sizes []FlexPaneSize
|
||||
children []tea.ViewModel
|
||||
sizes []FlexChildSize
|
||||
}
|
||||
|
||||
type FlexLayoutOption func(*flexLayout)
|
||||
|
||||
func (f *flexLayout) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for _, pane := range f.panes {
|
||||
if pane != nil {
|
||||
cmds = append(cmds, pane.Init())
|
||||
}
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (f *flexLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
return f, f.SetSize(msg.Width, msg.Height)
|
||||
}
|
||||
|
||||
for i, pane := range f.panes {
|
||||
if pane != nil {
|
||||
u, cmd := pane.Update(msg)
|
||||
f.panes[i] = u.(Container)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return f, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (f *flexLayout) View() string {
|
||||
if len(f.panes) == 0 {
|
||||
if len(f.children) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
views := make([]string, 0, len(f.panes))
|
||||
for i, pane := range f.panes {
|
||||
if pane == nil {
|
||||
views := make([]string, 0, len(f.children))
|
||||
for i, child := range f.children {
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var paneWidth, paneHeight int
|
||||
alignment := lipgloss.Center
|
||||
if alignable, ok := child.(Alignable); ok {
|
||||
alignment = alignable.Alignment()
|
||||
}
|
||||
var childWidth, childHeight int
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
paneWidth, paneHeight = f.calculatePaneSize(i)
|
||||
childWidth, childHeight = f.calculateChildSize(i)
|
||||
view := lipgloss.PlaceHorizontal(
|
||||
paneWidth,
|
||||
pane.Alignment(),
|
||||
pane.View(),
|
||||
childWidth,
|
||||
alignment,
|
||||
child.View(),
|
||||
// TODO: make configurable WithBackgroundStyle
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
views = append(views, view)
|
||||
} else {
|
||||
paneWidth, paneHeight = f.calculatePaneSize(i)
|
||||
childWidth, childHeight = f.calculateChildSize(i)
|
||||
view := lipgloss.Place(
|
||||
f.width,
|
||||
paneHeight,
|
||||
childHeight,
|
||||
lipgloss.Center,
|
||||
pane.Alignment(),
|
||||
pane.View(),
|
||||
alignment,
|
||||
child.View(),
|
||||
// TODO: make configurable WithBackgroundStyle
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
views = append(views, view)
|
||||
}
|
||||
}
|
||||
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
return lipgloss.JoinHorizontal(lipgloss.Center, views...)
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Center, views...)
|
||||
}
|
||||
|
||||
func (f *flexLayout) calculatePaneSize(index int) (width, height int) {
|
||||
if index >= len(f.panes) {
|
||||
func (f *flexLayout) calculateChildSize(index int) (width, height int) {
|
||||
if index >= len(f.children) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
totalFixed := 0
|
||||
flexCount := 0
|
||||
|
||||
for i, pane := range f.panes {
|
||||
if pane == nil {
|
||||
for i, child := range f.children {
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
if i < len(f.sizes) && f.sizes[i].Fixed {
|
||||
@@ -165,9 +140,13 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
currentX, currentY := 0, 0
|
||||
|
||||
for i, pane := range f.panes {
|
||||
if pane != nil {
|
||||
paneWidth, paneHeight := f.calculatePaneSize(i)
|
||||
for i, child := range f.children {
|
||||
if child != nil {
|
||||
paneWidth, paneHeight := f.calculateChildSize(i)
|
||||
alignment := lipgloss.Center
|
||||
if alignable, ok := child.(Alignable); ok {
|
||||
alignment = alignable.Alignment()
|
||||
}
|
||||
|
||||
// Calculate actual position based on alignment
|
||||
actualX, actualY := currentX, currentY
|
||||
@@ -179,11 +158,13 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
|
||||
} else {
|
||||
// In vertical layout, horizontal alignment affects X position
|
||||
contentWidth := paneWidth
|
||||
if pane.MaxWidth() > 0 && contentWidth > pane.MaxWidth() {
|
||||
contentWidth = pane.MaxWidth()
|
||||
if alignable, ok := child.(Alignable); ok {
|
||||
if alignable.MaxWidth() > 0 && contentWidth > alignable.MaxWidth() {
|
||||
contentWidth = alignable.MaxWidth()
|
||||
}
|
||||
}
|
||||
|
||||
switch pane.Alignment() {
|
||||
switch alignment {
|
||||
case lipgloss.Center:
|
||||
actualX = (f.width - contentWidth) / 2
|
||||
case lipgloss.Right:
|
||||
@@ -193,14 +174,15 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// Set position if the pane is a *container
|
||||
if c, ok := pane.(*container); ok {
|
||||
c.x = actualX
|
||||
c.y = actualY
|
||||
// Set position if the pane is Alignable
|
||||
if c, ok := child.(Alignable); ok {
|
||||
c.SetPosition(actualX, actualY)
|
||||
}
|
||||
|
||||
cmd := pane.SetSize(paneWidth, paneHeight)
|
||||
cmds = append(cmds, cmd)
|
||||
if sizeable, ok := child.(Sizeable); ok {
|
||||
cmd := sizeable.SetSize(paneWidth, paneHeight)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
// Update position for next pane
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
@@ -217,15 +199,15 @@ func (f *flexLayout) GetSize() (int, int) {
|
||||
return f.width, f.height
|
||||
}
|
||||
|
||||
func (f *flexLayout) SetPanes(panes []Container) tea.Cmd {
|
||||
f.panes = panes
|
||||
func (f *flexLayout) SetChildren(children []tea.ViewModel) tea.Cmd {
|
||||
f.children = children
|
||||
if f.width > 0 && f.height > 0 {
|
||||
return f.SetSize(f.width, f.height)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *flexLayout) SetPaneSizes(sizes []FlexPaneSize) tea.Cmd {
|
||||
func (f *flexLayout) SetSizes(sizes []FlexChildSize) tea.Cmd {
|
||||
f.sizes = sizes
|
||||
if f.width > 0 && f.height > 0 {
|
||||
return f.SetSize(f.width, f.height)
|
||||
@@ -241,11 +223,11 @@ func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewFlexLayout(options ...FlexLayoutOption) FlexLayout {
|
||||
func NewFlexLayout(children []tea.ViewModel, options ...FlexLayoutOption) FlexLayout {
|
||||
layout := &flexLayout{
|
||||
children: children,
|
||||
direction: FlexDirectionHorizontal,
|
||||
panes: []Container{},
|
||||
sizes: []FlexPaneSize{},
|
||||
sizes: []FlexChildSize{},
|
||||
}
|
||||
for _, option := range options {
|
||||
option(layout)
|
||||
@@ -259,13 +241,13 @@ func WithDirection(direction FlexDirection) FlexLayoutOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithPanes(panes ...Container) FlexLayoutOption {
|
||||
func WithChildren(children ...tea.ViewModel) FlexLayoutOption {
|
||||
return func(f *flexLayout) {
|
||||
f.panes = panes
|
||||
f.children = children
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaneSizes(sizes ...FlexPaneSize) FlexLayoutOption {
|
||||
func WithSizes(sizes ...FlexChildSize) FlexLayoutOption {
|
||||
return func(f *flexLayout) {
|
||||
f.sizes = sizes
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
)
|
||||
|
||||
var Current *LayoutInfo
|
||||
@@ -45,6 +46,13 @@ type Sizeable interface {
|
||||
GetSize() (int, int)
|
||||
}
|
||||
|
||||
type Alignable interface {
|
||||
MaxWidth() int
|
||||
Alignment() lipgloss.Position
|
||||
SetPosition(x, y int)
|
||||
GetPosition() (x, y int)
|
||||
}
|
||||
|
||||
func KeyMapToSlice(t any) (bindings []key.Binding) {
|
||||
typ := reflect.TypeOf(t)
|
||||
if typ.Kind() != reflect.Struct {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
chAnsi "github.com/charmbracelet/x/ansi"
|
||||
"github.com/muesli/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
@@ -23,29 +27,58 @@ func getLines(s string) (lines []string, widest int) {
|
||||
return lines, widest
|
||||
}
|
||||
|
||||
// overlayOptions holds configuration for overlay rendering
|
||||
type overlayOptions struct {
|
||||
whitespace *whitespace
|
||||
border bool
|
||||
borderColor *compat.AdaptiveColor
|
||||
}
|
||||
|
||||
// OverlayOption sets options for overlay rendering
|
||||
type OverlayOption func(*overlayOptions)
|
||||
|
||||
// PlaceOverlay places fg on top of bg.
|
||||
func PlaceOverlay(
|
||||
x, y int,
|
||||
fg, bg string,
|
||||
opts ...WhitespaceOption,
|
||||
opts ...OverlayOption,
|
||||
) string {
|
||||
fgLines, fgWidth := getLines(fg)
|
||||
bgLines, bgWidth := getLines(bg)
|
||||
bgHeight := len(bgLines)
|
||||
fgHeight := len(fgLines)
|
||||
|
||||
if fgWidth >= bgWidth && fgHeight >= bgHeight {
|
||||
// FIXME: return fg or bg?
|
||||
return fg
|
||||
// Parse options
|
||||
options := &overlayOptions{
|
||||
whitespace: &whitespace{},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
// TODO: allow placement outside of the bg box?
|
||||
x = util.Clamp(x, 0, bgWidth-fgWidth)
|
||||
y = util.Clamp(y, 0, bgHeight-fgHeight)
|
||||
// Adjust for borders if enabled
|
||||
if options.border {
|
||||
// Add space for left and right borders
|
||||
adjustedFgWidth := fgWidth + 2
|
||||
// Adjust placement to account for borders
|
||||
x = util.Clamp(x, 0, bgWidth-adjustedFgWidth)
|
||||
y = util.Clamp(y, 0, bgHeight-fgHeight)
|
||||
|
||||
ws := &whitespace{}
|
||||
for _, opt := range opts {
|
||||
opt(ws)
|
||||
// Pad all foreground lines to the same width for consistent borders
|
||||
for i := range fgLines {
|
||||
lineWidth := ansi.PrintableRuneWidth(fgLines[i])
|
||||
if lineWidth < fgWidth {
|
||||
fgLines[i] += strings.Repeat(" ", fgWidth-lineWidth)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if fgWidth >= bgWidth && fgHeight >= bgHeight {
|
||||
// FIXME: return fg or bg?
|
||||
return fg
|
||||
}
|
||||
// TODO: allow placement outside of the bg box?
|
||||
x = util.Clamp(x, 0, bgWidth-fgWidth)
|
||||
y = util.Clamp(y, 0, bgHeight-fgHeight)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
@@ -59,25 +92,62 @@ func PlaceOverlay(
|
||||
}
|
||||
|
||||
pos := 0
|
||||
|
||||
// Handle left side of the line up to the overlay
|
||||
if x > 0 {
|
||||
left := truncate.String(bgLine, uint(x))
|
||||
pos = ansi.PrintableRuneWidth(left)
|
||||
b.WriteString(left)
|
||||
if pos < x {
|
||||
b.WriteString(ws.render(x - pos))
|
||||
b.WriteString(options.whitespace.render(x - pos))
|
||||
pos = x
|
||||
}
|
||||
}
|
||||
|
||||
fgLine := fgLines[i-y]
|
||||
b.WriteString(fgLine)
|
||||
pos += ansi.PrintableRuneWidth(fgLine)
|
||||
// Render the overlay content with optional borders
|
||||
if options.border {
|
||||
// Get the foreground line
|
||||
fgLine := fgLines[i-y]
|
||||
fgLineWidth := ansi.PrintableRuneWidth(fgLine)
|
||||
|
||||
// Extract the styles at the border positions
|
||||
leftStyle := getStyleAtPosition(bgLine, pos)
|
||||
rightStyle := getStyleAtPosition(bgLine, pos + 1 + fgLineWidth)
|
||||
|
||||
// Left border - combine background from original with border foreground
|
||||
leftSeq := combineStyles(leftStyle, options.borderColor)
|
||||
if leftSeq != "" {
|
||||
b.WriteString(leftSeq)
|
||||
}
|
||||
b.WriteString("┃")
|
||||
b.WriteString("\x1b[0m") // Reset all styles
|
||||
pos++
|
||||
|
||||
// Content
|
||||
b.WriteString(fgLine)
|
||||
pos += fgLineWidth
|
||||
|
||||
// Right border - combine background from original with border foreground
|
||||
rightSeq := combineStyles(rightStyle, options.borderColor)
|
||||
if rightSeq != "" {
|
||||
b.WriteString(rightSeq)
|
||||
}
|
||||
b.WriteString("┃")
|
||||
b.WriteString("\x1b[0m") // Reset all styles
|
||||
pos++
|
||||
} else {
|
||||
// No border, just render the content
|
||||
fgLine := fgLines[i-y]
|
||||
b.WriteString(fgLine)
|
||||
pos += ansi.PrintableRuneWidth(fgLine)
|
||||
}
|
||||
|
||||
// Handle right side of the line after the overlay
|
||||
right := cutLeft(bgLine, pos)
|
||||
bgWidth := ansi.PrintableRuneWidth(bgLine)
|
||||
rightWidth := ansi.PrintableRuneWidth(right)
|
||||
if rightWidth <= bgWidth-pos {
|
||||
b.WriteString(ws.render(bgWidth - rightWidth - pos))
|
||||
b.WriteString(options.whitespace.render(bgWidth - rightWidth - pos))
|
||||
}
|
||||
|
||||
b.WriteString(right)
|
||||
@@ -92,6 +162,146 @@ func cutLeft(s string, cutWidth int) string {
|
||||
return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
|
||||
}
|
||||
|
||||
// ansiStyle represents parsed ANSI style attributes
|
||||
type ansiStyle struct {
|
||||
fgColor string
|
||||
bgColor string
|
||||
attrs []string
|
||||
}
|
||||
|
||||
// parseANSISequence parses an ANSI escape sequence into its components
|
||||
func parseANSISequence(seq string) ansiStyle {
|
||||
style := ansiStyle{}
|
||||
|
||||
// Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
|
||||
if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
|
||||
return style
|
||||
}
|
||||
|
||||
params := seq[2 : len(seq)-1]
|
||||
if params == "" {
|
||||
return style
|
||||
}
|
||||
|
||||
parts := strings.Split(params, ";")
|
||||
i := 0
|
||||
for i < len(parts) {
|
||||
switch parts[i] {
|
||||
case "0": // Reset
|
||||
style = ansiStyle{}
|
||||
case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
|
||||
style.attrs = append(style.attrs, parts[i])
|
||||
case "38": // Foreground color
|
||||
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
|
||||
// 256 color mode
|
||||
style.fgColor = strings.Join(parts[i:i+3], ";")
|
||||
i += 2
|
||||
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
|
||||
// RGB color mode
|
||||
style.fgColor = strings.Join(parts[i:i+5], ";")
|
||||
i += 4
|
||||
}
|
||||
case "48": // Background color
|
||||
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
|
||||
// 256 color mode
|
||||
style.bgColor = strings.Join(parts[i:i+3], ";")
|
||||
i += 2
|
||||
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
|
||||
// RGB color mode
|
||||
style.bgColor = strings.Join(parts[i:i+5], ";")
|
||||
i += 4
|
||||
}
|
||||
case "30", "31", "32", "33", "34", "35", "36", "37": // Standard foreground colors
|
||||
style.fgColor = parts[i]
|
||||
case "40", "41", "42", "43", "44", "45", "46", "47": // Standard background colors
|
||||
style.bgColor = parts[i]
|
||||
case "90", "91", "92", "93", "94", "95", "96", "97": // Bright foreground colors
|
||||
style.fgColor = parts[i]
|
||||
case "100", "101", "102", "103", "104", "105", "106", "107": // Bright background colors
|
||||
style.bgColor = parts[i]
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return style
|
||||
}
|
||||
|
||||
// combineStyles creates an ANSI sequence that combines background from one style with foreground from another
|
||||
func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
|
||||
if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
// Add attributes
|
||||
parts = append(parts, bgStyle.attrs...)
|
||||
|
||||
// Add background color from the original style
|
||||
if bgStyle.bgColor != "" {
|
||||
parts = append(parts, bgStyle.bgColor)
|
||||
}
|
||||
|
||||
// Add foreground color if specified
|
||||
if fgColor != nil {
|
||||
// Use the light color (could be improved to detect terminal background)
|
||||
color := (*fgColor).Light
|
||||
|
||||
// Use RGBA to get color components
|
||||
r, g, b, _ := color.RGBA()
|
||||
// RGBA returns 16-bit values, we need 8-bit
|
||||
parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
|
||||
}
|
||||
|
||||
// getStyleAtPosition extracts the active ANSI style at a given visual position
|
||||
func getStyleAtPosition(s string, targetPos int) ansiStyle {
|
||||
// ANSI escape sequence regex
|
||||
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
|
||||
visualPos := 0
|
||||
currentStyle := ansiStyle{}
|
||||
|
||||
i := 0
|
||||
for i < len(s) && visualPos <= targetPos {
|
||||
// Check if we're at an ANSI escape sequence
|
||||
if match := ansiRegex.FindStringIndex(s[i:]); match != nil && match[0] == 0 {
|
||||
// Found an ANSI sequence at current position
|
||||
seq := s[i : i+match[1]]
|
||||
parsedStyle := parseANSISequence(seq)
|
||||
|
||||
// Update current style (merge with existing)
|
||||
if parsedStyle.fgColor != "" {
|
||||
currentStyle.fgColor = parsedStyle.fgColor
|
||||
}
|
||||
if parsedStyle.bgColor != "" {
|
||||
currentStyle.bgColor = parsedStyle.bgColor
|
||||
}
|
||||
if len(parsedStyle.attrs) > 0 {
|
||||
currentStyle.attrs = parsedStyle.attrs
|
||||
}
|
||||
|
||||
i += match[1]
|
||||
} else if i < len(s) {
|
||||
// Regular character
|
||||
if visualPos == targetPos {
|
||||
return currentStyle
|
||||
}
|
||||
_, size := utf8.DecodeRuneInString(s[i:])
|
||||
i += size
|
||||
visualPos++
|
||||
}
|
||||
}
|
||||
|
||||
return currentStyle
|
||||
}
|
||||
|
||||
type whitespace struct {
|
||||
style termenv.Style
|
||||
chars string
|
||||
@@ -129,3 +339,26 @@ func (w whitespace) render(width int) string {
|
||||
|
||||
// WhitespaceOption sets a styling rule for rendering whitespace.
|
||||
type WhitespaceOption func(*whitespace)
|
||||
|
||||
// WithWhitespace sets whitespace options for the overlay
|
||||
func WithWhitespace(opts ...WhitespaceOption) OverlayOption {
|
||||
return func(o *overlayOptions) {
|
||||
for _, opt := range opts {
|
||||
opt(o.whitespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithOverlayBorder enables border rendering for the overlay
|
||||
func WithOverlayBorder() OverlayOption {
|
||||
return func(o *overlayOptions) {
|
||||
o.border = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithOverlayBorderColor sets the border color for the overlay
|
||||
func WithOverlayBorderColor(color compat.AdaptiveColor) OverlayOption {
|
||||
return func(o *overlayOptions) {
|
||||
o.borderColor = &color
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/completions"
|
||||
"github.com/sst/opencode/internal/components/chat"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
var ChatPage PageID = "chat"
|
||||
|
||||
type chatPage struct {
|
||||
app *app.App
|
||||
editor layout.Container
|
||||
messages layout.Container
|
||||
layout layout.FlexLayout
|
||||
completionDialog dialog.CompletionDialog
|
||||
completionManager *completions.CompletionManager
|
||||
showCompletionDialog bool
|
||||
}
|
||||
|
||||
type ChatKeyMap struct {
|
||||
Cancel key.Binding
|
||||
ToggleTools key.Binding
|
||||
ShowCompletionDialog key.Binding
|
||||
}
|
||||
|
||||
var keyMap = ChatKeyMap{
|
||||
Cancel: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "cancel"),
|
||||
),
|
||||
ToggleTools: key.NewBinding(
|
||||
key.WithKeys("ctrl+h"),
|
||||
key.WithHelp("ctrl+h", "toggle tools"),
|
||||
),
|
||||
ShowCompletionDialog: key.NewBinding(
|
||||
key.WithKeys("/"),
|
||||
key.WithHelp("/", "Complete"),
|
||||
),
|
||||
}
|
||||
|
||||
func (p *chatPage) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{
|
||||
p.layout.Init(),
|
||||
}
|
||||
cmds = append(cmds, p.completionDialog.Init())
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
cmd := p.layout.SetSize(msg.Width, msg.Height)
|
||||
cmds = append(cmds, cmd)
|
||||
case chat.SendMsg:
|
||||
p.showCompletionDialog = false
|
||||
cmd := p.sendMessage(msg.Text, msg.Attachments)
|
||||
if cmd != nil {
|
||||
return p, cmd
|
||||
}
|
||||
case dialog.CompletionDialogCloseMsg:
|
||||
p.showCompletionDialog = false
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
_, cmd := p.editor.Update(msg)
|
||||
if cmd != nil {
|
||||
return p, cmd
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, keyMap.ShowCompletionDialog):
|
||||
p.showCompletionDialog = true
|
||||
// Continue sending keys to layout->chat
|
||||
case key.Matches(msg, keyMap.Cancel):
|
||||
if p.app.Session.Id != "" {
|
||||
// Cancel the current session's generation process
|
||||
// This allows users to interrupt long-running operations
|
||||
p.app.Cancel(context.Background(), p.app.Session.Id)
|
||||
return p, nil
|
||||
}
|
||||
case key.Matches(msg, keyMap.ToggleTools):
|
||||
return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
|
||||
}
|
||||
}
|
||||
|
||||
if p.showCompletionDialog {
|
||||
// Get the current text from the editor to determine which provider to use
|
||||
editorModel := p.editor.GetContent().(interface{ GetValue() string })
|
||||
currentInput := editorModel.GetValue()
|
||||
|
||||
provider := p.completionManager.GetProvider(currentInput)
|
||||
p.completionDialog.SetProvider(provider)
|
||||
|
||||
context, contextCmd := p.completionDialog.Update(msg)
|
||||
p.completionDialog = context.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, contextCmd)
|
||||
|
||||
// Doesn't forward event if enter key is pressed and there are completions
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
if keyMsg.String() == "enter" { // && !p.completionDialog.IsEmpty() {
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u, cmd := p.layout.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
p.layout = u.(layout.FlexLayout)
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *chatPage) sendMessage(text string, attachments []app.Attachment) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
cmd := p.app.SendChatMessage(context.Background(), text, attachments)
|
||||
cmds = append(cmds, cmd)
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *chatPage) SetSize(width, height int) tea.Cmd {
|
||||
return p.layout.SetSize(width, height)
|
||||
}
|
||||
|
||||
func (p *chatPage) GetSize() (int, int) {
|
||||
return p.layout.GetSize()
|
||||
}
|
||||
|
||||
func (p *chatPage) View() string {
|
||||
layoutView := p.layout.View()
|
||||
|
||||
if p.showCompletionDialog {
|
||||
editorWidth, _ := p.editor.GetSize()
|
||||
editorX, editorY := p.editor.GetPosition()
|
||||
|
||||
p.completionDialog.SetWidth(editorWidth)
|
||||
overlay := p.completionDialog.View()
|
||||
|
||||
layoutView = layout.PlaceOverlay(
|
||||
editorX,
|
||||
editorY-lipgloss.Height(overlay)+2,
|
||||
overlay,
|
||||
layoutView,
|
||||
)
|
||||
}
|
||||
|
||||
return layoutView
|
||||
}
|
||||
|
||||
func NewChatPage(app *app.App) layout.ModelWithView {
|
||||
completionManager := completions.NewCompletionManager(app)
|
||||
initialProvider := completionManager.GetProvider("")
|
||||
completionDialog := dialog.NewCompletionDialogComponent(initialProvider)
|
||||
|
||||
messagesContainer := layout.NewContainer(
|
||||
chat.NewMessagesComponent(app),
|
||||
)
|
||||
editorContainer := layout.NewContainer(
|
||||
chat.NewEditorComponent(app),
|
||||
layout.WithMaxWidth(layout.Current.Container.Width),
|
||||
layout.WithAlignCenter(),
|
||||
)
|
||||
|
||||
return &chatPage{
|
||||
app: app,
|
||||
editor: editorContainer,
|
||||
messages: messagesContainer,
|
||||
completionDialog: completionDialog,
|
||||
completionManager: completionManager,
|
||||
layout: layout.NewFlexLayout(
|
||||
layout.WithPanes(messagesContainer, editorContainer),
|
||||
layout.WithDirection(layout.FlexDirectionVertical),
|
||||
layout.WithPaneSizes(
|
||||
layout.FlexPaneSizeGrow,
|
||||
layout.FlexPaneSizeFixed(6),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package page
|
||||
|
||||
type PageID string
|
||||
|
||||
// PageChangeMsg is used to change the current page
|
||||
type PageChangeMsg struct {
|
||||
ID PageID
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
type SessionSelectedMsg = *client.SessionInfo
|
||||
type ModelSelectedMsg struct {
|
||||
Provider client.ProviderInfo
|
||||
Model client.ModelInfo
|
||||
}
|
||||
|
||||
type SessionClearedMsg struct{}
|
||||
type CompactSessionMsg struct{}
|
||||
|
||||
// TODO: remove
|
||||
type StateUpdatedMsg struct {
|
||||
State map[string]any
|
||||
}
|
||||
@@ -8,6 +8,6 @@ var Terminal *TerminalInfo
|
||||
|
||||
func init() {
|
||||
Terminal = &TerminalInfo{
|
||||
BackgroundIsDark: false,
|
||||
BackgroundIsDark: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package styles
|
||||
|
||||
const (
|
||||
OpenCodeIcon string = "◍"
|
||||
|
||||
ErrorIcon string = "ⓔ"
|
||||
WarningIcon string = "ⓦ"
|
||||
InfoIcon string = "ⓘ"
|
||||
HintIcon string = "ⓗ"
|
||||
SpinnerIcon string = "⟳"
|
||||
DocumentIcon string = "🖼"
|
||||
)
|
||||
@@ -0,0 +1,298 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
)
|
||||
|
||||
// EverforestTheme implements the Theme interface with Everforest colors.
|
||||
// It provides both dark and light variants with Medium (default) contrast.
|
||||
type EverforestTheme struct {
|
||||
BaseTheme
|
||||
}
|
||||
|
||||
// NewEverforestTheme creates a new instance of the Everforest Medium theme.
|
||||
func NewEverforestTheme() *EverforestTheme {
|
||||
// Everforest color palette - Medium variant
|
||||
// Official colors from https://github.com/sainnhe/everforest/wiki
|
||||
// Dark mode colors - using Everforest:Dark Medium contrast palette
|
||||
darkStep1 := "#2d353b" // App background
|
||||
darkStep2 := "#333c43" // Subtle background
|
||||
darkStep3 := "#343f44" // UI element background
|
||||
darkStep4 := "#3d484d" // Hovered UI element background
|
||||
darkStep5 := "#475258" // Active/Selected UI element background
|
||||
darkStep6 := "#7a8478" // Subtle borders and separators
|
||||
darkStep7 := "#859289" // UI element border and focus rings
|
||||
darkStep8 := "#9da9a0" // Hovered UI element border
|
||||
darkStep9 := "#a7c080" // Solid backgrounds
|
||||
darkStep10 := "#83c092" // Hovered solid backgrounds
|
||||
darkStep11 := "#7a8478" // Low-contrast text
|
||||
darkStep12 := "#d3c6aa" // High-contrast text
|
||||
|
||||
// Dark mode accent colors
|
||||
darkPrimary := darkStep9 // Primary uses step 9 (green)
|
||||
darkSecondary := "#7fbbb3" // Secondary (blue)
|
||||
darkAccent := "#d699b6" // Accent (purple)
|
||||
darkRed := "#e67e80" // Error (red)
|
||||
darkOrange := "#e69875" // Warning (orange)
|
||||
darkGreen := "#a7c080" // Success (green)
|
||||
darkCyan := "#83c092" // Info (aqua)
|
||||
darkYellow := "#dbbc7f" // Emphasized text
|
||||
|
||||
// Light mode colors for the Everforest:Light Medium contrast palette
|
||||
lightStep1 := "#fdf6e3" // App background
|
||||
lightStep2 := "#efebd4" // Subtle background
|
||||
lightStep3 := "#f4f0d9" // UI element background
|
||||
lightStep4 := "#efebd4" // Hovered UI element background
|
||||
lightStep5 := "#e6e2cc" // Active/Selected UI element background
|
||||
lightStep6 := "#a6b0a0" // Subtle borders and separators
|
||||
lightStep7 := "#939f91" // UI element border and focus rings
|
||||
lightStep8 := "#829181" // Hovered UI element border
|
||||
lightStep9 := "#8da101" // Solid backgrounds
|
||||
lightStep10 := "#35a77c" // Hovered solid backgrounds
|
||||
lightStep11 := "#a6b0a0" // Low-contrast text
|
||||
lightStep12 := "#5c6a72" // High-contrast text
|
||||
|
||||
// Light mode accent colors
|
||||
lightPrimary := lightStep9 // Primary uses step 9 (green)
|
||||
lightSecondary := "#3a94c5" // Secondary blue
|
||||
lightAccent := "#df69ba" // Accent purple
|
||||
lightRed := "#f85552" // Error red
|
||||
lightOrange := "#f57d26" // Warning orange
|
||||
lightGreen := "#8da101" // Success green
|
||||
lightCyan := "#35a77c" // Info aqua
|
||||
lightYellow := "#dfa000" // Emphasized text
|
||||
|
||||
// Unused variables. These could be used for hover states
|
||||
_ = darkStep4
|
||||
_ = darkStep5
|
||||
_ = darkStep10
|
||||
_ = lightStep4
|
||||
_ = lightStep5
|
||||
_ = lightStep10
|
||||
|
||||
theme := &EverforestTheme{}
|
||||
|
||||
// Base colors
|
||||
theme.PrimaryColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkPrimary),
|
||||
Light: lipgloss.Color(lightPrimary),
|
||||
}
|
||||
theme.SecondaryColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkSecondary),
|
||||
Light: lipgloss.Color(lightSecondary),
|
||||
}
|
||||
theme.AccentColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkAccent),
|
||||
Light: lipgloss.Color(lightAccent),
|
||||
}
|
||||
|
||||
// Status colors
|
||||
theme.ErrorColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkRed),
|
||||
Light: lipgloss.Color(lightRed),
|
||||
}
|
||||
theme.WarningColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkOrange),
|
||||
Light: lipgloss.Color(lightOrange),
|
||||
}
|
||||
theme.SuccessColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkGreen),
|
||||
Light: lipgloss.Color(lightGreen),
|
||||
}
|
||||
theme.InfoColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkCyan),
|
||||
Light: lipgloss.Color(lightCyan),
|
||||
}
|
||||
|
||||
// Text colors
|
||||
theme.TextColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep12),
|
||||
Light: lipgloss.Color(lightStep12),
|
||||
}
|
||||
theme.TextMutedColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep11),
|
||||
Light: lipgloss.Color(lightStep11),
|
||||
}
|
||||
|
||||
// Background colors
|
||||
theme.BackgroundColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep1),
|
||||
Light: lipgloss.Color(lightStep1),
|
||||
}
|
||||
theme.BackgroundSubtleColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep2),
|
||||
Light: lipgloss.Color(lightStep2),
|
||||
}
|
||||
theme.BackgroundElementColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep3),
|
||||
Light: lipgloss.Color(lightStep3),
|
||||
}
|
||||
|
||||
// Border colors
|
||||
theme.BorderColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep7),
|
||||
Light: lipgloss.Color(lightStep7),
|
||||
}
|
||||
theme.BorderActiveColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep8),
|
||||
Light: lipgloss.Color(lightStep8),
|
||||
}
|
||||
theme.BorderSubtleColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep6),
|
||||
Light: lipgloss.Color(lightStep6),
|
||||
}
|
||||
|
||||
// Diff view colors
|
||||
theme.DiffAddedColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#A7C080"),
|
||||
Light: lipgloss.Color("#8DA101"),
|
||||
}
|
||||
theme.DiffRemovedColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#E67E80"),
|
||||
Light: lipgloss.Color("#F85552"),
|
||||
}
|
||||
theme.DiffContextColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#7A8478"),
|
||||
Light: lipgloss.Color("#A6B0A0"),
|
||||
}
|
||||
theme.DiffHunkHeaderColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#859289"),
|
||||
Light: lipgloss.Color("#939F91"),
|
||||
}
|
||||
theme.DiffHighlightAddedColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#A7C080"),
|
||||
Light: lipgloss.Color("#8DA101"),
|
||||
}
|
||||
theme.DiffHighlightRemovedColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#E67E80"),
|
||||
Light: lipgloss.Color("#F85552"),
|
||||
}
|
||||
theme.DiffAddedBgColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#425047"),
|
||||
Light: lipgloss.Color("#F0F1D2"),
|
||||
}
|
||||
theme.DiffRemovedBgColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#543A48"),
|
||||
Light: lipgloss.Color("#FBE3DA"),
|
||||
}
|
||||
theme.DiffContextBgColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep2),
|
||||
Light: lipgloss.Color(lightStep2),
|
||||
}
|
||||
theme.DiffLineNumberColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep3),
|
||||
Light: lipgloss.Color(lightStep3),
|
||||
}
|
||||
theme.DiffAddedLineNumberBgColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#3A4A3F"),
|
||||
Light: lipgloss.Color("#E8F2D1"),
|
||||
}
|
||||
theme.DiffRemovedLineNumberBgColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color("#4A3A40"),
|
||||
Light: lipgloss.Color("#FBDAD2"),
|
||||
}
|
||||
|
||||
// Markdown colors
|
||||
theme.MarkdownTextColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep12),
|
||||
Light: lipgloss.Color(lightStep12),
|
||||
}
|
||||
theme.MarkdownHeadingColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkSecondary),
|
||||
Light: lipgloss.Color(lightSecondary),
|
||||
}
|
||||
theme.MarkdownLinkColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkPrimary),
|
||||
Light: lipgloss.Color(lightPrimary),
|
||||
}
|
||||
theme.MarkdownLinkTextColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkCyan),
|
||||
Light: lipgloss.Color(lightCyan),
|
||||
}
|
||||
theme.MarkdownCodeColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkGreen),
|
||||
Light: lipgloss.Color(lightGreen),
|
||||
}
|
||||
theme.MarkdownBlockQuoteColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkYellow),
|
||||
Light: lipgloss.Color(lightYellow),
|
||||
}
|
||||
theme.MarkdownEmphColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkYellow),
|
||||
Light: lipgloss.Color(lightYellow),
|
||||
}
|
||||
theme.MarkdownStrongColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkAccent),
|
||||
Light: lipgloss.Color(lightAccent),
|
||||
}
|
||||
theme.MarkdownHorizontalRuleColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep11),
|
||||
Light: lipgloss.Color(lightStep11),
|
||||
}
|
||||
theme.MarkdownListItemColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkPrimary),
|
||||
Light: lipgloss.Color(lightPrimary),
|
||||
}
|
||||
theme.MarkdownListEnumerationColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkCyan),
|
||||
Light: lipgloss.Color(lightCyan),
|
||||
}
|
||||
theme.MarkdownImageColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkPrimary),
|
||||
Light: lipgloss.Color(lightPrimary),
|
||||
}
|
||||
theme.MarkdownImageTextColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkCyan),
|
||||
Light: lipgloss.Color(lightCyan),
|
||||
}
|
||||
theme.MarkdownCodeBlockColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep12),
|
||||
Light: lipgloss.Color(lightStep12),
|
||||
}
|
||||
|
||||
// Syntax highlighting colors
|
||||
theme.SyntaxCommentColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep11),
|
||||
Light: lipgloss.Color(lightStep11),
|
||||
}
|
||||
theme.SyntaxKeywordColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkPrimary),
|
||||
Light: lipgloss.Color(lightPrimary),
|
||||
}
|
||||
theme.SyntaxFunctionColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkSecondary),
|
||||
Light: lipgloss.Color(lightSecondary),
|
||||
}
|
||||
theme.SyntaxVariableColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkRed),
|
||||
Light: lipgloss.Color(lightRed),
|
||||
}
|
||||
theme.SyntaxStringColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkGreen),
|
||||
Light: lipgloss.Color(lightGreen),
|
||||
}
|
||||
theme.SyntaxNumberColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkAccent),
|
||||
Light: lipgloss.Color(lightAccent),
|
||||
}
|
||||
theme.SyntaxTypeColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkYellow),
|
||||
Light: lipgloss.Color(lightYellow),
|
||||
}
|
||||
theme.SyntaxOperatorColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkCyan),
|
||||
Light: lipgloss.Color(lightCyan),
|
||||
}
|
||||
theme.SyntaxPunctuationColor = compat.AdaptiveColor{
|
||||
Dark: lipgloss.Color(darkStep12),
|
||||
Light: lipgloss.Color(lightStep12),
|
||||
}
|
||||
|
||||
return theme
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Register the Everforest theme with the theme manager
|
||||
RegisterTheme("everforest", NewEverforestTheme())
|
||||
}
|
||||
+473
-229
@@ -3,195 +3,246 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/cursor"
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
||||
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/core"
|
||||
"github.com/sst/opencode/internal/completions"
|
||||
"github.com/sst/opencode/internal/components/chat"
|
||||
"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/page"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
type appModel struct {
|
||||
width, height int
|
||||
currentPage page.PageID
|
||||
previousPage page.PageID
|
||||
pages map[page.PageID]layout.ModelWithView
|
||||
loadedPages map[page.PageID]bool
|
||||
status core.StatusComponent
|
||||
app *app.App
|
||||
modal layout.Modal
|
||||
width, height int
|
||||
app *app.App
|
||||
modal layout.Modal
|
||||
status status.StatusComponent
|
||||
editor chat.EditorComponent
|
||||
messages chat.MessagesComponent
|
||||
editorContainer layout.Container
|
||||
layout layout.FlexLayout
|
||||
completions dialog.CompletionDialog
|
||||
completionManager *completions.CompletionManager
|
||||
showCompletionDialog bool
|
||||
leaderBinding *key.Binding
|
||||
isLeaderSequence bool
|
||||
toastManager *toast.ToastManager
|
||||
}
|
||||
|
||||
func (a appModel) Init() tea.Cmd {
|
||||
t := theme.CurrentTheme()
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
|
||||
cmds = append(cmds, tea.RequestBackgroundColor)
|
||||
|
||||
cmd := a.pages[a.currentPage].Init()
|
||||
a.loadedPages[a.currentPage] = true
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
cmd = a.status.Init()
|
||||
cmds = append(cmds, cmd)
|
||||
// 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 {
|
||||
shouldShow := app.Info.Git && app.Info.Time.Initialized == nil
|
||||
shouldShow := a.app.Info.Git && a.app.Info.Time.Initialized == nil
|
||||
return dialog.ShowInitDialogMsg{Show: shouldShow}
|
||||
})
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a appModel) updateAllPages(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
var cmd tea.Cmd
|
||||
|
||||
for id := range a.pages {
|
||||
updated, cmd := a.pages[id].Update(msg)
|
||||
a.pages[id] = updated.(layout.ModelWithView)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
s, cmd := a.status.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
a.status = s.(core.StatusComponent)
|
||||
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
var cmd tea.Cmd
|
||||
|
||||
if a.modal != nil {
|
||||
bypassModal := false
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
keyString := msg.String()
|
||||
|
||||
if _, ok := msg.(modal.CloseModalMsg); ok {
|
||||
a.modal = nil
|
||||
return a, nil
|
||||
}
|
||||
|
||||
if msg, ok := msg.(tea.KeyMsg); ok {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
// 1. Handle active modal
|
||||
if a.modal != nil {
|
||||
switch keyString {
|
||||
// Escape always closes current modal
|
||||
case "esc", "ctrl+c":
|
||||
a.modal = nil
|
||||
return a, nil
|
||||
case "ctrl+c":
|
||||
return a, tea.Quit
|
||||
}
|
||||
|
||||
// don't send commands to the modal
|
||||
for _, cmdDef := range a.app.Commands {
|
||||
if key.Matches(msg, cmdDef.KeyBinding) {
|
||||
bypassModal = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// thanks i hate this
|
||||
switch msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
bypassModal = true
|
||||
case client.EventSessionUpdated:
|
||||
bypassModal = true
|
||||
case client.EventMessageUpdated:
|
||||
bypassModal = true
|
||||
case cursor.BlinkMsg:
|
||||
bypassModal = true
|
||||
case spinner.TickMsg:
|
||||
bypassModal = true
|
||||
}
|
||||
|
||||
if !bypassModal {
|
||||
// Pass all other key presses to the modal
|
||||
updatedModal, cmd := a.modal.Update(msg)
|
||||
a.modal = updatedModal.(layout.Modal)
|
||||
return a, cmd
|
||||
}
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case commands.ExecuteCommandMsg:
|
||||
switch msg.Name {
|
||||
case "quit":
|
||||
return a, tea.Quit
|
||||
case "new":
|
||||
a.app.Session = &client.SessionInfo{}
|
||||
a.app.Messages = []client.MessageInfo{}
|
||||
cmds = append(cmds, util.CmdHandler(state.SessionClearedMsg{}))
|
||||
case "sessions":
|
||||
sessionDialog := dialog.NewSessionDialog(a.app)
|
||||
a.modal = sessionDialog
|
||||
case "model":
|
||||
modelDialog := dialog.NewModelDialog(a.app)
|
||||
a.modal = modelDialog
|
||||
case "theme":
|
||||
themeDialog := dialog.NewThemeDialog()
|
||||
a.modal = themeDialog
|
||||
case "help":
|
||||
var helpBindings []key.Binding
|
||||
for _, cmd := range a.app.Commands {
|
||||
// Create a new binding for help display
|
||||
helpBindings = append(helpBindings, key.NewBinding(
|
||||
key.WithKeys(cmd.KeyBinding.Keys()...),
|
||||
key.WithHelp("/"+cmd.Name, cmd.Description),
|
||||
))
|
||||
// 2. Check for commands that require leader
|
||||
if a.isLeaderSequence {
|
||||
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
|
||||
a.isLeaderSequence = false
|
||||
if len(matches) > 0 {
|
||||
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
|
||||
}
|
||||
helpDialog := dialog.NewHelpDialog(helpBindings...)
|
||||
a.modal = helpDialog
|
||||
}
|
||||
slog.Info("Execute command", "cmds", cmds)
|
||||
return a, tea.Batch(cmds...)
|
||||
|
||||
// 3. Handle completions trigger
|
||||
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
|
||||
// (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.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 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...)
|
||||
}
|
||||
|
||||
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.Batch(cmds...)
|
||||
}
|
||||
|
||||
// 4. Maximize editor responsiveness for printable characters
|
||||
if msg.Text != "" {
|
||||
updated, cmd := a.editor.Update(msg)
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// 5. Check for leader key activation
|
||||
if a.leaderBinding != nil &&
|
||||
!a.isLeaderSequence &&
|
||||
key.Matches(msg, *a.leaderBinding) {
|
||||
a.isLeaderSequence = true
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// 6. Check again for commands that don't require leader
|
||||
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
|
||||
if len(matches) > 0 {
|
||||
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
|
||||
}
|
||||
|
||||
// 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
|
||||
case tea.MouseWheelMsg:
|
||||
if a.modal != nil {
|
||||
return a, nil
|
||||
}
|
||||
updated, cmd := a.messages.Update(msg)
|
||||
a.messages = updated.(chat.MessagesComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case tea.BackgroundColorMsg:
|
||||
styles.Terminal = &styles.TerminalInfo{
|
||||
BackgroundIsDark: msg.IsDark(),
|
||||
}
|
||||
|
||||
case cursor.BlinkMsg:
|
||||
return a.updateAllPages(msg)
|
||||
|
||||
case spinner.TickMsg:
|
||||
return a.updateAllPages(msg)
|
||||
|
||||
slog.Debug("Background color", "isDark", msg.IsDark())
|
||||
case modal.CloseModalMsg:
|
||||
a.modal = nil
|
||||
return a, nil
|
||||
case commands.ExecuteCommandMsg:
|
||||
updated, cmd := a.executeCommand(commands.Command(msg))
|
||||
return updated, cmd
|
||||
case commands.ExecuteCommandsMsg:
|
||||
for _, command := range msg {
|
||||
updated, cmd := a.executeCommand(command)
|
||||
if cmd != nil {
|
||||
return updated, cmd
|
||||
}
|
||||
}
|
||||
case app.SendMsg:
|
||||
a.showCompletionDialog = false
|
||||
cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
|
||||
cmds = append(cmds, cmd)
|
||||
case dialog.CompletionDialogCloseMsg:
|
||||
a.showCompletionDialog = false
|
||||
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
|
||||
return a.updateAllPages(state.StateUpdatedMsg{State: nil})
|
||||
}
|
||||
|
||||
case client.EventMessageUpdated:
|
||||
if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id {
|
||||
exists := false
|
||||
for i, m := range a.app.Messages {
|
||||
if m.Id == msg.Properties.Info.Id {
|
||||
a.app.Messages[i] = msg.Properties.Info
|
||||
return a.updateAllPages(state.StateUpdatedMsg{State: nil})
|
||||
exists = true
|
||||
}
|
||||
}
|
||||
a.app.Messages = append(a.app.Messages, msg.Properties.Info)
|
||||
return a.updateAllPages(state.StateUpdatedMsg{State: nil})
|
||||
if !exists {
|
||||
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
|
||||
|
||||
// TODO: move away from global state
|
||||
layout.Current = &layout.LayoutInfo{
|
||||
Viewport: layout.Dimensions{
|
||||
Width: a.width,
|
||||
@@ -201,139 +252,332 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
Width: min(a.width, 80),
|
||||
},
|
||||
}
|
||||
|
||||
s, cmd := a.status.Update(msg)
|
||||
a.status = s.(core.StatusComponent)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
a.layout.SetSize(a.width, a.height)
|
||||
case app.SessionSelectedMsg:
|
||||
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")
|
||||
}
|
||||
|
||||
updated, cmd := a.pages[a.currentPage].Update(msg)
|
||||
a.pages[a.currentPage] = updated.(layout.ModelWithView)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
if a.modal != nil {
|
||||
s, cmd := a.modal.Update(msg)
|
||||
a.modal = s.(layout.Modal)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
return a, tea.Batch(cmds...)
|
||||
|
||||
case page.PageChangeMsg:
|
||||
return a, a.moveToPage(msg.ID)
|
||||
|
||||
case state.SessionSelectedMsg:
|
||||
a.app.Session = msg
|
||||
a.app.Messages, _ = a.app.ListMessages(context.Background(), msg.Id)
|
||||
return a.updateAllPages(msg)
|
||||
|
||||
case state.ModelSelectedMsg:
|
||||
a.app.Messages = messages
|
||||
case app.ModelSelectedMsg:
|
||||
a.app.Provider = &msg.Provider
|
||||
a.app.Model = &msg.Model
|
||||
a.app.Config.Provider = msg.Provider.Id
|
||||
a.app.Config.Model = msg.Model.Id
|
||||
a.app.SaveConfig()
|
||||
return a.updateAllPages(msg)
|
||||
|
||||
case dialog.ThemeChangedMsg:
|
||||
a.app.Config.Theme = msg.ThemeName
|
||||
a.app.SaveConfig()
|
||||
|
||||
updated, cmd := a.pages[a.currentPage].Update(msg)
|
||||
a.pages[a.currentPage] = updated.(layout.ModelWithView)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
s, cmd := a.status.Update(msg)
|
||||
a.app.State.Provider = msg.Provider.Id
|
||||
a.app.State.Model = msg.Model.Id
|
||||
a.app.SaveState()
|
||||
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)
|
||||
a.status = s.(core.StatusComponent)
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
|
||||
return a, tea.Batch(cmds...)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
// give the editor a chance to clear input
|
||||
case "ctrl+c":
|
||||
updated, cmd := a.pages[a.currentPage].Update(msg)
|
||||
a.pages[a.currentPage] = updated.(layout.ModelWithView)
|
||||
if cmd != nil {
|
||||
return a, cmd
|
||||
}
|
||||
}
|
||||
|
||||
// First, check for modal triggers from the command registry
|
||||
if a.modal == nil {
|
||||
for _, cmdDef := range a.app.Commands {
|
||||
if key.Matches(msg, cmdDef.KeyBinding) {
|
||||
// If a key matches, send an ExecuteCommandMsg to self.
|
||||
// This unifies keybinding and slash command handling.
|
||||
return a, util.CmdHandler(commands.ExecuteCommandMsg{Name: cmdDef.Name})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update status bar
|
||||
s, cmd := a.status.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
a.status = s.(core.StatusComponent)
|
||||
a.status = s.(status.StatusComponent)
|
||||
|
||||
// update current page
|
||||
updated, cmd := a.pages[a.currentPage].Update(msg)
|
||||
a.pages[a.currentPage] = updated.(layout.ModelWithView)
|
||||
// update editor
|
||||
u, cmd := a.editor.Update(msg)
|
||||
a.editor = u.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// update messages
|
||||
u, cmd = a.messages.Update(msg)
|
||||
a.messages = u.(chat.MessagesComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// update modal
|
||||
if a.modal != nil {
|
||||
u, cmd := a.modal.Update(msg)
|
||||
a.modal = u.(layout.Modal)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
if a.showCompletionDialog {
|
||||
u, cmd := a.completions.Update(msg)
|
||||
a.completions = u.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
if _, ok := a.loadedPages[pageID]; !ok {
|
||||
cmd := a.pages[pageID].Init()
|
||||
cmds = append(cmds, cmd)
|
||||
a.loadedPages[pageID] = true
|
||||
}
|
||||
a.previousPage = a.currentPage
|
||||
a.currentPage = pageID
|
||||
if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
|
||||
cmd := sizable.SetSize(a.width, a.height)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
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 {
|
||||
a.completions.SetWidth(editorWidth)
|
||||
overlay := a.completions.View()
|
||||
layoutView = layout.PlaceOverlay(
|
||||
editorX,
|
||||
editorY-lipgloss.Height(overlay)+2,
|
||||
overlay,
|
||||
layoutView,
|
||||
)
|
||||
}
|
||||
|
||||
components := []string{
|
||||
a.pages[a.currentPage].View(),
|
||||
layoutView,
|
||||
a.status.View(),
|
||||
}
|
||||
components = append(components, 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
|
||||
}
|
||||
|
||||
func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
|
||||
cmds := []tea.Cmd{
|
||||
util.CmdHandler(commands.CommandExecutedMsg(command)),
|
||||
}
|
||||
switch command.Name {
|
||||
case commands.AppHelpCommand:
|
||||
helpDialog := dialog.NewHelpDialog(a.app.Commands.Sorted())
|
||||
a.modal = helpDialog
|
||||
case commands.EditorOpenCommand:
|
||||
if a.app.IsBusy() {
|
||||
// status.Warn("Agent is working, please wait...")
|
||||
return a, nil
|
||||
}
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
return a, toast.NewErrorToast("No EDITOR set, can't open editor")
|
||||
}
|
||||
|
||||
value := a.editor.Value()
|
||||
updated, cmd := a.editor.Clear()
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
tmpfile, err := os.CreateTemp("", "msg_*.md")
|
||||
tmpfile.WriteString(value)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create temp file", "error", err)
|
||||
return a, toast.NewErrorToast("Something went wrong, couldn't open editor")
|
||||
}
|
||||
tmpfile.Close()
|
||||
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
|
||||
c.Stdin = os.Stdin
|
||||
c.Stdout = os.Stdout
|
||||
c.Stderr = os.Stderr
|
||||
cmd = tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
if err != nil {
|
||||
slog.Error("Failed to open editor", "error", err)
|
||||
return nil
|
||||
}
|
||||
content, err := os.ReadFile(tmpfile.Name())
|
||||
if err != nil {
|
||||
slog.Error("Failed to read file", "error", err)
|
||||
return nil
|
||||
}
|
||||
if len(content) == 0 {
|
||||
slog.Warn("Message is empty")
|
||||
return nil
|
||||
}
|
||||
os.Remove(tmpfile.Name())
|
||||
// attachments := m.attachments
|
||||
// m.attachments = nil
|
||||
return app.SendMsg{
|
||||
Text: string(content),
|
||||
Attachments: []app.Attachment{}, // attachments,
|
||||
}
|
||||
})
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.SessionNewCommand:
|
||||
if a.app.Session.Id == "" {
|
||||
return a, nil
|
||||
}
|
||||
a.app.Session = &client.SessionInfo{}
|
||||
a.app.Messages = []client.MessageInfo{}
|
||||
cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
|
||||
case commands.SessionListCommand:
|
||||
sessionDialog := dialog.NewSessionDialog(a.app)
|
||||
a.modal = sessionDialog
|
||||
case commands.SessionShareCommand:
|
||||
if a.app.Session.Id == "" {
|
||||
return a, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
a.app.Cancel(context.Background(), a.app.Session.Id)
|
||||
return a, nil
|
||||
case commands.SessionCompactCommand:
|
||||
if a.app.Session.Id == "" {
|
||||
return a, nil
|
||||
}
|
||||
// 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
|
||||
case commands.ThemeListCommand:
|
||||
themeDialog := dialog.NewThemeDialog()
|
||||
a.modal = themeDialog
|
||||
case commands.ProjectInitCommand:
|
||||
cmds = append(cmds, a.app.InitializeProject(context.Background()))
|
||||
case commands.InputClearCommand:
|
||||
if a.editor.Value() == "" {
|
||||
return a, nil
|
||||
}
|
||||
updated, cmd := a.editor.Clear()
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.InputPasteCommand:
|
||||
updated, cmd := a.editor.Paste()
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.InputSubmitCommand:
|
||||
updated, cmd := a.editor.Submit()
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.InputNewlineCommand:
|
||||
updated, cmd := a.editor.Newline()
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.HistoryPreviousCommand:
|
||||
if a.showCompletionDialog {
|
||||
return a, nil
|
||||
}
|
||||
updated, cmd := a.editor.Previous()
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.HistoryNextCommand:
|
||||
if a.showCompletionDialog {
|
||||
return a, nil
|
||||
}
|
||||
updated, cmd := a.editor.Next()
|
||||
a.editor = updated.(chat.EditorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.MessagesFirstCommand:
|
||||
updated, cmd := a.messages.First()
|
||||
a.messages = updated.(chat.MessagesComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.MessagesLastCommand:
|
||||
updated, cmd := a.messages.Last()
|
||||
a.messages = updated.(chat.MessagesComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.MessagesPageUpCommand:
|
||||
updated, cmd := a.messages.PageUp()
|
||||
a.messages = updated.(chat.MessagesComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.MessagesPageDownCommand:
|
||||
updated, cmd := a.messages.PageDown()
|
||||
a.messages = updated.(chat.MessagesComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.MessagesHalfPageUpCommand:
|
||||
updated, cmd := a.messages.HalfPageUp()
|
||||
a.messages = updated.(chat.MessagesComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.MessagesHalfPageDownCommand:
|
||||
updated, cmd := a.messages.HalfPageDown()
|
||||
a.messages = updated.(chat.MessagesComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
case commands.AppExitCommand:
|
||||
return a, tea.Quit
|
||||
}
|
||||
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 {
|
||||
startPage := page.ChatPage
|
||||
completionManager := completions.NewCompletionManager(app)
|
||||
initialProvider := completionManager.DefaultProvider()
|
||||
|
||||
messages := chat.NewMessagesComponent(app)
|
||||
editor := chat.NewEditorComponent(app)
|
||||
completions := dialog.NewCompletionDialogComponent(initialProvider)
|
||||
|
||||
editorContainer := layout.NewContainer(
|
||||
editor,
|
||||
layout.WithMaxWidth(layout.Current.Container.Width),
|
||||
layout.WithAlignCenter(),
|
||||
)
|
||||
messagesContainer := layout.NewContainer(messages)
|
||||
|
||||
var leaderBinding *key.Binding
|
||||
if (*app.Config.Keybinds).Leader != nil {
|
||||
binding := key.NewBinding(key.WithKeys(*app.Config.Keybinds.Leader))
|
||||
leaderBinding = &binding
|
||||
}
|
||||
|
||||
model := &appModel{
|
||||
currentPage: startPage,
|
||||
loadedPages: make(map[page.PageID]bool),
|
||||
status: core.NewStatusCmp(app),
|
||||
app: app,
|
||||
pages: map[page.PageID]layout.ModelWithView{
|
||||
page.ChatPage: page.NewChatPage(app),
|
||||
},
|
||||
status: status.NewStatusCmp(app),
|
||||
app: app,
|
||||
editor: editor,
|
||||
messages: messages,
|
||||
completions: completions,
|
||||
completionManager: completionManager,
|
||||
leaderBinding: leaderBinding,
|
||||
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(5),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
return model
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -44,6 +44,25 @@
|
||||
"description": "Get app info"
|
||||
}
|
||||
},
|
||||
"/config_get": {
|
||||
"post": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Get config info",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Config.Info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"operationId": "postConfig_get",
|
||||
"parameters": [],
|
||||
"description": "Get config info"
|
||||
}
|
||||
},
|
||||
"/app_initialize": {
|
||||
"post": {
|
||||
"responses": {
|
||||
@@ -478,6 +497,25 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/installation_info": {
|
||||
"post": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Get installation info",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InstallationInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"operationId": "postInstallation_info",
|
||||
"parameters": [],
|
||||
"description": "Get installation info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -487,6 +525,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.storage.write"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.installation.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.lsp.client.diagnostics"
|
||||
},
|
||||
@@ -510,6 +551,7 @@
|
||||
"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",
|
||||
@@ -544,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": {
|
||||
@@ -782,12 +848,28 @@
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "number"
|
||||
},
|
||||
"cache": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"read": {
|
||||
"type": "number"
|
||||
},
|
||||
"write": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"read",
|
||||
"write"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input",
|
||||
"output",
|
||||
"reasoning"
|
||||
"reasoning",
|
||||
"cache"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -1181,21 +1263,20 @@
|
||||
"share": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"secret",
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1215,6 +1296,7 @@
|
||||
"required": [
|
||||
"id",
|
||||
"title",
|
||||
"version",
|
||||
"time"
|
||||
]
|
||||
},
|
||||
@@ -1276,13 +1358,17 @@
|
||||
},
|
||||
"cwd": {
|
||||
"type": "string"
|
||||
},
|
||||
"state": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"config",
|
||||
"data",
|
||||
"root",
|
||||
"cwd"
|
||||
"cwd",
|
||||
"state"
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
@@ -1301,21 +1387,275 @@
|
||||
"time"
|
||||
]
|
||||
},
|
||||
"Error": {
|
||||
"Config.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON schema reference for configuration validation"
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"description": "Theme name to use for the interface"
|
||||
},
|
||||
"keybinds": {
|
||||
"$ref": "#/components/schemas/Config.Keybinds",
|
||||
"description": "Custom keybind configurations"
|
||||
},
|
||||
"autoshare": {
|
||||
"type": "boolean",
|
||||
"description": "Share newly created sessions automatically"
|
||||
},
|
||||
"autoupdate": {
|
||||
"type": "boolean",
|
||||
"description": "Automatically update to the latest version"
|
||||
},
|
||||
"disabled_providers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Disable providers that are loaded automatically"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model to use in the format of provider/model, eg anthropic/claude-2"
|
||||
},
|
||||
"provider": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"env": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"npm": {
|
||||
"type": "string"
|
||||
},
|
||||
"models": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"attachment": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"cost": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "number"
|
||||
},
|
||||
"output": {
|
||||
"type": "number"
|
||||
},
|
||||
"cache_read": {
|
||||
"type": "number"
|
||||
},
|
||||
"cache_write": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input",
|
||||
"output"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"context": {
|
||||
"type": "number"
|
||||
},
|
||||
"output": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"context",
|
||||
"output"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"models"
|
||||
]
|
||||
},
|
||||
"description": "Custom provider configurations and model overrides"
|
||||
},
|
||||
"mcp": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Config.McpLocal"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Config.McpRemote"
|
||||
}
|
||||
],
|
||||
"discriminator": {
|
||||
"propertyName": "type",
|
||||
"mapping": {
|
||||
"local": "#/components/schemas/Config.McpLocal",
|
||||
"remote": "#/components/schemas/Config.McpRemote"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "MCP (Model Context Protocol) server configurations"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Config.Keybinds": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"leader": {
|
||||
"type": "string",
|
||||
"description": "Leader key for keybind combinations"
|
||||
},
|
||||
"help": {
|
||||
"type": "string",
|
||||
"description": "Show help dialog"
|
||||
},
|
||||
"editor_open": {
|
||||
"type": "string",
|
||||
"description": "Open external editor"
|
||||
},
|
||||
"session_new": {
|
||||
"type": "string",
|
||||
"description": "Create a new session"
|
||||
},
|
||||
"session_list": {
|
||||
"type": "string",
|
||||
"description": "List all sessions"
|
||||
},
|
||||
"session_share": {
|
||||
"type": "string",
|
||||
"description": "Share current session"
|
||||
},
|
||||
"session_interrupt": {
|
||||
"type": "string",
|
||||
"description": "Interrupt current session"
|
||||
},
|
||||
"session_compact": {
|
||||
"type": "string",
|
||||
"description": "Toggle compact mode for session"
|
||||
},
|
||||
"tool_details": {
|
||||
"type": "string",
|
||||
"description": "Show tool details"
|
||||
},
|
||||
"model_list": {
|
||||
"type": "string",
|
||||
"description": "List available models"
|
||||
},
|
||||
"theme_list": {
|
||||
"type": "string",
|
||||
"description": "List available themes"
|
||||
},
|
||||
"project_init": {
|
||||
"type": "string",
|
||||
"description": "Initialize project configuration"
|
||||
},
|
||||
"input_clear": {
|
||||
"type": "string",
|
||||
"description": "Clear input field"
|
||||
},
|
||||
"input_paste": {
|
||||
"type": "string",
|
||||
"description": "Paste from clipboard"
|
||||
},
|
||||
"input_submit": {
|
||||
"type": "string",
|
||||
"description": "Submit input"
|
||||
},
|
||||
"input_newline": {
|
||||
"type": "string",
|
||||
"description": "Insert newline in input"
|
||||
},
|
||||
"history_previous": {
|
||||
"type": "string",
|
||||
"description": "Navigate to previous history item"
|
||||
},
|
||||
"history_next": {
|
||||
"type": "string",
|
||||
"description": "Navigate to next history item"
|
||||
},
|
||||
"messages_page_up": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages up by one page"
|
||||
},
|
||||
"messages_page_down": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages down by one page"
|
||||
},
|
||||
"messages_half_page_up": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages up by half page"
|
||||
},
|
||||
"messages_half_page_down": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages down by half page"
|
||||
},
|
||||
"messages_previous": {
|
||||
"type": "string",
|
||||
"description": "Navigate to previous message"
|
||||
},
|
||||
"messages_next": {
|
||||
"type": "string",
|
||||
"description": "Navigate to next message"
|
||||
},
|
||||
"messages_first": {
|
||||
"type": "string",
|
||||
"description": "Navigate to first message"
|
||||
},
|
||||
"messages_last": {
|
||||
"type": "string",
|
||||
"description": "Navigate to last message"
|
||||
},
|
||||
"app_exit": {
|
||||
"type": "string",
|
||||
"description": "Exit the application"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Provider.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1328,6 +1668,9 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"npm": {
|
||||
"type": "string"
|
||||
},
|
||||
"models": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -1366,18 +1709,16 @@
|
||||
"output": {
|
||||
"type": "number"
|
||||
},
|
||||
"inputCached": {
|
||||
"cache_read": {
|
||||
"type": "number"
|
||||
},
|
||||
"outputCached": {
|
||||
"cache_write": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input",
|
||||
"output",
|
||||
"inputCached",
|
||||
"outputCached"
|
||||
"output"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
@@ -1408,6 +1749,79 @@
|
||||
"limit",
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"Config.McpLocal": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"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": [
|
||||
"type",
|
||||
"command"
|
||||
]
|
||||
},
|
||||
"Config.McpRemote": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "remote",
|
||||
"description": "Type of MCP server connection"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL of the remote MCP server"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"InstallationInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"latest": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"version",
|
||||
"latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user