Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9991352663 | ||
|
|
91c4da5dbd | ||
|
|
2fd0e7dd6b | ||
|
|
d50b7ad481 | ||
|
|
df95c49401 | ||
|
|
8b73c52f00 | ||
|
|
5603098d17 | ||
|
|
f436a50125 | ||
|
|
e19e977591 | ||
|
|
addbe295b1 | ||
|
|
9a573dedc6 | ||
|
|
9ea0d71e8d | ||
|
|
b1a3599017 | ||
|
|
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 |
@@ -3,7 +3,8 @@ name: deploy
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dontlook
|
- dev
|
||||||
|
- production
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -20,6 +21,6 @@ jobs:
|
|||||||
|
|
||||||
- run: bun install
|
- run: bun install
|
||||||
|
|
||||||
- run: bun sst deploy --stage=dev
|
- run: bun sst deploy --stage=${{ github.ref_name }}
|
||||||
env:
|
env:
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
|||||||
@@ -5,5 +5,3 @@ node_modules
|
|||||||
.env
|
.env
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
app.log
|
|
||||||
gopls.log
|
|
||||||
@@ -7,19 +7,17 @@
|
|||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
<p align="center">AI coding agent, built for the terminal.</p>
|
||||||
<p align="center">
|
<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://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>
|
<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>
|
</p>
|
||||||
|
|
||||||
|
[](https://opencode.ai)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
AI coding agent, built for the terminal.
|
|
||||||
|
|
||||||
**Note:** Version 0.1.x is a full rewrite, and we do not have proper documentation for it yet. Should have this out week of June 17th 2025.
|
|
||||||
|
|
||||||
[](https://opencode.ai)
|
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -34,86 +32,9 @@ paru -S opencode-bin # Arch Linux
|
|||||||
|
|
||||||
> **Note:** Remove versions older than 0.1.x before installing
|
> **Note:** Remove versions older than 0.1.x before installing
|
||||||
|
|
||||||
### Providers
|
### Documentation
|
||||||
|
|
||||||
The recommended approach is to sign up for Claude Pro or Max, run `opencode auth login`, and select Anthropic. It's the most cost-effective way to use opencode.
|
For more info on how to configure opencode [**head over to our docs**](https://opencode.ai/docs).
|
||||||
|
|
||||||
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'll 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). Be sure to specify the npm package to use to load the provider.
|
|
||||||
|
|
||||||
```json title="opencode.json"
|
|
||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"provider": {
|
|
||||||
"ollama": {
|
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
|
||||||
"options": {
|
|
||||||
"baseURL": "http://localhost:11434/v1"
|
|
||||||
},
|
|
||||||
"models": {
|
|
||||||
"llama2": {
|
|
||||||
"name": "llama2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
@@ -122,12 +43,11 @@ To run opencode locally you need.
|
|||||||
- Bun
|
- Bun
|
||||||
- Golang 1.24.x
|
- Golang 1.24.x
|
||||||
|
|
||||||
To run.
|
And run.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ bun install
|
$ bun install
|
||||||
$ cd packages/opencode
|
$ bun run packages/opencode/src/index.ts
|
||||||
$ bun run src/index.ts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### FAQ
|
### FAQ
|
||||||
@@ -143,9 +63,7 @@ OpenRouter is not in the Models.dev database yet, but you can configure it manua
|
|||||||
"openrouter": {
|
"openrouter": {
|
||||||
"npm": "@openrouter/ai-sdk-provider",
|
"npm": "@openrouter/ai-sdk-provider",
|
||||||
"name": "OpenRouter",
|
"name": "OpenRouter",
|
||||||
"options": {
|
"options": {},
|
||||||
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
||||||
},
|
|
||||||
"models": {
|
"models": {
|
||||||
"anthropic/claude-3.5-sonnet": {
|
"anthropic/claude-3.5-sonnet": {
|
||||||
"name": "Claude 3.5 Sonnet"
|
"name": "Claude 3.5 Sonnet"
|
||||||
@@ -156,6 +74,8 @@ OpenRouter is not in the Models.dev database yet, but you can configure it manua
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
And then to configure an api key you can do `opencode auth login` and select "Other -> 'openrouter'"
|
||||||
|
|
||||||
#### How is this different than Claude Code?
|
#### How is this different than Claude Code?
|
||||||
|
|
||||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
"sst": "3.17.4",
|
"sst": "3.17.6",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
@@ -43,8 +43,11 @@
|
|||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
"zod-openapi": "4.2.4",
|
"zod-openapi": "4.2.4",
|
||||||
|
"zod-validation-error": "3.5.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@ai-sdk/amazon-bedrock": "2.2.10",
|
||||||
|
"@ai-sdk/anthropic": "1.2.12",
|
||||||
"@tsconfig/bun": "1.0.7",
|
"@tsconfig/bun": "1.0.7",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/turndown": "5.0.5",
|
"@types/turndown": "5.0.5",
|
||||||
@@ -74,10 +77,11 @@
|
|||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"shiki": "3.4.2",
|
"shiki": "3.4.2",
|
||||||
"solid-js": "1.9.7",
|
"solid-js": "1.9.7",
|
||||||
"toolbeam-docs-theme": "0.2.4",
|
"toolbeam-docs-theme": "0.3.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"opencode": "workspace:*",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -86,6 +90,9 @@
|
|||||||
"sharp",
|
"sharp",
|
||||||
"esbuild",
|
"esbuild",
|
||||||
],
|
],
|
||||||
|
"patchedDependencies": {
|
||||||
|
"ai@4.3.16": "patches/ai@4.3.16.patch",
|
||||||
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"zod": "3.24.2",
|
"zod": "3.24.2",
|
||||||
},
|
},
|
||||||
@@ -96,6 +103,10 @@
|
|||||||
"zod": "3.24.2",
|
"zod": "3.24.2",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@2.2.10", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-icLGO7Q0NinnHIPgT+y1QjHVwH4HwV+brWbvM+FfCG2Afpa89PyKa3Ret91kGjZpBgM/xnj1B7K5eM+rRlsXQA=="],
|
||||||
|
|
||||||
|
"@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": ["@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=="],
|
"@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=="],
|
||||||
@@ -130,6 +141,12 @@
|
|||||||
|
|
||||||
"@astrojs/underscore-redirects": ["@astrojs/underscore-redirects@0.6.1", "", {}, "sha512-4bMLrs2KW+8/vHEE5Ffv2HbxCbbgXO+2N6MpoCsMXUlUoi7pgEEx8kbkzMXJ2dZtWF3gvwm9lvgjnFeanC2LGg=="],
|
"@astrojs/underscore-redirects": ["@astrojs/underscore-redirects@0.6.1", "", {}, "sha512-4bMLrs2KW+8/vHEE5Ffv2HbxCbbgXO+2N6MpoCsMXUlUoi7pgEEx8kbkzMXJ2dZtWF3gvwm9lvgjnFeanC2LGg=="],
|
||||||
|
|
||||||
|
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||||
|
|
||||||
|
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/types": ["@aws-sdk/types@3.821.0", "", { "dependencies": { "@smithy/types": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-Znroqdai1a90TlxGaJ+FK1lwC0fHpo97Xjsp5UKGR5JODYm7f9+/fF17ebO1KdoBr/Rm0UIFiF5VmI8ts9F1eA=="],
|
||||||
|
|
||||||
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||||
|
|
||||||
"@babel/compat-data": ["@babel/compat-data@7.27.3", "", {}, "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw=="],
|
"@babel/compat-data": ["@babel/compat-data@7.27.3", "", {}, "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw=="],
|
||||||
@@ -410,6 +427,18 @@
|
|||||||
|
|
||||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||||
|
|
||||||
|
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.0.4", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.3.1", "@smithy/util-hex-encoding": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-7XoWfZqWb/QoR/rAU4VSi0mWnO2vu9/ltS6JZ5ZSZv0eovLVfDfu0/AX4ub33RsJTOth3TiFWSHS5YdztvFnig=="],
|
||||||
|
|
||||||
|
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw=="],
|
||||||
|
|
||||||
|
"@smithy/types": ["@smithy/types@4.3.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA=="],
|
||||||
|
|
||||||
|
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug=="],
|
||||||
|
|
||||||
|
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw=="],
|
||||||
|
|
||||||
|
"@smithy/util-utf8": ["@smithy/util-utf8@4.0.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow=="],
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||||
|
|
||||||
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
|
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
|
||||||
@@ -1418,23 +1447,23 @@
|
|||||||
|
|
||||||
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
|
"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=="],
|
"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 +1517,7 @@
|
|||||||
|
|
||||||
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
|
"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=="],
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
@@ -1648,12 +1677,24 @@
|
|||||||
|
|
||||||
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
|
"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=="],
|
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||||
|
|
||||||
|
"@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.2.1", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-bO35JbWpVvyKRl7cmSJD822e8YA8ThR/YbUsciWNA7yTcqpIAL2hJDToWP5KcZBWxGT6IOdOkHSXARSNZc4l/Q=="],
|
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.2.1", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-bO35JbWpVvyKRl7cmSJD822e8YA8ThR/YbUsciWNA7yTcqpIAL2hJDToWP5KcZBWxGT6IOdOkHSXARSNZc4l/Q=="],
|
||||||
|
|
||||||
|
"@aws-crypto/crc32/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||||
|
|
||||||
|
"@aws-crypto/util/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"@aws-sdk/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"@babel/generator/@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=="],
|
"@babel/generator/@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=="],
|
||||||
@@ -1674,6 +1715,18 @@
|
|||||||
|
|
||||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
|
"@smithy/eventstream-codec/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"@smithy/is-array-buffer/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"@smithy/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"@smithy/util-buffer-from/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"@smithy/util-hex-encoding/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"@smithy/util-utf8/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
@@ -1744,6 +1797,8 @@
|
|||||||
|
|
||||||
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
|
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
|
||||||
|
|
||||||
|
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
@@ -1810,6 +1865,8 @@
|
|||||||
|
|
||||||
"wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
|
"wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
|
||||||
|
|
||||||
|
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||||
|
|
||||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"args/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
"args/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||||
|
|||||||
+3
-2
@@ -23,8 +23,9 @@ export const api = new sst.cloudflare.Worker("Api", {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
args.migrations = {
|
args.migrations = {
|
||||||
oldTag: "v1",
|
// Note: when releasing the next tag, make sure all stages use tag v2
|
||||||
newTag: "v1",
|
oldTag: $app.stage === "production" ? "" : "v1",
|
||||||
|
newTag: $app.stage === "production" ? "" : "v1",
|
||||||
//newSqliteClasses: ["SyncServer"],
|
//newSqliteClasses: ["SyncServer"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-3
@@ -1,5 +1,3 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json"
|
||||||
"mcp": {},
|
|
||||||
"provider": {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.2.14",
|
"packageManager": "bun@1.2.14",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "bun run --filter='*' typecheck"
|
"typecheck": "bun run --filter='*' typecheck",
|
||||||
|
"postinstall": "./scripts/hooks"
|
||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
"sst": "3.17.4"
|
"sst": "3.17.6"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -37,5 +38,8 @@
|
|||||||
"esbuild",
|
"esbuild",
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"sharp"
|
"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)
|
this.ctx.acceptWebSocket(server)
|
||||||
|
|
||||||
const data = await this.ctx.storage.list()
|
const data = await this.ctx.storage.list()
|
||||||
for (const [key, content] of data.entries()) {
|
Array.from(data.entries())
|
||||||
server.send(JSON.stringify({ key, content }))
|
.filter(([key, _]) => key.startsWith("session/"))
|
||||||
}
|
.map(([key, content]) => server.send(JSON.stringify({ key, content })))
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 101,
|
status: 101,
|
||||||
@@ -71,11 +71,9 @@ export class SyncServer extends DurableObject<Env> {
|
|||||||
|
|
||||||
public async getData() {
|
public async getData() {
|
||||||
const data = await this.ctx.storage.list()
|
const data = await this.ctx.storage.list()
|
||||||
const messages = []
|
return Array.from(data.entries())
|
||||||
for (const [key, content] of data.entries()) {
|
.filter(([key, _]) => key.startsWith("session/"))
|
||||||
messages.push({ key, content })
|
.map(([key, content]) => ({ key, content }))
|
||||||
}
|
|
||||||
return messages
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSecret() {
|
private async getSecret() {
|
||||||
@@ -122,7 +120,7 @@ export default {
|
|||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
secret,
|
secret,
|
||||||
url: "https://dev.opencode.ai/s/" + short,
|
url: "https://opencode.ai/s/" + short,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|||||||
@@ -2,13 +2,155 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"$schema": {
|
"$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": {
|
"provider": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"api": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -50,18 +192,16 @@
|
|||||||
"output": {
|
"output": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
"inputCached": {
|
"cache_read": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
"outputCached": {
|
"cache_write": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"input",
|
"input",
|
||||||
"output",
|
"output"
|
||||||
"inputCached",
|
|
||||||
"outputCached"
|
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
@@ -97,7 +237,8 @@
|
|||||||
"models"
|
"models"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
},
|
||||||
|
"description": "Custom provider configurations and model overrides"
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -108,19 +249,22 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "local"
|
"const": "local",
|
||||||
|
"description": "Type of MCP server connection"
|
||||||
},
|
},
|
||||||
"command": {
|
"command": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
},
|
||||||
|
"description": "Command and arguments to run the MCP server"
|
||||||
},
|
},
|
||||||
"environment": {
|
"environment": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
},
|
||||||
|
"description": "Environment variables to set when running the MCP server"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -134,10 +278,12 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "remote"
|
"const": "remote",
|
||||||
|
"description": "Type of MCP server connection"
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"description": "URL of the remote MCP server"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -147,7 +293,8 @@
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"description": "MCP (Model Context Protocol) server configurations"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
@@ -9,12 +9,11 @@
|
|||||||
"dev": "bun run ./src/index.ts"
|
"dev": "bun run ./src/index.ts"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
"./*": [
|
"./*": "./src/*.ts"
|
||||||
"./src/*.ts",
|
|
||||||
"./src/*/index.ts"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@ai-sdk/amazon-bedrock": "2.2.10",
|
||||||
|
"@ai-sdk/anthropic": "1.2.12",
|
||||||
"@tsconfig/bun": "1.0.7",
|
"@tsconfig/bun": "1.0.7",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/turndown": "5.0.5",
|
"@types/turndown": "5.0.5",
|
||||||
@@ -44,6 +43,7 @@
|
|||||||
"xdg-basedir": "5.1.0",
|
"xdg-basedir": "5.1.0",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "catalog:",
|
"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
|
// Create symlink to the actual binary
|
||||||
fs.symlinkSync(binaryPath, binScript)
|
fs.symlinkSync(binaryPath, binScript)
|
||||||
console.log(`OpenCode binary symlinked: ${binScript} -> ${binaryPath}`)
|
console.log(`opencode binary symlinked: ${binScript} -> ${binaryPath}`)
|
||||||
} catch (error) {
|
} 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)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export namespace App {
|
|||||||
data: z.string(),
|
data: z.string(),
|
||||||
root: z.string(),
|
root: z.string(),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
|
state: z.string(),
|
||||||
}),
|
}),
|
||||||
time: z.object({
|
time: z.object({
|
||||||
initialized: z.number().optional(),
|
initialized: z.number().optional(),
|
||||||
@@ -32,7 +33,7 @@ export namespace App {
|
|||||||
|
|
||||||
const APP_JSON = "app.json"
|
const APP_JSON = "app.json"
|
||||||
|
|
||||||
async function create(input: { cwd: string; version: string }) {
|
async function create(input: { cwd: string }) {
|
||||||
log.info("creating", {
|
log.info("creating", {
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
})
|
})
|
||||||
@@ -49,9 +50,7 @@ export namespace App {
|
|||||||
const stateFile = Bun.file(path.join(data, APP_JSON))
|
const stateFile = Bun.file(path.join(data, APP_JSON))
|
||||||
const state = (await stateFile.json().catch(() => ({}))) as {
|
const state = (await stateFile.json().catch(() => ({}))) as {
|
||||||
initialized: number
|
initialized: number
|
||||||
version: string
|
|
||||||
}
|
}
|
||||||
state.version = input.version
|
|
||||||
await stateFile.write(JSON.stringify(state))
|
await stateFile.write(JSON.stringify(state))
|
||||||
|
|
||||||
const services = new Map<
|
const services = new Map<
|
||||||
@@ -70,13 +69,13 @@ export namespace App {
|
|||||||
git: git !== undefined,
|
git: git !== undefined,
|
||||||
path: {
|
path: {
|
||||||
config: Global.Path.config,
|
config: Global.Path.config,
|
||||||
|
state: Global.Path.state,
|
||||||
data,
|
data,
|
||||||
root: git ?? input.cwd,
|
root: git ?? input.cwd,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const result = {
|
const result = {
|
||||||
version: input.version,
|
|
||||||
services,
|
services,
|
||||||
info,
|
info,
|
||||||
}
|
}
|
||||||
@@ -108,7 +107,7 @@ export namespace App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function provide<T>(
|
export async function provide<T>(
|
||||||
input: { cwd: string; version: string },
|
input: { cwd: string },
|
||||||
cb: (app: Info) => Promise<T>,
|
cb: (app: Info) => Promise<T>,
|
||||||
) {
|
) {
|
||||||
const app = await create(input)
|
const app = await create(input)
|
||||||
@@ -124,12 +123,11 @@ export namespace App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function initialize() {
|
export async function initialize() {
|
||||||
const { info, version } = ctx.use()
|
const { info } = ctx.use()
|
||||||
info.time.initialized = Date.now()
|
info.time.initialized = Date.now()
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(info.path.data, APP_JSON),
|
path.join(info.path.data, APP_JSON),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version,
|
|
||||||
initialized: Date.now(),
|
initialized: Date.now(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export namespace AuthAnthropic {
|
|||||||
const pkce = await generatePKCE()
|
const pkce = await generatePKCE()
|
||||||
const url = new URL("https://claude.ai/oauth/authorize", import.meta.url)
|
const url = new URL("https://claude.ai/oauth/authorize", import.meta.url)
|
||||||
url.searchParams.set("code", "true")
|
url.searchParams.set("code", "true")
|
||||||
url.searchParams.set("client_id", "9d1c250a-e61b-44d9-88ed-5944d1962f5e")
|
url.searchParams.set("client_id", CLIENT_ID)
|
||||||
url.searchParams.set("response_type", "code")
|
url.searchParams.set("response_type", "code")
|
||||||
url.searchParams.set(
|
url.searchParams.set(
|
||||||
"redirect_uri",
|
"redirect_uri",
|
||||||
@@ -38,7 +38,7 @@ export namespace AuthAnthropic {
|
|||||||
code: splits[0],
|
code: splits[0],
|
||||||
state: splits[1],
|
state: splits[1],
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
client_id: CLIENT_ID,
|
||||||
redirect_uri: "https://console.anthropic.com/oauth/code/callback",
|
redirect_uri: "https://console.anthropic.com/oauth/code/callback",
|
||||||
code_verifier: verifier,
|
code_verifier: verifier,
|
||||||
}),
|
}),
|
||||||
@@ -48,6 +48,7 @@ export namespace AuthAnthropic {
|
|||||||
await Auth.set("anthropic", {
|
await Auth.set("anthropic", {
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
refresh: json.refresh_token as string,
|
refresh: json.refresh_token as string,
|
||||||
|
access: json.access_token as string,
|
||||||
expires: Date.now() + json.expires_in * 1000,
|
expires: Date.now() + json.expires_in * 1000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,7 @@ export namespace AuthAnthropic {
|
|||||||
export async function access() {
|
export async function access() {
|
||||||
const info = await Auth.get("anthropic")
|
const info = await Auth.get("anthropic")
|
||||||
if (!info || info.type !== "oauth") return
|
if (!info || info.type !== "oauth") return
|
||||||
|
if (info.access && info.expires > Date.now()) return info.access
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"https://console.anthropic.com/v1/oauth/token",
|
"https://console.anthropic.com/v1/oauth/token",
|
||||||
{
|
{
|
||||||
@@ -74,6 +76,7 @@ export namespace AuthAnthropic {
|
|||||||
await Auth.set("anthropic", {
|
await Auth.set("anthropic", {
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
refresh: json.refresh_token as string,
|
refresh: json.refresh_token as string,
|
||||||
|
access: json.access_token as string,
|
||||||
expires: Date.now() + json.expires_in * 1000,
|
expires: Date.now() + json.expires_in * 1000,
|
||||||
})
|
})
|
||||||
return json.access_token as string
|
return json.access_token as string
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export namespace Auth {
|
|||||||
export const Oauth = z.object({
|
export const Oauth = z.object({
|
||||||
type: z.literal("oauth"),
|
type: z.literal("oauth"),
|
||||||
refresh: z.string(),
|
refresh: z.string(),
|
||||||
|
access: z.string(),
|
||||||
expires: z.number(),
|
expires: z.number(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import { map, pipe, sortBy, values } from "remeda"
|
|||||||
|
|
||||||
export const AuthCommand = cmd({
|
export const AuthCommand = cmd({
|
||||||
command: "auth",
|
command: "auth",
|
||||||
describe: "manage credentials",
|
describe: "Manage credentials",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs
|
yargs
|
||||||
.command(AuthLoginCommand)
|
.command(AuthLoginCommand)
|
||||||
.command(AuthLogoutCommand)
|
.command(AuthLogoutCommand)
|
||||||
.command(AuthListCommand)
|
.command(AuthListCommand)
|
||||||
.demandCommand(),
|
.demandCommand(),
|
||||||
async handler() {},
|
async handler() { },
|
||||||
})
|
})
|
||||||
|
|
||||||
export const AuthListCommand = cmd({
|
export const AuthListCommand = cmd({
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Server } from "../../server/server"
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import type { CommandModule } from "yargs"
|
import type { CommandModule } from "yargs"
|
||||||
import { Config } from "../../config/config"
|
|
||||||
|
|
||||||
export const GenerateCommand = {
|
export const GenerateCommand = {
|
||||||
command: "generate",
|
command: "generate",
|
||||||
|
|||||||
@@ -6,68 +6,102 @@ import { Session } from "../../session"
|
|||||||
import { Share } from "../../share/share"
|
import { Share } from "../../share/share"
|
||||||
import { Message } from "../../session/message"
|
import { Message } from "../../session/message"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { VERSION } from "../version"
|
import { cmd } from "./cmd"
|
||||||
|
import { Flag } from "../../flag/flag"
|
||||||
const COLOR = [
|
import { Config } from "../../config/config"
|
||||||
UI.Style.TEXT_SUCCESS_BOLD,
|
|
||||||
UI.Style.TEXT_INFO_BOLD,
|
|
||||||
UI.Style.TEXT_HIGHLIGHT_BOLD,
|
|
||||||
UI.Style.TEXT_WARNING_BOLD,
|
|
||||||
]
|
|
||||||
|
|
||||||
const TOOL: Record<string, [string, string]> = {
|
const TOOL: Record<string, [string, string]> = {
|
||||||
opencode_todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||||
opencode_todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||||
opencode_bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
||||||
opencode_edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
||||||
opencode_glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
||||||
opencode_grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
|
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
|
||||||
opencode_list: ["List", UI.Style.TEXT_INFO_BOLD],
|
list: ["List", UI.Style.TEXT_INFO_BOLD],
|
||||||
opencode_read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
|
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
|
||||||
opencode_write: ["Write", UI.Style.TEXT_SUCCESS_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..]",
|
command: "run [message..]",
|
||||||
describe: "Run OpenCode with a message",
|
describe: "Run opencode with a message",
|
||||||
builder: (yargs: Argv) => {
|
builder: (yargs: Argv) => {
|
||||||
return yargs
|
return yargs
|
||||||
.positional("message", {
|
.positional("message", {
|
||||||
describe: "Message to send",
|
describe: "message to send",
|
||||||
type: "string",
|
type: "string",
|
||||||
array: true,
|
array: true,
|
||||||
default: [],
|
default: [],
|
||||||
})
|
})
|
||||||
|
.option("continue", {
|
||||||
|
alias: ["c"],
|
||||||
|
describe: "Continue the last session",
|
||||||
|
type: "boolean",
|
||||||
|
})
|
||||||
.option("session", {
|
.option("session", {
|
||||||
|
alias: ["s"],
|
||||||
describe: "Session ID to continue",
|
describe: "Session ID to continue",
|
||||||
type: "string",
|
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: {
|
handler: async (args) => {
|
||||||
message: string[]
|
|
||||||
session?: string
|
|
||||||
printLogs?: boolean
|
|
||||||
}) => {
|
|
||||||
const message = args.message.join(" ")
|
const message = args.message.join(" ")
|
||||||
await App.provide(
|
await App.provide(
|
||||||
{
|
{
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
version: VERSION,
|
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
await Share.init()
|
await Share.init()
|
||||||
const session = args.session
|
const session = await (async () => {
|
||||||
? await Session.get(args.session)
|
if (args.continue) {
|
||||||
: await Session.create()
|
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.empty()
|
||||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
||||||
UI.empty()
|
UI.empty()
|
||||||
UI.println(
|
|
||||||
UI.Style.TEXT_INFO_BOLD +
|
const cfg = await Config.get()
|
||||||
"~ https://dev.opencode.ai/s/" +
|
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),
|
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()
|
UI.empty()
|
||||||
|
|
||||||
@@ -75,8 +109,8 @@ export const RunCommand = {
|
|||||||
UI.println(
|
UI.println(
|
||||||
color + `|`,
|
color + `|`,
|
||||||
UI.Style.TEXT_NORMAL +
|
UI.Style.TEXT_NORMAL +
|
||||||
UI.Style.TEXT_DIM +
|
UI.Style.TEXT_DIM +
|
||||||
` ${type.padEnd(7, " ")}`,
|
` ${type.padEnd(7, " ")}`,
|
||||||
"",
|
"",
|
||||||
UI.Style.TEXT_NORMAL + title,
|
UI.Style.TEXT_NORMAL + title,
|
||||||
)
|
)
|
||||||
@@ -113,8 +147,6 @@ export const RunCommand = {
|
|||||||
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const { providerID, modelID } = await Provider.defaultModel()
|
|
||||||
await Session.chat({
|
await Session.chat({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
providerID,
|
providerID,
|
||||||
@@ -130,4 +162,4 @@ export const RunCommand = {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { App } from "../../app/app"
|
import { App } from "../../app/app"
|
||||||
import { LSP } from "../../lsp"
|
import { LSP } from "../../lsp"
|
||||||
import { VERSION } from "../version"
|
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
|
|
||||||
export const ScrapCommand = cmd({
|
export const ScrapCommand = cmd({
|
||||||
@@ -8,9 +7,12 @@ export const ScrapCommand = cmd({
|
|||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.positional("file", { type: "string", demandOption: true }),
|
yargs.positional("file", { type: "string", demandOption: true }),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
await App.provide({ cwd: process.cwd(), version: VERSION }, async (app) => {
|
await App.provide(
|
||||||
await LSP.touchFile(args.file, true)
|
{ cwd: process.cwd() },
|
||||||
console.log(await LSP.diagnostics())
|
async () => {
|
||||||
})
|
await LSP.touchFile(args.file, true)
|
||||||
|
console.log(await LSP.diagnostics())
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import type { Argv } from "yargs"
|
import type { Argv } from "yargs"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { VERSION } from "../version"
|
|
||||||
import * as prompts from "@clack/prompts"
|
import * as prompts from "@clack/prompts"
|
||||||
import { Installation } from "../../installation"
|
import { Installation } from "../../installation"
|
||||||
|
|
||||||
export const UpgradeCommand = {
|
export const UpgradeCommand = {
|
||||||
command: "upgrade [target]",
|
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) => {
|
builder: (yargs: Argv) => {
|
||||||
return yargs.positional("target", {
|
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",
|
type: "string",
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -27,7 +26,7 @@ export const UpgradeCommand = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const target = args.target ?? (await Installation.latest())
|
const target = args.target ?? (await Installation.latest())
|
||||||
prompts.log.info(`From ${VERSION} → ${target}`)
|
prompts.log.info(`From ${Installation.VERSION} → ${target}`)
|
||||||
const spinner = prompts.spinner()
|
const spinner = prompts.spinner()
|
||||||
spinner.start("Upgrading...")
|
spinner.start("Upgrading...")
|
||||||
const err = await Installation.upgrade(method, target).catch((err) => err)
|
const err = await Installation.upgrade(method, target).catch((err) => err)
|
||||||
@@ -41,69 +40,5 @@ export const UpgradeCommand = {
|
|||||||
}
|
}
|
||||||
spinner.stop("Upgrade complete")
|
spinner.stop("Upgrade complete")
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
|
||||||
|
|
||||||
/*
|
|
||||||
if (!process.execPath.includes(path.join(".opencode", "bin")) && false) {
|
|
||||||
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 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")
|
|
||||||
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}`)
|
|
||||||
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 { Log } from "../util/log"
|
||||||
|
import path from "path"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { ModelsDev } from "../provider/models"
|
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 {
|
export namespace Config {
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
export const state = App.state("config", async (app) => {
|
export const state = App.state("config", async (app) => {
|
||||||
let result: Info = {}
|
let result = await global()
|
||||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||||
const [resolved] = await Filesystem.findUp(
|
const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
|
||||||
file,
|
for (const resolved of found.toReversed()) {
|
||||||
app.path.cwd,
|
result = mergeDeep(result, await load(resolved))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.info("loaded", result)
|
log.info("loaded", result)
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
export const McpLocal = z.object({
|
export const McpLocal = z
|
||||||
type: z.literal("local"),
|
.object({
|
||||||
command: z.string().array(),
|
type: z.literal("local").describe("Type of MCP server connection"),
|
||||||
environment: z.record(z.string(), z.string()).optional(),
|
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({
|
export const McpRemote = z
|
||||||
type: z.literal("remote"),
|
.object({
|
||||||
url: z.string(),
|
type: z.literal("remote").describe("Type of MCP server connection"),
|
||||||
})
|
url: z.string().describe("URL of the remote MCP server"),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.openapi({
|
||||||
|
ref: "Config.McpRemote",
|
||||||
|
})
|
||||||
|
|
||||||
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
||||||
export type Mcp = z.infer<typeof Mcp>
|
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
|
export const Info = z
|
||||||
.object({
|
.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
|
provider: z
|
||||||
.record(
|
.record(
|
||||||
ModelsDev.Provider.partial().extend({
|
ModelsDev.Provider.partial().extend({
|
||||||
@@ -58,13 +161,72 @@ export namespace Config {
|
|||||||
options: z.record(z.any()).optional(),
|
options: z.record(z.any()).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional()
|
||||||
mcp: z.record(z.string(), Mcp).optional(),
|
.describe("Custom provider configurations and model overrides"),
|
||||||
|
mcp: z
|
||||||
|
.record(z.string(), Mcp)
|
||||||
|
.optional()
|
||||||
|
.describe("MCP (Model Context Protocol) server configurations"),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
.openapi({
|
||||||
|
ref: "Config.Info",
|
||||||
|
})
|
||||||
|
|
||||||
export type Info = z.output<typeof 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() {
|
export function get() {
|
||||||
return state()
|
return state()
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -1,4 +1,3 @@
|
|||||||
import { App } from "../app/app"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import fs from "fs/promises"
|
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,24 +0,0 @@
|
|||||||
import { z } from "zod"
|
|
||||||
import { Global } from "."
|
|
||||||
import { lazy } from "../util/lazy"
|
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
export namespace GlobalConfig {
|
|
||||||
export const Info = z.object({
|
|
||||||
autoupdate: z.boolean().optional(),
|
|
||||||
provider: z.string().optional(),
|
|
||||||
model: z.string().optional(),
|
|
||||||
})
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const get = lazy(async () => {
|
|
||||||
const toml = await import(path.join(Global.Path.config, "config"), {
|
|
||||||
with: {
|
|
||||||
type: "toml",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((mod) => mod.default)
|
|
||||||
.catch(() => ({}))
|
|
||||||
return Info.parse(toml)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import { xdgData, xdgCache, xdgConfig } from "xdg-basedir"
|
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
const app = "opencode"
|
const app = "opencode"
|
||||||
@@ -7,18 +7,23 @@ const app = "opencode"
|
|||||||
const data = path.join(xdgData!, app)
|
const data = path.join(xdgData!, app)
|
||||||
const cache = path.join(xdgCache!, app)
|
const cache = path.join(xdgCache!, app)
|
||||||
const config = path.join(xdgConfig!, app)
|
const config = path.join(xdgConfig!, app)
|
||||||
|
const state = path.join(xdgState!, app)
|
||||||
await Promise.all([
|
|
||||||
fs.mkdir(data, { recursive: true }),
|
|
||||||
fs.mkdir(config, { recursive: true }),
|
|
||||||
fs.mkdir(cache, { recursive: true }),
|
|
||||||
])
|
|
||||||
|
|
||||||
export namespace Global {
|
export namespace Global {
|
||||||
export const Path = {
|
export const Path = {
|
||||||
data,
|
data,
|
||||||
bin: path.join(data, "bin"),
|
bin: path.join(data, "bin"),
|
||||||
|
providers: path.join(config, "providers"),
|
||||||
cache,
|
cache,
|
||||||
config,
|
config,
|
||||||
|
state,
|
||||||
} as const
|
} 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,28 +9,21 @@ import yargs from "yargs"
|
|||||||
import { hideBin } from "yargs/helpers"
|
import { hideBin } from "yargs/helpers"
|
||||||
import { RunCommand } from "./cli/cmd/run"
|
import { RunCommand } from "./cli/cmd/run"
|
||||||
import { GenerateCommand } from "./cli/cmd/generate"
|
import { GenerateCommand } from "./cli/cmd/generate"
|
||||||
import { VERSION } from "./cli/version"
|
|
||||||
import { ScrapCommand } from "./cli/cmd/scrap"
|
import { ScrapCommand } from "./cli/cmd/scrap"
|
||||||
import { Log } from "./util/log"
|
import { Log } from "./util/log"
|
||||||
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
|
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
|
||||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||||
import { Provider } from "./provider/provider"
|
import { Provider } from "./provider/provider"
|
||||||
import { UI } from "./cli/ui"
|
import { UI } from "./cli/ui"
|
||||||
import { GlobalConfig } from "./global/config"
|
|
||||||
import { Installation } from "./installation"
|
import { Installation } from "./installation"
|
||||||
;(async () => {
|
import { Bus } from "./bus"
|
||||||
if (Installation.VERSION === "dev") return
|
import { Config } from "./config/config"
|
||||||
const config = await GlobalConfig.get()
|
import { NamedError } from "./util/error"
|
||||||
if (config.autoupdate === false) return
|
import { FormatError } from "./cli/error"
|
||||||
const latest = await Installation.latest()
|
|
||||||
if (Installation.VERSION === latest) return
|
|
||||||
const method = await Installation.method()
|
|
||||||
await Installation.upgrade(method, latest).catch(() => {})
|
|
||||||
})()
|
|
||||||
|
|
||||||
const cli = yargs(hideBin(process.argv))
|
const cli = yargs(hideBin(process.argv))
|
||||||
.scriptName("opencode")
|
.scriptName("opencode")
|
||||||
.version(VERSION)
|
.version(Installation.VERSION)
|
||||||
.option("print-logs", {
|
.option("print-logs", {
|
||||||
describe: "Print logs to stderr",
|
describe: "Print logs to stderr",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
@@ -38,14 +31,14 @@ const cli = yargs(hideBin(process.argv))
|
|||||||
.middleware(async () => {
|
.middleware(async () => {
|
||||||
await Log.init({ print: process.argv.includes("--print-logs") })
|
await Log.init({ print: process.argv.includes("--print-logs") })
|
||||||
Log.Default.info("opencode", {
|
Log.Default.info("opencode", {
|
||||||
version: VERSION,
|
version: Installation.VERSION,
|
||||||
args: process.argv.slice(2),
|
args: process.argv.slice(2),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.usage("\n" + UI.logo())
|
.usage("\n" + UI.logo())
|
||||||
.command({
|
.command({
|
||||||
command: "$0 [project]",
|
command: "$0 [project]",
|
||||||
describe: "Start opencode TUI",
|
describe: "start opencode TUI",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.positional("project", {
|
yargs.positional("project", {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -55,52 +48,65 @@ const cli = yargs(hideBin(process.argv))
|
|||||||
while (true) {
|
while (true) {
|
||||||
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
||||||
process.chdir(cwd)
|
process.chdir(cwd)
|
||||||
const result = await App.provide(
|
const result = await App.provide({ cwd }, async (app) => {
|
||||||
{ cwd, version: VERSION },
|
const providers = await Provider.list()
|
||||||
async (app) => {
|
if (Object.keys(providers).length === 0) {
|
||||||
const providers = await Provider.list()
|
return "needs_provider"
|
||||||
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()
|
;(async () => {
|
||||||
const server = Server.listen()
|
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"]
|
await proc.exited
|
||||||
let cwd = new URL("../../tui/cmd/opencode", import.meta.url)
|
server.stop()
|
||||||
.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 proc.exited
|
|
||||||
await server.stop()
|
|
||||||
|
|
||||||
return "done"
|
return "done"
|
||||||
},
|
})
|
||||||
)
|
|
||||||
if (result === "done") break
|
if (result === "done") break
|
||||||
if (result === "needs_provider") {
|
if (result === "needs_provider") {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
@@ -116,21 +122,38 @@ const cli = yargs(hideBin(process.argv))
|
|||||||
.command(ScrapCommand)
|
.command(ScrapCommand)
|
||||||
.command(AuthCommand)
|
.command(AuthCommand)
|
||||||
.command(UpgradeCommand)
|
.command(UpgradeCommand)
|
||||||
.fail((msg, err) => {
|
.fail((msg) => {
|
||||||
if (
|
if (
|
||||||
msg.startsWith("Unknown argument") ||
|
msg.startsWith("Unknown argument") ||
|
||||||
msg.startsWith("Not enough non-option arguments")
|
msg.startsWith("Not enough non-option arguments")
|
||||||
) {
|
) {
|
||||||
cli.showHelp("log")
|
cli.showHelp("log")
|
||||||
}
|
}
|
||||||
Log.Default.error(msg, {
|
|
||||||
err,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await cli.parse()
|
await cli.parse()
|
||||||
} catch (e) {
|
} 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",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,24 @@ import path from "path"
|
|||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
const OPENCODE_VERSION: string
|
||||||
|
}
|
||||||
|
|
||||||
export namespace Installation {
|
export namespace Installation {
|
||||||
export type Method = Awaited<ReturnType<typeof method>>
|
export type Method = Awaited<ReturnType<typeof method>>
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Updated: Bus.event(
|
||||||
|
"installation.updated",
|
||||||
|
z.object({
|
||||||
|
version: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
export const Info = z
|
export const Info = z
|
||||||
.object({
|
.object({
|
||||||
version: z.string(),
|
version: z.string(),
|
||||||
@@ -23,6 +37,14 @@ export namespace Installation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSnapshot() {
|
||||||
|
return VERSION.startsWith("0.0.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDev() {
|
||||||
|
return VERSION === "dev"
|
||||||
|
}
|
||||||
|
|
||||||
export async function method() {
|
export async function method() {
|
||||||
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
|
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
|
||||||
const exec = process.execPath.toLowerCase()
|
const exec = process.execPath.toLowerCase()
|
||||||
@@ -99,6 +121,6 @@ export namespace Installation {
|
|||||||
export async function latest() {
|
export async function latest() {
|
||||||
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
|
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => data.tag_name.slice(1))
|
.then((data) => data.tag_name.slice(1) as string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export namespace LSPServer {
|
|||||||
const proc = Bun.spawn({
|
const proc = Bun.spawn({
|
||||||
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
|
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
|
||||||
env: { ...process.env, GOBIN: Global.Path.bin },
|
env: { ...process.env, GOBIN: Global.Path.bin },
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
stdin: "pipe",
|
||||||
})
|
})
|
||||||
const exit = await proc.exited
|
const exit = await proc.exited
|
||||||
if (exit !== 0) {
|
if (exit !== 0) {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export namespace Permission {
|
|||||||
title: Info["title"]
|
title: Info["title"]
|
||||||
metadata: Info["metadata"]
|
metadata: Info["metadata"]
|
||||||
}) {
|
}) {
|
||||||
|
return
|
||||||
const { pending, approved } = state()
|
const { pending, approved } = state()
|
||||||
log.info("asking", {
|
log.info("asking", {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { lazy } from "../util/lazy"
|
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
@@ -14,17 +13,19 @@ export namespace ModelsDev {
|
|||||||
attachment: z.boolean(),
|
attachment: z.boolean(),
|
||||||
reasoning: z.boolean(),
|
reasoning: z.boolean(),
|
||||||
temperature: z.boolean(),
|
temperature: z.boolean(),
|
||||||
|
tool_call: z.boolean(),
|
||||||
cost: z.object({
|
cost: z.object({
|
||||||
input: z.number(),
|
input: z.number(),
|
||||||
output: z.number(),
|
output: z.number(),
|
||||||
inputCached: z.number(),
|
cache_read: z.number().optional(),
|
||||||
outputCached: z.number(),
|
cache_write: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
limit: z.object({
|
limit: z.object({
|
||||||
context: z.number(),
|
context: z.number(),
|
||||||
output: z.number(),
|
output: z.number(),
|
||||||
}),
|
}),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
options: z.record(z.any()),
|
||||||
})
|
})
|
||||||
.openapi({
|
.openapi({
|
||||||
ref: "Model.Info",
|
ref: "Model.Info",
|
||||||
@@ -33,6 +34,7 @@ export namespace ModelsDev {
|
|||||||
|
|
||||||
export const Provider = z
|
export const Provider = z
|
||||||
.object({
|
.object({
|
||||||
|
api: z.string().optional(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
env: z.array(z.string()),
|
env: z.array(z.string()),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -64,30 +66,4 @@ export namespace ModelsDev {
|
|||||||
throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
|
throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
|
||||||
await Bun.write(file, result)
|
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"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,13 @@ import { TaskTool } from "../tool/task"
|
|||||||
export namespace Provider {
|
export namespace Provider {
|
||||||
const log = Log.create({ service: "provider" })
|
const log = Log.create({ service: "provider" })
|
||||||
|
|
||||||
type CustomLoader = (
|
type CustomLoader = (provider: ModelsDev.Provider) => Promise<
|
||||||
provider: ModelsDev.Provider,
|
| {
|
||||||
) => Promise<Record<string, any> | false>
|
getModel?: (sdk: any, modelID: string) => Promise<any>
|
||||||
|
options: Record<string, any>
|
||||||
|
}
|
||||||
|
| false
|
||||||
|
>
|
||||||
|
|
||||||
type Source = "env" | "config" | "custom" | "api"
|
type Source = "env" | "config" | "custom" | "api"
|
||||||
|
|
||||||
@@ -40,27 +44,56 @@ export namespace Provider {
|
|||||||
for (const model of Object.values(provider.models)) {
|
for (const model of Object.values(provider.models)) {
|
||||||
model.cost = {
|
model.cost = {
|
||||||
input: 0,
|
input: 0,
|
||||||
inputCached: 0,
|
|
||||||
output: 0,
|
output: 0,
|
||||||
outputCached: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
apiKey: "",
|
options: {
|
||||||
headers: {
|
apiKey: "",
|
||||||
authorization: `Bearer ${access}`,
|
async fetch(input: any, init: any) {
|
||||||
"anthropic-beta": "oauth-2025-04-20",
|
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,
|
||||||
|
})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
openai: async () => {
|
||||||
|
return {
|
||||||
|
async getModel(sdk: any, modelID: string) {
|
||||||
|
return sdk.responses(modelID)
|
||||||
|
},
|
||||||
|
options: {},
|
||||||
|
}
|
||||||
|
},
|
||||||
"amazon-bedrock": async () => {
|
"amazon-bedrock": async () => {
|
||||||
if (!process.env["AWS_PROFILE"]) return false
|
if (!process.env["AWS_PROFILE"]) false
|
||||||
|
|
||||||
|
const region = process.env["AWS_REGION"] ?? "us-east-1"
|
||||||
|
|
||||||
const { fromNodeProviderChain } = await import(
|
const { fromNodeProviderChain } = await import(
|
||||||
await BunProc.install("@aws-sdk/credential-providers")
|
await BunProc.install("@aws-sdk/credential-providers")
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
region: process.env["AWS_REGION"] ?? "us-east-1",
|
options: {
|
||||||
credentialProvider: fromNodeProviderChain(),
|
region,
|
||||||
|
credentialProvider: fromNodeProviderChain(),
|
||||||
|
},
|
||||||
|
async getModel(sdk: any, modelID: string) {
|
||||||
|
if (modelID.includes("claude")) {
|
||||||
|
const prefix = region.split("-")[0]
|
||||||
|
modelID = `${prefix}.${modelID}`
|
||||||
|
}
|
||||||
|
return sdk.languageModel(modelID)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -73,6 +106,7 @@ export namespace Provider {
|
|||||||
[providerID: string]: {
|
[providerID: string]: {
|
||||||
source: Source
|
source: Source
|
||||||
info: ModelsDev.Provider
|
info: ModelsDev.Provider
|
||||||
|
getModel?: (sdk: any, modelID: string) => Promise<any>
|
||||||
options: Record<string, any>
|
options: Record<string, any>
|
||||||
}
|
}
|
||||||
} = {}
|
} = {}
|
||||||
@@ -88,23 +122,28 @@ export namespace Provider {
|
|||||||
id: string,
|
id: string,
|
||||||
options: Record<string, any>,
|
options: Record<string, any>,
|
||||||
source: Source,
|
source: Source,
|
||||||
|
getModel?: (sdk: any, modelID: string) => Promise<any>,
|
||||||
) {
|
) {
|
||||||
const provider = providers[id]
|
const provider = providers[id]
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
|
const info = database[id]
|
||||||
|
if (!info) return
|
||||||
|
if (info.api) options["baseURL"] = info.api
|
||||||
providers[id] = {
|
providers[id] = {
|
||||||
source,
|
source,
|
||||||
info: database[id],
|
info,
|
||||||
options,
|
options,
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
provider.options = mergeDeep(provider.options, options)
|
provider.options = mergeDeep(provider.options, options)
|
||||||
provider.source = source
|
provider.source = source
|
||||||
|
provider.getModel = getModel ?? provider.getModel
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [providerID, provider] of Object.entries(
|
const configProviders = Object.entries(config.provider ?? {})
|
||||||
config.provider ?? {},
|
|
||||||
)) {
|
for (const [providerID, provider] of configProviders) {
|
||||||
const existing = database[providerID]
|
const existing = database[providerID]
|
||||||
const parsed: ModelsDev.Provider = {
|
const parsed: ModelsDev.Provider = {
|
||||||
id: providerID,
|
id: providerID,
|
||||||
@@ -122,13 +161,19 @@ export namespace Provider {
|
|||||||
attachment: model.attachment ?? existing?.attachment ?? false,
|
attachment: model.attachment ?? existing?.attachment ?? false,
|
||||||
reasoning: model.reasoning ?? existing?.reasoning ?? false,
|
reasoning: model.reasoning ?? existing?.reasoning ?? false,
|
||||||
temperature: model.temperature ?? existing?.temperature ?? false,
|
temperature: model.temperature ?? existing?.temperature ?? false,
|
||||||
cost: model.cost ??
|
tool_call: model.tool_call ?? existing?.tool_call ?? true,
|
||||||
existing?.cost ?? {
|
cost: {
|
||||||
input: 0,
|
...existing?.cost,
|
||||||
output: 0,
|
...model.cost,
|
||||||
inputCached: 0,
|
input: 0,
|
||||||
outputCached: 0,
|
output: 0,
|
||||||
},
|
cache_read: 0,
|
||||||
|
cache_write: 0,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...existing?.options,
|
||||||
|
...model.options,
|
||||||
|
},
|
||||||
limit: model.limit ??
|
limit: model.limit ??
|
||||||
existing?.limit ?? {
|
existing?.limit ?? {
|
||||||
context: 0,
|
context: 0,
|
||||||
@@ -140,8 +185,12 @@ export namespace Provider {
|
|||||||
database[providerID] = parsed
|
database[providerID] = parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const disabled = await Config.get().then(
|
||||||
|
(cfg) => new Set(cfg.disabled_providers ?? []),
|
||||||
|
)
|
||||||
// load env
|
// load env
|
||||||
for (const [providerID, provider] of Object.entries(database)) {
|
for (const [providerID, provider] of Object.entries(database)) {
|
||||||
|
if (disabled.has(providerID)) continue
|
||||||
if (provider.env.some((item) => process.env[item])) {
|
if (provider.env.some((item) => process.env[item])) {
|
||||||
mergeProvider(providerID, {}, "env")
|
mergeProvider(providerID, {}, "env")
|
||||||
}
|
}
|
||||||
@@ -149,6 +198,7 @@ export namespace Provider {
|
|||||||
|
|
||||||
// load apikeys
|
// load apikeys
|
||||||
for (const [providerID, provider] of Object.entries(await Auth.all())) {
|
for (const [providerID, provider] of Object.entries(await Auth.all())) {
|
||||||
|
if (disabled.has(providerID)) continue
|
||||||
if (provider.type === "api") {
|
if (provider.type === "api") {
|
||||||
mergeProvider(providerID, { apiKey: provider.key }, "api")
|
mergeProvider(providerID, { apiKey: provider.key }, "api")
|
||||||
}
|
}
|
||||||
@@ -156,18 +206,22 @@ export namespace Provider {
|
|||||||
|
|
||||||
// load custom
|
// load custom
|
||||||
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
|
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||||
|
if (disabled.has(providerID)) continue
|
||||||
const result = await fn(database[providerID])
|
const result = await fn(database[providerID])
|
||||||
if (result) mergeProvider(providerID, result, "custom")
|
if (result)
|
||||||
|
mergeProvider(providerID, result.options, "custom", result.getModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// load config
|
// load config
|
||||||
for (const [providerID, provider] of Object.entries(
|
for (const [providerID, provider] of configProviders) {
|
||||||
config.provider ?? {},
|
|
||||||
)) {
|
|
||||||
mergeProvider(providerID, provider.options ?? {}, "config")
|
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 })
|
log.info("found", { providerID })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,8 +244,8 @@ export namespace Provider {
|
|||||||
const s = await state()
|
const s = await state()
|
||||||
const existing = s.sdk.get(provider.id)
|
const existing = s.sdk.get(provider.id)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const [pkg, version] = await ModelsDev.pkg(provider.npm ?? provider.id)
|
const pkg = provider.npm ?? provider.id
|
||||||
const mod = await import(await BunProc.install(pkg, version))
|
const mod = await import(await BunProc.install(pkg, "latest"))
|
||||||
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
||||||
const loaded = fn(s.providers[provider.id]?.options)
|
const loaded = fn(s.providers[provider.id]?.options)
|
||||||
s.sdk.set(provider.id, loaded)
|
s.sdk.set(provider.id, loaded)
|
||||||
@@ -218,9 +272,9 @@ export namespace Provider {
|
|||||||
const sdk = await getSDK(provider.info)
|
const sdk = await getSDK(provider.info)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const language =
|
const language = provider.getModel
|
||||||
// @ts-expect-error
|
? await provider.getModel(sdk, modelID)
|
||||||
"responses" in sdk ? sdk.responses(modelID) : sdk.languageModel(modelID)
|
: sdk.languageModel(modelID)
|
||||||
log.info("found", { providerID, modelID })
|
log.info("found", { providerID, modelID })
|
||||||
s.models.set(key, {
|
s.models.set(key, {
|
||||||
info,
|
info,
|
||||||
@@ -257,7 +311,15 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function defaultModel() {
|
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")
|
if (!provider) throw new Error("no providers found")
|
||||||
const [model] = sort(Object.values(provider.info.models))
|
const [model] = sort(Object.values(provider.info.models))
|
||||||
if (!model) throw new Error("no models found")
|
if (!model) throw new Error("no models found")
|
||||||
@@ -267,6 +329,14 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseModel(model: string) {
|
||||||
|
const [providerID, ...rest] = model.split("/")
|
||||||
|
return {
|
||||||
|
providerID: providerID,
|
||||||
|
modelID: rest.join("/"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const TOOLS = [
|
const TOOLS = [
|
||||||
BashTool,
|
BashTool,
|
||||||
EditTool,
|
EditTool,
|
||||||
@@ -285,11 +355,20 @@ export namespace Provider {
|
|||||||
TaskTool,
|
TaskTool,
|
||||||
TodoReadTool,
|
TodoReadTool,
|
||||||
]
|
]
|
||||||
|
|
||||||
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
|
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
|
||||||
anthropic: TOOLS.filter((t) => t.id !== "opencode.patch"),
|
anthropic: TOOLS.filter((t) => t.id !== "patch"),
|
||||||
openai: TOOLS,
|
openai: TOOLS.map((t) => ({
|
||||||
|
...t,
|
||||||
|
parameters: optionalToNullable(t.parameters),
|
||||||
|
})),
|
||||||
|
azure: TOOLS.map((t) => ({
|
||||||
|
...t,
|
||||||
|
parameters: optionalToNullable(t.parameters),
|
||||||
|
})),
|
||||||
google: TOOLS,
|
google: TOOLS,
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function tools(providerID: string) {
|
export async function tools(providerID: string) {
|
||||||
/*
|
/*
|
||||||
const cfg = await Config.get()
|
const cfg = await Config.get()
|
||||||
@@ -301,6 +380,38 @@ export namespace Provider {
|
|||||||
return TOOL_MAPPING[providerID] ?? TOOLS
|
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(
|
export const ModelNotFoundError = NamedError.create(
|
||||||
"ProviderModelNotFoundError",
|
"ProviderModelNotFoundError",
|
||||||
z.object({
|
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,10 +12,10 @@ import { App } from "../app/app"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { mapValues } from "remeda"
|
import { mapValues } from "remeda"
|
||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
import { Fzf } from "../external/fzf"
|
|
||||||
import { ModelsDev } from "../provider/models"
|
import { ModelsDev } from "../provider/models"
|
||||||
import { Ripgrep } from "../external/ripgrep"
|
import { Ripgrep } from "../external/ripgrep"
|
||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
|
import { Config } from "../config/config"
|
||||||
|
|
||||||
const ERRORS = {
|
const ERRORS = {
|
||||||
400: {
|
400: {
|
||||||
@@ -141,6 +141,25 @@ export namespace Server {
|
|||||||
return c.json(App.info())
|
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(
|
.post(
|
||||||
"/app_initialize",
|
"/app_initialize",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import type { Tool } from "../tool/tool"
|
|||||||
import { SystemPrompt } from "./system"
|
import { SystemPrompt } from "./system"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import type { ModelsDev } from "../provider/models"
|
import type { ModelsDev } from "../provider/models"
|
||||||
|
import { Installation } from "../installation"
|
||||||
|
import { Config } from "../config/config"
|
||||||
|
import { ProviderTransform } from "../provider/transform"
|
||||||
|
|
||||||
export namespace Session {
|
export namespace Session {
|
||||||
const log = Log.create({ service: "session" })
|
const log = Log.create({ service: "session" })
|
||||||
@@ -40,11 +43,11 @@ export namespace Session {
|
|||||||
parentID: Identifier.schema("session").optional(),
|
parentID: Identifier.schema("session").optional(),
|
||||||
share: z
|
share: z
|
||||||
.object({
|
.object({
|
||||||
secret: z.string(),
|
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
|
version: z.string(),
|
||||||
time: z.object({
|
time: z.object({
|
||||||
created: z.number(),
|
created: z.number(),
|
||||||
updated: z.number(),
|
updated: z.number(),
|
||||||
@@ -55,6 +58,12 @@ export namespace Session {
|
|||||||
})
|
})
|
||||||
export type Info = z.output<typeof Info>
|
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 = {
|
export const Event = {
|
||||||
Updated: Bus.event(
|
Updated: Bus.event(
|
||||||
"session.updated",
|
"session.updated",
|
||||||
@@ -70,19 +79,30 @@ export namespace Session {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = App.state("session", () => {
|
const state = App.state(
|
||||||
const sessions = new Map<string, Info>()
|
"session",
|
||||||
const messages = new Map<string, Message.Info[]>()
|
() => {
|
||||||
|
const sessions = new Map<string, Info>()
|
||||||
|
const messages = new Map<string, Message.Info[]>()
|
||||||
|
const pending = new Map<string, AbortController>()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
messages,
|
messages,
|
||||||
}
|
pending,
|
||||||
})
|
}
|
||||||
|
},
|
||||||
|
async (state) => {
|
||||||
|
for (const [_, controller] of state.pending) {
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
export async function create(parentID?: string) {
|
export async function create(parentID?: string) {
|
||||||
const result: Info = {
|
const result: Info = {
|
||||||
id: Identifier.descending("session"),
|
id: Identifier.descending("session"),
|
||||||
|
version: Installation.VERSION,
|
||||||
parentID,
|
parentID,
|
||||||
title:
|
title:
|
||||||
(parentID ? "Child session - " : "New Session - ") +
|
(parentID ? "Child session - " : "New Session - ") +
|
||||||
@@ -95,7 +115,8 @@ export namespace Session {
|
|||||||
log.info("created", result)
|
log.info("created", result)
|
||||||
state().sessions.set(result.id, result)
|
state().sessions.set(result.id, result)
|
||||||
await Storage.writeJSON("session/info/" + 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) => {
|
share(result.id).then((share) => {
|
||||||
update(result.id, (draft) => {
|
update(result.id, (draft) => {
|
||||||
draft.share = share
|
draft.share = share
|
||||||
@@ -117,13 +138,21 @@ export namespace Session {
|
|||||||
return read as Info
|
return read as Info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getShare(id: string) {
|
||||||
|
return Storage.readJSON<ShareInfo>("session/share/" + id)
|
||||||
|
}
|
||||||
|
|
||||||
export async function share(id: string) {
|
export async function share(id: string) {
|
||||||
const session = await get(id)
|
const session = await get(id)
|
||||||
if (session.share) return session.share
|
if (session.share) return session.share
|
||||||
const share = await Share.create(id)
|
const share = await Share.create(id)
|
||||||
await update(id, (draft) => {
|
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)) {
|
for (const msg of await messages(id)) {
|
||||||
await Share.sync("session/message/" + id + "/" + msg.id, msg)
|
await Share.sync("session/message/" + id + "/" + msg.id, msg)
|
||||||
}
|
}
|
||||||
@@ -169,10 +198,10 @@ export namespace Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function abort(sessionID: string) {
|
export function abort(sessionID: string) {
|
||||||
const controller = pending.get(sessionID)
|
const controller = state().pending.get(sessionID)
|
||||||
if (!controller) return false
|
if (!controller) return false
|
||||||
controller.abort()
|
controller.abort()
|
||||||
pending.delete(sessionID)
|
state().pending.delete(sessionID)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,8 +237,9 @@ export namespace Session {
|
|||||||
previous.metadata.assistant.tokens.cache.write +
|
previous.metadata.assistant.tokens.cache.write +
|
||||||
previous.metadata.assistant.tokens.output
|
previous.metadata.assistant.tokens.output
|
||||||
if (
|
if (
|
||||||
|
model.info.limit.context &&
|
||||||
tokens >
|
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({
|
await summarize({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -232,20 +262,12 @@ export namespace Session {
|
|||||||
if (msgs.length === 0 && !session.parentID) {
|
if (msgs.length === 0 && !session.parentID) {
|
||||||
generateText({
|
generateText({
|
||||||
maxTokens: input.providerID === "google" ? 1024 : 20,
|
maxTokens: input.providerID === "google" ? 1024 : 20,
|
||||||
|
providerOptions: model.info.options,
|
||||||
messages: [
|
messages: [
|
||||||
...SystemPrompt.title(input.providerID).map(
|
...SystemPrompt.title(input.providerID).map(
|
||||||
(x): CoreMessage => ({
|
(x): CoreMessage => ({
|
||||||
role: "system",
|
role: "system",
|
||||||
content: x,
|
content: x,
|
||||||
providerOptions: {
|
|
||||||
...(input.providerID === "anthropic"
|
|
||||||
? {
|
|
||||||
anthropic: {
|
|
||||||
cacheControl: { type: "ephemeral" },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
...convertToCoreMessages([
|
...convertToCoreMessages([
|
||||||
@@ -255,7 +277,9 @@ export namespace Session {
|
|||||||
parts: toParts(input.parts),
|
parts: toParts(input.parts),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
],
|
].map((msg, i) =>
|
||||||
|
ProviderTransform.message(msg, i, input.providerID, input.modelID),
|
||||||
|
),
|
||||||
model: model.language,
|
model: model.language,
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -328,6 +352,16 @@ export namespace Session {
|
|||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
abort: abort.signal,
|
abort: abort.signal,
|
||||||
messageID: next.id,
|
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] = {
|
next.metadata!.tool![opts.toolCallId] = {
|
||||||
...result.metadata,
|
...result.metadata,
|
||||||
@@ -474,30 +508,22 @@ export namespace Session {
|
|||||||
toolCallStreaming: true,
|
toolCallStreaming: true,
|
||||||
abortSignal: abort.signal,
|
abortSignal: abort.signal,
|
||||||
maxSteps: 1000,
|
maxSteps: 1000,
|
||||||
|
providerOptions: model.info.options,
|
||||||
messages: [
|
messages: [
|
||||||
...system.map(
|
...system.map(
|
||||||
(x, index): CoreMessage => ({
|
(x): CoreMessage => ({
|
||||||
role: "system",
|
role: "system",
|
||||||
content: x,
|
content: x,
|
||||||
providerOptions: {
|
|
||||||
...(input.providerID === "anthropic" && index < 4
|
|
||||||
? {
|
|
||||||
anthropic: {
|
|
||||||
cacheControl: { type: "ephemeral" },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
...convertToCoreMessages(
|
...convertToCoreMessages(
|
||||||
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
|
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
|
||||||
),
|
),
|
||||||
],
|
].map((msg, i) =>
|
||||||
temperature: model.info.id === "codex-mini-latest" ? undefined : 0,
|
ProviderTransform.message(msg, i, input.providerID, input.modelID),
|
||||||
tools: {
|
),
|
||||||
...tools,
|
temperature: model.info.temperature ? 0 : undefined,
|
||||||
},
|
tools: model.info.tool_call === false ? undefined : tools,
|
||||||
model: model.language,
|
model: model.language,
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
@@ -558,7 +584,7 @@ export namespace Session {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case "tool-call-delta":
|
case "tool-call-delta":
|
||||||
break
|
continue
|
||||||
|
|
||||||
// for some reason ai sdk claims to not send this part but it does
|
// for some reason ai sdk claims to not send this part but it does
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
@@ -593,6 +619,7 @@ export namespace Session {
|
|||||||
l.info("unhandled", {
|
l.info("unhandled", {
|
||||||
type: value.type,
|
type: value.type,
|
||||||
})
|
})
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
await updateMessage(next)
|
await updateMessage(next)
|
||||||
}
|
}
|
||||||
@@ -721,17 +748,16 @@ export namespace Session {
|
|||||||
await updateMessage(next)
|
await updateMessage(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = new Map<string, AbortController>()
|
|
||||||
function lock(sessionID: string) {
|
function lock(sessionID: string) {
|
||||||
log.info("locking", { sessionID })
|
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()
|
const controller = new AbortController()
|
||||||
pending.set(sessionID, controller)
|
state().pending.set(sessionID, controller)
|
||||||
return {
|
return {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
[Symbol.dispose]() {
|
[Symbol.dispose]() {
|
||||||
log.info("unlocking", { sessionID })
|
log.info("unlocking", { sessionID })
|
||||||
pending.delete(sessionID)
|
state().pending.delete(sessionID)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -756,6 +782,16 @@ export namespace Session {
|
|||||||
cost: new Decimal(0)
|
cost: new Decimal(0)
|
||||||
.add(new Decimal(tokens.input).mul(model.cost.input).div(1_000_000))
|
.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.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(),
|
.toNumber(),
|
||||||
tokens,
|
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
|
- ensure it is not more than 50 characters long
|
||||||
- the title should be a summary of the user's message
|
- the title should be a summary of the user's message
|
||||||
- it should be one line long
|
- it should be one line long
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export namespace SystemPrompt {
|
|||||||
export async function environment() {
|
export async function environment() {
|
||||||
const app = App.info()
|
const app = App.info()
|
||||||
|
|
||||||
const tree = async () => {
|
;async () => {
|
||||||
const files = await Ripgrep.files({
|
const files = await Ripgrep.files({
|
||||||
cwd: app.path.cwd,
|
cwd: app.path.cwd,
|
||||||
})
|
})
|
||||||
@@ -82,9 +82,9 @@ export namespace SystemPrompt {
|
|||||||
` Platform: ${process.platform}`,
|
` Platform: ${process.platform}`,
|
||||||
` Today's date: ${new Date().toDateString()}`,
|
` Today's date: ${new Date().toDateString()}`,
|
||||||
`</env>`,
|
`</env>`,
|
||||||
`<project>`,
|
// `<project>`,
|
||||||
` ${app.git ? await tree() : ""}`,
|
// ` ${app.git ? await tree() : ""}`,
|
||||||
`</project>`,
|
// `</project>`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
|
import { Installation } from "../installation"
|
||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
import { Storage } from "../storage/storage"
|
import { Storage } from "../storage/storage"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
@@ -19,10 +20,11 @@ export namespace Share {
|
|||||||
export async function sync(key: string, content: any) {
|
export async function sync(key: string, content: any) {
|
||||||
const [root, ...splits] = key.split("/")
|
const [root, ...splits] = key.split("/")
|
||||||
if (root !== "session") return
|
if (root !== "session") return
|
||||||
const [, sessionID] = splits
|
const [sub, sessionID] = splits
|
||||||
const session = await Session.get(sessionID)
|
if (sub === "share") return
|
||||||
if (!session.share) return
|
const share = await Session.getShare(sessionID).catch(() => {})
|
||||||
const { secret } = session.share
|
if (!share) return
|
||||||
|
const { secret } = share
|
||||||
pending.set(key, content)
|
pending.set(key, content)
|
||||||
queue = queue
|
queue = queue
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -55,7 +57,10 @@ export namespace Share {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const URL =
|
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) {
|
export async function create(sessionID: string) {
|
||||||
return fetch(`${URL}/share_create`, {
|
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) {
|
export async function readJSON<T>(key: string) {
|
||||||
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
|
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
|
const MAX_TIMEOUT = 10 * 60 * 1000
|
||||||
|
|
||||||
export const BashTool = Tool.define({
|
export const BashTool = Tool.define({
|
||||||
id: "opencode.bash",
|
id: "bash",
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
command: z.string().describe("The command to execute"),
|
command: z.string().describe("The command to execute"),
|
||||||
@@ -35,7 +35,7 @@ export const BashTool = Tool.define({
|
|||||||
.min(0)
|
.min(0)
|
||||||
.max(MAX_TIMEOUT)
|
.max(MAX_TIMEOUT)
|
||||||
.describe("Optional timeout in milliseconds")
|
.describe("Optional timeout in milliseconds")
|
||||||
.nullable(),
|
.optional(),
|
||||||
description: z
|
description: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import DESCRIPTION from "./edit.txt"
|
|||||||
import { App } from "../app/app"
|
import { App } from "../app/app"
|
||||||
|
|
||||||
export const EditTool = Tool.define({
|
export const EditTool = Tool.define({
|
||||||
id: "opencode.edit",
|
id: "edit",
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||||
@@ -21,7 +21,7 @@ export const EditTool = Tool.define({
|
|||||||
),
|
),
|
||||||
replaceAll: z
|
replaceAll: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.nullable()
|
.optional()
|
||||||
.describe("Replace all occurences of old_string (default false)"),
|
.describe("Replace all occurences of old_string (default false)"),
|
||||||
}),
|
}),
|
||||||
async execute(params, ctx) {
|
async execute(params, ctx) {
|
||||||
@@ -35,7 +35,7 @@ export const EditTool = Tool.define({
|
|||||||
: path.join(app.path.cwd, params.filePath)
|
: path.join(app.path.cwd, params.filePath)
|
||||||
|
|
||||||
await Permission.ask({
|
await Permission.ask({
|
||||||
id: "opencode.edit",
|
id: "edit",
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
title: "Edit this file: " + filepath,
|
title: "Edit this file: " + filepath,
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -87,7 +87,9 @@ export const EditTool = Tool.define({
|
|||||||
await file.write(contentNew)
|
await file.write(contentNew)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const diff = createTwoFilesPatch(filepath, filepath, contentOld, contentNew)
|
const diff = trimDiff(
|
||||||
|
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
|
||||||
|
)
|
||||||
|
|
||||||
FileTimes.read(ctx.sessionID, filepath)
|
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"
|
import DESCRIPTION from "./glob.txt"
|
||||||
|
|
||||||
export const GlobTool = Tool.define({
|
export const GlobTool = Tool.define({
|
||||||
id: "opencode.glob",
|
id: "glob",
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
pattern: z.string().describe("The glob pattern to match files against"),
|
pattern: z.string().describe("The glob pattern to match files against"),
|
||||||
path: z
|
path: z
|
||||||
.string()
|
.string()
|
||||||
.nullable()
|
.optional()
|
||||||
.describe(
|
.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.`,
|
`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"
|
import DESCRIPTION from "./grep.txt"
|
||||||
|
|
||||||
export const GrepTool = Tool.define({
|
export const GrepTool = Tool.define({
|
||||||
id: "opencode.grep",
|
id: "grep",
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
pattern: z
|
pattern: z
|
||||||
@@ -14,13 +14,13 @@ export const GrepTool = Tool.define({
|
|||||||
.describe("The regex pattern to search for in file contents"),
|
.describe("The regex pattern to search for in file contents"),
|
||||||
path: z
|
path: z
|
||||||
.string()
|
.string()
|
||||||
.nullable()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
"The directory to search in. Defaults to the current working directory.",
|
"The directory to search in. Defaults to the current working directory.",
|
||||||
),
|
),
|
||||||
include: z
|
include: z
|
||||||
.string()
|
.string()
|
||||||
.nullable()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
|
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const IGNORE_PATTERNS = [
|
|||||||
const LIMIT = 100
|
const LIMIT = 100
|
||||||
|
|
||||||
export const ListTool = Tool.define({
|
export const ListTool = Tool.define({
|
||||||
id: "opencode.list",
|
id: "list",
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
path: z
|
path: z
|
||||||
@@ -29,11 +29,11 @@ export const ListTool = Tool.define({
|
|||||||
.describe(
|
.describe(
|
||||||
"The absolute path to the directory to list (must be absolute, not relative)",
|
"The absolute path to the directory to list (must be absolute, not relative)",
|
||||||
)
|
)
|
||||||
.nullable(),
|
.optional(),
|
||||||
ignore: z
|
ignore: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.describe("List of glob patterns to ignore")
|
.describe("List of glob patterns to ignore")
|
||||||
.nullable(),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
async execute(params) {
|
async execute(params) {
|
||||||
const app = App.info()
|
const app = App.info()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { App } from "../app/app"
|
|||||||
import DESCRIPTION from "./lsp-diagnostics.txt"
|
import DESCRIPTION from "./lsp-diagnostics.txt"
|
||||||
|
|
||||||
export const LspDiagnosticTool = Tool.define({
|
export const LspDiagnosticTool = Tool.define({
|
||||||
id: "opencode.lsp_diagnostics",
|
id: "lsp_diagnostics",
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
path: z.string().describe("The path to the file to get diagnostics."),
|
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"
|
import DESCRIPTION from "./lsp-hover.txt"
|
||||||
|
|
||||||
export const LspHoverTool = Tool.define({
|
export const LspHoverTool = Tool.define({
|
||||||
id: "opencode.lsp_hover",
|
id: "lsp_hover",
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
file: z.string().describe("The path to the file to get diagnostics."),
|
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"
|
import { App } from "../app/app"
|
||||||
|
|
||||||
export const MultiEditTool = Tool.define({
|
export const MultiEditTool = Tool.define({
|
||||||
id: "opencode.multiedit",
|
id: "multiedit",
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
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 { Tool } from "./tool"
|
||||||
import { FileTimes } from "./util/file-times"
|
import { FileTimes } from "./util/file-times"
|
||||||
import DESCRIPTION from "./patch.txt"
|
import DESCRIPTION from "./patch.txt"
|
||||||
import { App } from "../app/app"
|
|
||||||
|
|
||||||
const PatchParams = z.object({
|
const PatchParams = z.object({
|
||||||
patchText: z
|
patchText: z
|
||||||
@@ -233,7 +232,7 @@ async function applyCommit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PatchTool = Tool.define({
|
export const PatchTool = Tool.define({
|
||||||
id: "opencode.patch",
|
id: "patch",
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: PatchParams,
|
parameters: PatchParams,
|
||||||
execute: async (params, ctx) => {
|
execute: async (params, ctx) => {
|
||||||
|
|||||||
@@ -12,18 +12,18 @@ const DEFAULT_READ_LIMIT = 2000
|
|||||||
const MAX_LINE_LENGTH = 2000
|
const MAX_LINE_LENGTH = 2000
|
||||||
|
|
||||||
export const ReadTool = Tool.define({
|
export const ReadTool = Tool.define({
|
||||||
id: "opencode.read",
|
id: "read",
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
filePath: z.string().describe("The path to the file to read"),
|
filePath: z.string().describe("The path to the file to read"),
|
||||||
offset: z
|
offset: z
|
||||||
.number()
|
.number()
|
||||||
.describe("The line number to start reading from (0-based)")
|
.describe("The line number to start reading from (0-based)")
|
||||||
.nullable(),
|
.optional(),
|
||||||
limit: z
|
limit: z
|
||||||
.number()
|
.number()
|
||||||
.describe("The number of lines to read (defaults to 2000)")
|
.describe("The number of lines to read (defaults to 2000)")
|
||||||
.nullable(),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
async execute(params, ctx) {
|
async execute(params, ctx) {
|
||||||
let filePath = params.filePath
|
let filePath = params.filePath
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { Tool } from "./tool"
|
|||||||
import DESCRIPTION from "./task.txt"
|
import DESCRIPTION from "./task.txt"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import { Message } from "../session/message"
|
||||||
|
|
||||||
export const TaskTool = Tool.define({
|
export const TaskTool = Tool.define({
|
||||||
id: "opencode.task",
|
id: "task",
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
description: z
|
description: z
|
||||||
@@ -17,6 +19,31 @@ export const TaskTool = Tool.define({
|
|||||||
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
|
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
|
||||||
const metadata = msg.metadata.assistant!
|
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({
|
const result = await Session.chat({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
modelID: metadata.modelID,
|
modelID: metadata.modelID,
|
||||||
@@ -28,10 +55,11 @@ export const TaskTool = Tool.define({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
unsub()
|
||||||
return {
|
return {
|
||||||
metadata: {
|
metadata: {
|
||||||
title: params.description,
|
title: params.description,
|
||||||
|
summary: summary(result),
|
||||||
},
|
},
|
||||||
output: result.parts.findLast((x) => x.type === "text")!.text,
|
output: result.parts.findLast((x) => x.type === "text")!.text,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const state = App.state("todo-tool", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const TodoWriteTool = Tool.define({
|
export const TodoWriteTool = Tool.define({
|
||||||
id: "opencode.todowrite",
|
id: "todowrite",
|
||||||
description: DESCRIPTION_WRITE,
|
description: DESCRIPTION_WRITE,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
todos: z.array(TodoInfo).describe("The updated todo list"),
|
todos: z.array(TodoInfo).describe("The updated todo list"),
|
||||||
@@ -42,7 +42,7 @@ export const TodoWriteTool = Tool.define({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const TodoReadTool = Tool.define({
|
export const TodoReadTool = Tool.define({
|
||||||
id: "opencode.todoread",
|
id: "todoread",
|
||||||
description: "Use this tool to read your todo list",
|
description: "Use this tool to read your todo list",
|
||||||
parameters: z.object({}),
|
parameters: z.object({}),
|
||||||
async execute(_params, opts) {
|
async execute(_params, opts) {
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ export namespace Tool {
|
|||||||
title: string
|
title: string
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
export type Context = {
|
export type Context<M extends Metadata = Metadata> = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
messageID: string
|
messageID: string
|
||||||
abort: AbortSignal
|
abort: AbortSignal
|
||||||
|
metadata(meta: M): void
|
||||||
}
|
}
|
||||||
export interface Info<
|
export interface Info<
|
||||||
Parameters extends StandardSchemaV1 = StandardSchemaV1,
|
Parameters extends StandardSchemaV1 = StandardSchemaV1,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
|
|||||||
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
|
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
|
||||||
|
|
||||||
export const WebFetchTool = Tool.define({
|
export const WebFetchTool = Tool.define({
|
||||||
id: "opencode.webfetch",
|
id: "webfetch",
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
url: z.string().describe("The URL to fetch content from"),
|
url: z.string().describe("The URL to fetch content from"),
|
||||||
@@ -22,7 +22,7 @@ export const WebFetchTool = Tool.define({
|
|||||||
.min(0)
|
.min(0)
|
||||||
.max(MAX_TIMEOUT / 1000)
|
.max(MAX_TIMEOUT / 1000)
|
||||||
.describe("Optional timeout in seconds (max 120)")
|
.describe("Optional timeout in seconds (max 120)")
|
||||||
.nullable(),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
async execute(params, ctx) {
|
async execute(params, ctx) {
|
||||||
// Validate URL
|
// Validate URL
|
||||||
@@ -76,7 +76,7 @@ export const WebFetchTool = Tool.define({
|
|||||||
switch (params.format) {
|
switch (params.format) {
|
||||||
case "text":
|
case "text":
|
||||||
if (contentType.includes("text/html")) {
|
if (contentType.includes("text/html")) {
|
||||||
const text = extractTextFromHTML(content)
|
const text = await extractTextFromHTML(content)
|
||||||
return {
|
return {
|
||||||
output: text,
|
output: text,
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -127,10 +127,45 @@ export const WebFetchTool = Tool.define({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function extractTextFromHTML(html: string): string {
|
async function extractTextFromHTML(html: string) {
|
||||||
const doc = new DOMParser().parseFromString(html, "text/html")
|
let text = ""
|
||||||
const text = doc.body.textContent || doc.body.innerText || ""
|
let skipContent = false
|
||||||
return text.replace(/\s+/g, " ").trim()
|
|
||||||
|
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 {
|
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
|
- Provides up-to-date information for current events and recent data
|
||||||
- Returns search result information formatted as search result blocks
|
- Returns search result information formatted as search result blocks
|
||||||
- Use this tool for accessing information beyond Claude's knowledge cutoff
|
- 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"
|
import { App } from "../app/app"
|
||||||
|
|
||||||
export const WriteTool = Tool.define({
|
export const WriteTool = Tool.define({
|
||||||
id: "opencode.write",
|
id: "write",
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
filePath: z
|
filePath: z
|
||||||
@@ -29,7 +29,7 @@ export const WriteTool = Tool.define({
|
|||||||
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
|
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
|
||||||
|
|
||||||
await Permission.ask({
|
await Permission.ask({
|
||||||
id: "opencode.write",
|
id: "write",
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
title: exists
|
title: exists
|
||||||
? "Overwrite this file: " + filepath
|
? "Overwrite this file: " + filepath
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z, type ZodSchema } from "zod"
|
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 {
|
export abstract class NamedError extends Error {
|
||||||
abstract schema(): ZodSchema
|
abstract schema(): ZodSchema
|
||||||
@@ -11,15 +11,16 @@ export abstract class NamedError extends Error {
|
|||||||
name: Name,
|
name: Name,
|
||||||
data: Data,
|
data: Data,
|
||||||
) {
|
) {
|
||||||
|
const schema = z
|
||||||
|
.object({
|
||||||
|
name: z.literal(name),
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
.openapi({
|
||||||
|
ref: name,
|
||||||
|
})
|
||||||
const result = class extends NamedError {
|
const result = class extends NamedError {
|
||||||
public static readonly Schema = z
|
public static readonly Schema = schema
|
||||||
.object({
|
|
||||||
name: z.literal(name),
|
|
||||||
data: data,
|
|
||||||
})
|
|
||||||
.openapi({
|
|
||||||
ref: name,
|
|
||||||
})
|
|
||||||
|
|
||||||
public readonly name = name as Name
|
public readonly name = name as Name
|
||||||
|
|
||||||
@@ -29,10 +30,6 @@ export abstract class NamedError extends Error {
|
|||||||
) {
|
) {
|
||||||
super(name, options)
|
super(name, options)
|
||||||
this.name = name
|
this.name = name
|
||||||
log.error(name, {
|
|
||||||
...this.data,
|
|
||||||
cause: options?.cause?.toString(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static isInstance(input: any): input is InstanceType<typeof result> {
|
static isInstance(input: any): input is InstanceType<typeof result> {
|
||||||
@@ -40,7 +37,7 @@ export abstract class NamedError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
schema() {
|
schema() {
|
||||||
return result.Schema
|
return schema
|
||||||
}
|
}
|
||||||
|
|
||||||
toObject() {
|
toObject() {
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ export namespace Log {
|
|||||||
}
|
}
|
||||||
const result = {
|
const result = {
|
||||||
info(message?: any, extra?: Record<string, any>) {
|
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>) {
|
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>) {
|
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) {
|
tag(key: string, value: string) {
|
||||||
if (tags) tags[key] = value
|
if (tags) tags[key] = value
|
||||||
|
|||||||
@@ -3,21 +3,33 @@ import { App } from "../../src/app/app"
|
|||||||
import { GlobTool } from "../../src/tool/glob"
|
import { GlobTool } from "../../src/tool/glob"
|
||||||
import { ListTool } from "../../src/tool/ls"
|
import { ListTool } from "../../src/tool/ls"
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
sessionID: "test",
|
||||||
|
messageID: "",
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
metadata: () => {},
|
||||||
|
}
|
||||||
describe("tool.glob", () => {
|
describe("tool.glob", () => {
|
||||||
test("truncate", async () => {
|
test("truncate", async () => {
|
||||||
await App.provide({ cwd: process.cwd(), version: "test" }, async () => {
|
await App.provide({ cwd: process.cwd() }, async () => {
|
||||||
let result = await GlobTool.execute(
|
let result = await GlobTool.execute(
|
||||||
{ pattern: "./node_modules/**/*" },
|
{
|
||||||
{ sessionID: "test" },
|
pattern: "./node_modules/**/*",
|
||||||
|
path: undefined,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
)
|
)
|
||||||
expect(result.metadata.truncated).toBe(true)
|
expect(result.metadata.truncated).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
test("basic", async () => {
|
test("basic", async () => {
|
||||||
await App.provide({ cwd: process.cwd(), version: "test" }, async () => {
|
await App.provide({ cwd: process.cwd() }, async () => {
|
||||||
let result = await GlobTool.execute(
|
let result = await GlobTool.execute(
|
||||||
{ pattern: "*.json" },
|
{
|
||||||
{ sessionID: "test" },
|
pattern: "*.json",
|
||||||
|
path: undefined,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
)
|
)
|
||||||
expect(result.metadata).toMatchObject({
|
expect(result.metadata).toMatchObject({
|
||||||
truncated: false,
|
truncated: false,
|
||||||
@@ -29,15 +41,12 @@ describe("tool.glob", () => {
|
|||||||
|
|
||||||
describe("tool.ls", () => {
|
describe("tool.ls", () => {
|
||||||
test("basic", async () => {
|
test("basic", async () => {
|
||||||
const result = await App.provide(
|
const result = await App.provide({ cwd: process.cwd() }, async () => {
|
||||||
{ cwd: process.cwd(), version: "test" },
|
return await ListTool.execute(
|
||||||
async () => {
|
{ path: "./example", ignore: [".git"] },
|
||||||
return await ListTool.execute(
|
ctx,
|
||||||
{ path: "./example" },
|
)
|
||||||
{ sessionID: "test" },
|
})
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
expect(result.output).toMatchSnapshot()
|
expect(result.output).toMatchSnapshot()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,10 +23,28 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
url := os.Getenv("OPENCODE_SERVER")
|
url := os.Getenv("OPENCODE_SERVER")
|
||||||
|
|
||||||
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
|
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
|
||||||
var appInfo client.AppInfo
|
var appInfo client.AppInfo
|
||||||
json.Unmarshal([]byte(appInfoStr), &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)
|
httpClient, err := client.NewClientWithResponses(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to create client", "error", err)
|
slog.Error("Failed to create client", "error", err)
|
||||||
@@ -46,7 +64,7 @@ func main() {
|
|||||||
tui.NewModel(app_),
|
tui.NewModel(app_),
|
||||||
tea.WithAltScreen(),
|
tea.WithAltScreen(),
|
||||||
tea.WithKeyboardEnhancements(),
|
tea.WithKeyboardEnhancements(),
|
||||||
// tea.WithMouseCellMotion(),
|
tea.WithMouseCellMotion(),
|
||||||
)
|
)
|
||||||
|
|
||||||
eventClient, err := client.NewClient(url)
|
eventClient, err := client.NewClient(url)
|
||||||
@@ -67,35 +85,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
go func() {
|
|
||||||
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
|
// Run the TUI
|
||||||
result, err := program.Run()
|
result, err := program.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("TUI error", "error", err)
|
slog.Error("TUI error", "error", err)
|
||||||
// return fmt.Errorf("TUI error: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("TUI exited", "result", result)
|
slog.Info("TUI exited", "result", result)
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/sst/opencode/internal/commands"
|
"github.com/sst/opencode/internal/commands"
|
||||||
"github.com/sst/opencode/internal/config"
|
"github.com/sst/opencode/internal/config"
|
||||||
"github.com/sst/opencode/internal/state"
|
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
"github.com/sst/opencode/internal/util"
|
"github.com/sst/opencode/internal/util"
|
||||||
"github.com/sst/opencode/pkg/client"
|
"github.com/sst/opencode/pkg/client"
|
||||||
@@ -20,16 +20,32 @@ import (
|
|||||||
var RootPath string
|
var RootPath string
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
Info client.AppInfo
|
Info client.AppInfo
|
||||||
Version string
|
Version string
|
||||||
ConfigPath string
|
StatePath string
|
||||||
Config *config.Config
|
Config *client.ConfigInfo
|
||||||
Client *client.ClientWithResponses
|
Client *client.ClientWithResponses
|
||||||
Provider *client.ProviderInfo
|
State *config.State
|
||||||
Model *client.ModelInfo
|
Provider *client.ProviderInfo
|
||||||
Session *client.SessionInfo
|
Model *client.ModelInfo
|
||||||
Messages []client.MessageInfo
|
Session *client.SessionInfo
|
||||||
Commands commands.Registry
|
Messages []client.MessageInfo
|
||||||
|
Commands commands.CommandRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
@@ -40,23 +56,51 @@ func New(
|
|||||||
) (*App, error) {
|
) (*App, error) {
|
||||||
RootPath = appInfo.Path.Root
|
RootPath = appInfo.Path.Root
|
||||||
|
|
||||||
appConfigPath := filepath.Join(appInfo.Path.Config, "config")
|
configResponse, err := httpClient.PostConfigGetWithResponse(ctx)
|
||||||
appConfig, err := config.LoadConfig(appConfigPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
appConfig = config.NewConfig()
|
return nil, err
|
||||||
config.SaveConfig(appConfigPath, appConfig)
|
|
||||||
}
|
}
|
||||||
theme.SetTheme(appConfig.Theme)
|
configInfo := configResponse.JSON200
|
||||||
|
if configInfo.Keybinds == nil {
|
||||||
|
leader := "ctrl+x"
|
||||||
|
keybinds := client.ConfigKeybinds{
|
||||||
|
Leader: &leader,
|
||||||
|
}
|
||||||
|
configInfo.Keybinds = &keybinds
|
||||||
|
}
|
||||||
|
|
||||||
|
appStatePath := filepath.Join(appInfo.Path.State, "tui")
|
||||||
|
appState, err := config.LoadState(appStatePath)
|
||||||
|
if err != nil {
|
||||||
|
appState = config.NewState()
|
||||||
|
config.SaveState(appStatePath, appState)
|
||||||
|
}
|
||||||
|
|
||||||
|
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:], "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
if appState.Theme != "" {
|
||||||
|
theme.SetTheme(appState.Theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Loaded config", "config", configInfo)
|
||||||
|
|
||||||
app := &App{
|
app := &App{
|
||||||
Info: appInfo,
|
Info: appInfo,
|
||||||
Version: version,
|
Version: version,
|
||||||
ConfigPath: appConfigPath,
|
StatePath: appStatePath,
|
||||||
Config: appConfig,
|
Config: configInfo,
|
||||||
Client: httpClient,
|
State: appState,
|
||||||
Session: &client.SessionInfo{},
|
Client: httpClient,
|
||||||
Messages: []client.MessageInfo{},
|
Session: &client.SessionInfo{},
|
||||||
Commands: commands.NewCommandRegistry(),
|
Messages: []client.MessageInfo{},
|
||||||
|
Commands: commands.LoadFromConfig(configInfo),
|
||||||
}
|
}
|
||||||
|
|
||||||
return app, nil
|
return app, nil
|
||||||
@@ -102,11 +146,11 @@ func (a *App) InitializeProvider() tea.Cmd {
|
|||||||
var currentProvider *client.ProviderInfo
|
var currentProvider *client.ProviderInfo
|
||||||
var currentModel *client.ModelInfo
|
var currentModel *client.ModelInfo
|
||||||
for _, provider := range providers {
|
for _, provider := range providers {
|
||||||
if provider.Id == a.Config.Provider {
|
if provider.Id == a.State.Provider {
|
||||||
currentProvider = &provider
|
currentProvider = &provider
|
||||||
|
|
||||||
for _, model := range provider.Models {
|
for _, model := range provider.Models {
|
||||||
if model.Id == a.Config.Model {
|
if model.Id == a.State.Model {
|
||||||
currentModel = &model
|
currentModel = &model
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +162,7 @@ func (a *App) InitializeProvider() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: handle no provider or model setup, yet
|
// TODO: handle no provider or model setup, yet
|
||||||
return state.ModelSelectedMsg{
|
return ModelSelectedMsg{
|
||||||
Provider: *currentProvider,
|
Provider: *currentProvider,
|
||||||
Model: *currentModel,
|
Model: *currentModel,
|
||||||
}
|
}
|
||||||
@@ -153,8 +197,11 @@ func (a *App) IsBusy() bool {
|
|||||||
return lastMessage.Metadata.Time.Completed == nil
|
return lastMessage.Metadata.Time.Completed == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) SaveConfig() {
|
func (a *App) SaveState() {
|
||||||
config.SaveConfig(a.ConfigPath, a.Config)
|
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 {
|
func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
||||||
@@ -167,7 +214,7 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a.Session = session
|
a.Session = session
|
||||||
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
|
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
|
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
|
||||||
@@ -236,7 +283,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
a.Session = session
|
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
|
// TODO: Handle attachments when API supports them
|
||||||
@@ -341,3 +388,7 @@ func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error)
|
|||||||
providers := *resp.JSON200
|
providers := *resp.JSON200
|
||||||
return providers.Providers, nil
|
return providers.Providers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// func (a *App) loadCustomKeybinds() {
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
|||||||
@@ -1,91 +1,281 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
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 {
|
type Command struct {
|
||||||
// Name is the identifier used for slash commands (e.g., "new").
|
Name CommandName
|
||||||
Name string
|
|
||||||
// Description is a short explanation of what the command does.
|
|
||||||
Description string
|
Description string
|
||||||
// KeyBinding is the keyboard shortcut to trigger this command.
|
Keybindings []Keybinding
|
||||||
KeyBinding key.Binding
|
Trigger string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry holds all the available commands.
|
func (c Command) Keys() []string {
|
||||||
type Registry map[string]Command
|
var keys []string
|
||||||
|
for _, k := range c.Keybindings {
|
||||||
// ExecuteCommandMsg is a message sent when a command should be executed.
|
keys = append(keys, k.Key)
|
||||||
type ExecuteCommandMsg struct {
|
}
|
||||||
Name string
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCommandRegistry() Registry {
|
type CommandRegistry map[CommandName]Command
|
||||||
return Registry{
|
|
||||||
"help": {
|
func (r CommandRegistry) Sorted() []Command {
|
||||||
Name: "help",
|
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",
|
Description: "show help",
|
||||||
KeyBinding: key.NewBinding(
|
Keybindings: parseBindings("<leader>h"),
|
||||||
key.WithKeys("f1", "super+/", "super+h"),
|
Trigger: "help",
|
||||||
),
|
|
||||||
},
|
},
|
||||||
"new": {
|
{
|
||||||
Name: "new",
|
Name: EditorOpenCommand,
|
||||||
|
Description: "open editor",
|
||||||
|
Keybindings: parseBindings("<leader>e"),
|
||||||
|
Trigger: "editor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: SessionNewCommand,
|
||||||
Description: "new session",
|
Description: "new session",
|
||||||
KeyBinding: key.NewBinding(
|
Keybindings: parseBindings("<leader>n"),
|
||||||
key.WithKeys("f2", "super+n"),
|
Trigger: "new",
|
||||||
),
|
|
||||||
},
|
},
|
||||||
"sessions": {
|
{
|
||||||
Name: "sessions",
|
Name: SessionListCommand,
|
||||||
Description: "switch session",
|
Description: "list sessions",
|
||||||
KeyBinding: key.NewBinding(
|
Keybindings: parseBindings("<leader>l"),
|
||||||
key.WithKeys("f3", "super+s"),
|
Trigger: "sessions",
|
||||||
),
|
|
||||||
},
|
},
|
||||||
"model": {
|
{
|
||||||
Name: "model",
|
Name: SessionShareCommand,
|
||||||
Description: "switch model",
|
Description: "share session",
|
||||||
KeyBinding: key.NewBinding(
|
Keybindings: parseBindings("<leader>s"),
|
||||||
key.WithKeys("f4", "super+m"),
|
Trigger: "share",
|
||||||
),
|
|
||||||
},
|
},
|
||||||
"theme": {
|
{
|
||||||
Name: "theme",
|
Name: SessionInterruptCommand,
|
||||||
Description: "switch theme",
|
Description: "interrupt session",
|
||||||
KeyBinding: key.NewBinding(
|
Keybindings: parseBindings("esc"),
|
||||||
key.WithKeys("f5", "super+t"),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
"share": {
|
{
|
||||||
Name: "share",
|
Name: SessionCompactCommand,
|
||||||
Description: "create shareable link",
|
Description: "compact the session",
|
||||||
KeyBinding: key.NewBinding(
|
Keybindings: parseBindings("<leader>c"),
|
||||||
key.WithKeys("f6"),
|
Trigger: "compact",
|
||||||
),
|
|
||||||
},
|
},
|
||||||
"init": {
|
{
|
||||||
Name: "init",
|
Name: ToolDetailsCommand,
|
||||||
Description: "create or update AGENTS.md",
|
Description: "toggle tool details",
|
||||||
KeyBinding: key.NewBinding(
|
Keybindings: parseBindings("<leader>d"),
|
||||||
key.WithKeys("f7"),
|
Trigger: "details",
|
||||||
),
|
|
||||||
},
|
},
|
||||||
// "compact": {
|
{
|
||||||
// Name: "compact",
|
Name: ModelListCommand,
|
||||||
// Description: "compact the session",
|
Description: "list models",
|
||||||
// KeyBinding: key.NewBinding(
|
Keybindings: parseBindings("<leader>m"),
|
||||||
// key.WithKeys("f8"),
|
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"),
|
||||||
// },
|
// },
|
||||||
"quit": {
|
// {
|
||||||
Name: "quit",
|
// Name: HistoryNextCommand,
|
||||||
Description: "quit",
|
// Description: "next prompt",
|
||||||
KeyBinding: key.NewBinding(
|
// Keybindings: parseBindings("down"),
|
||||||
key.WithKeys("f10", "ctrl+c", "super+q"),
|
// },
|
||||||
),
|
{
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,11 +35,10 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
|
|||||||
return "no matching commands"
|
return "no matching commands"
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCommandCompletionItem(cmd commands.Command, space int) dialog.CompletionItemI {
|
func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
|
||||||
t := theme.CurrentTheme()
|
|
||||||
spacer := strings.Repeat(" ", space)
|
spacer := strings.Repeat(" ", space)
|
||||||
title := " /" + cmd.Name + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
|
title := " /" + cmd.Trigger + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
|
||||||
value := "/" + cmd.Name
|
value := string(cmd.Name)
|
||||||
return dialog.NewCompletionItem(dialog.CompletionItem{
|
return dialog.NewCompletionItem(dialog.CompletionItem{
|
||||||
Title: title,
|
Title: title,
|
||||||
Value: value,
|
Value: value,
|
||||||
@@ -47,20 +46,27 @@ func getCommandCompletionItem(cmd commands.Command, space int) dialog.Completion
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
||||||
|
t := theme.CurrentTheme()
|
||||||
|
commands := c.app.Commands
|
||||||
|
|
||||||
space := 1
|
space := 1
|
||||||
for _, cmd := range c.app.Commands {
|
for _, cmd := range c.app.Commands {
|
||||||
if lipgloss.Width(cmd.Name) > space {
|
if lipgloss.Width(cmd.Trigger) > space {
|
||||||
space = lipgloss.Width(cmd.Name)
|
space = lipgloss.Width(cmd.Trigger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
space += 2
|
space += 2
|
||||||
|
|
||||||
|
sorted := commands.Sorted()
|
||||||
if query == "" {
|
if query == "" {
|
||||||
// If no query, return all commands
|
// If no query, return all commands
|
||||||
items := []dialog.CompletionItemI{}
|
items := []dialog.CompletionItemI{}
|
||||||
for _, cmd := range c.app.Commands {
|
for _, cmd := range sorted {
|
||||||
space := space - lipgloss.Width(cmd.Name)
|
if cmd.Trigger == "" {
|
||||||
items = append(items, getCommandCompletionItem(cmd, space))
|
continue
|
||||||
|
}
|
||||||
|
space := space - lipgloss.Width(cmd.Trigger)
|
||||||
|
items = append(items, getCommandCompletionItem(cmd, space, t))
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
@@ -69,10 +75,13 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
|
|||||||
var commandNames []string
|
var commandNames []string
|
||||||
commandMap := make(map[string]dialog.CompletionItemI)
|
commandMap := make(map[string]dialog.CompletionItemI)
|
||||||
|
|
||||||
for _, cmd := range c.app.Commands {
|
for _, cmd := range sorted {
|
||||||
space := space - lipgloss.Width(cmd.Name)
|
if cmd.Trigger == "" {
|
||||||
commandNames = append(commandNames, cmd.Name)
|
continue
|
||||||
commandMap[cmd.Name] = getCommandCompletionItem(cmd, space)
|
}
|
||||||
|
space := space - lipgloss.Width(cmd.Trigger)
|
||||||
|
commandNames = append(commandNames, cmd.Trigger)
|
||||||
|
commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, space, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find fuzzy matches
|
// Find fuzzy matches
|
||||||
@@ -88,6 +97,5 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
func (m *CompletionManager) GetProvider(input string) dialog.CompletionProvider {
|
||||||
if strings.HasPrefix(input, "/") {
|
if strings.HasPrefix(input, "/") {
|
||||||
return m.providers["commands"]
|
return m.providers["commands"]
|
||||||
}
|
}
|
||||||
return m.providers["files"]
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/key"
|
|
||||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
"github.com/charmbracelet/bubbles/v2/spinner"
|
||||||
"github.com/charmbracelet/bubbles/v2/textarea"
|
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/commands"
|
"github.com/sst/opencode/internal/commands"
|
||||||
"github.com/sst/opencode/internal/components/dialog"
|
"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/image"
|
||||||
"github.com/sst/opencode/internal/layout"
|
"github.com/sst/opencode/internal/layout"
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
@@ -22,75 +19,32 @@ import (
|
|||||||
"github.com/sst/opencode/internal/util"
|
"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 {
|
type editorComponent struct {
|
||||||
width int
|
|
||||||
height int
|
|
||||||
app *app.App
|
app *app.App
|
||||||
|
width, height int
|
||||||
textarea textarea.Model
|
textarea textarea.Model
|
||||||
attachments []app.Attachment
|
attachments []app.Attachment
|
||||||
deleteMode bool
|
|
||||||
history []string
|
history []string
|
||||||
historyIndex int
|
historyIndex int
|
||||||
currentMessage string
|
currentMessage string
|
||||||
spinner spinner.Model
|
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 {
|
func (m *editorComponent) Init() tea.Cmd {
|
||||||
return tea.Batch(textarea.Blink, m.spinner.Tick, tea.EnableReportFocus)
|
return tea.Batch(textarea.Blink, m.spinner.Tick, tea.EnableReportFocus)
|
||||||
}
|
}
|
||||||
@@ -98,154 +52,36 @@ func (m *editorComponent) Init() tea.Cmd {
|
|||||||
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case dialog.ThemeChangedMsg:
|
case spinner.TickMsg:
|
||||||
|
m.spinner, cmd = m.spinner.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
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.textarea = createTextArea(&m.textarea)
|
||||||
m.spinner = createSpinner()
|
m.spinner = createSpinner()
|
||||||
return m, m.spinner.Tick
|
return m, tea.Batch(m.spinner.Tick, textarea.Blink)
|
||||||
case dialog.CompletionSelectedMsg:
|
case dialog.CompletionSelectedMsg:
|
||||||
if msg.IsCommand {
|
if msg.IsCommand {
|
||||||
// Execute the command directly
|
|
||||||
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
|
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
|
||||||
m.textarea.Reset()
|
updated, cmd := m.Clear()
|
||||||
return m, util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
|
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 {
|
} else {
|
||||||
// For files, replace the text in the editor
|
|
||||||
existingValue := m.textarea.Value()
|
existingValue := m.textarea.Value()
|
||||||
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
|
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
|
||||||
m.textarea.SetValue(modifiedValue)
|
m.textarea.SetValue(modifiedValue + " ")
|
||||||
return m, nil
|
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)
|
m.spinner, cmd = m.spinner.Update(msg)
|
||||||
@@ -257,7 +93,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) View() string {
|
func (m *editorComponent) Content() string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
base := styles.BaseStyle().Background(t.Background()).Render
|
base := styles.BaseStyle().Background(t.Background()).Render
|
||||||
muted := styles.Muted().Background(t.Background()).Render
|
muted := styles.Muted().Background(t.Background()).Render
|
||||||
@@ -277,9 +113,6 @@ func (m *editorComponent) View() string {
|
|||||||
PaddingTop(1).
|
PaddingTop(1).
|
||||||
PaddingBottom(1).
|
PaddingBottom(1).
|
||||||
Background(t.BackgroundElement()).
|
Background(t.BackgroundElement()).
|
||||||
Border(lipgloss.ThickBorder(), false, true).
|
|
||||||
BorderForeground(t.BackgroundSubtle()).
|
|
||||||
BorderBackground(t.Background()).
|
|
||||||
Render(textarea)
|
Render(textarea)
|
||||||
|
|
||||||
hint := base("enter") + muted(" send ")
|
hint := base("enter") + muted(" send ")
|
||||||
@@ -289,7 +122,7 @@ func (m *editorComponent) View() string {
|
|||||||
|
|
||||||
model := ""
|
model := ""
|
||||||
if m.app.Model != nil {
|
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)
|
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
|
||||||
@@ -299,66 +132,52 @@ func (m *editorComponent) View() string {
|
|||||||
info = styles.Padded().Background(t.Background()).Render(info)
|
info = styles.Padded().Background(t.Background()).Render(info)
|
||||||
|
|
||||||
content := strings.Join([]string{"", textarea, info}, "\n")
|
content := strings.Join([]string{"", textarea, info}, "\n")
|
||||||
|
|
||||||
return content
|
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 {
|
func (m *editorComponent) SetSize(width, height int) tea.Cmd {
|
||||||
m.width = width
|
m.width = width
|
||||||
m.height = height
|
m.height = height
|
||||||
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
|
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
|
||||||
m.textarea.SetHeight(height - 4) // account for info underneath
|
// m.textarea.SetHeight(height - 4)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) GetSize() (int, int) {
|
func (m *editorComponent) Lines() int {
|
||||||
return m.width, m.height
|
return m.textarea.LineCount()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) openEditor(value string) tea.Cmd {
|
func (m *editorComponent) Value() string {
|
||||||
editor := os.Getenv("EDITOR")
|
return m.textarea.Value()
|
||||||
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) send() tea.Cmd {
|
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
||||||
value := strings.TrimSpace(m.textarea.Value())
|
value := strings.TrimSpace(m.Value())
|
||||||
m.textarea.Reset()
|
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
|
attachments := m.attachments
|
||||||
|
|
||||||
// Save to history if not empty and not a duplicate of the last entry
|
// Save to history if not empty and not a duplicate of the last entry
|
||||||
@@ -371,24 +190,77 @@ func (m *editorComponent) send() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.attachments = nil
|
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
|
func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
|
||||||
// if strings.HasPrefix(value, "/") {
|
m.textarea.Newline()
|
||||||
// commandName := strings.TrimPrefix(value, "/")
|
return m, nil
|
||||||
// if _, ok := m.app.Commands[commandName]; ok {
|
}
|
||||||
// return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
return tea.Batch(
|
func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
|
||||||
util.CmdHandler(SendMsg{
|
currentLine := m.textarea.Line()
|
||||||
Text: value,
|
|
||||||
Attachments: attachments,
|
// 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 {
|
func createTextArea(existing *textarea.Model) textarea.Model {
|
||||||
@@ -434,11 +306,7 @@ func createSpinner() spinner.Model {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) GetValue() string {
|
func NewEditorComponent(app *app.App) EditorComponent {
|
||||||
return m.textarea.Value()
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEditorComponent(app *app.App) layout.ModelWithView {
|
|
||||||
s := createSpinner()
|
s := createSpinner()
|
||||||
ta := createTextArea(nil)
|
ta := createTextArea(nil)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package chat
|
package chat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -136,7 +137,7 @@ func renderContentBlock(content string, options ...renderingOption) string {
|
|||||||
PaddingBottom(renderer.paddingBottom).
|
PaddingBottom(renderer.paddingBottom).
|
||||||
PaddingLeft(renderer.paddingLeft).
|
PaddingLeft(renderer.paddingLeft).
|
||||||
PaddingRight(renderer.paddingRight).
|
PaddingRight(renderer.paddingRight).
|
||||||
Background(t.BackgroundSubtle()).
|
Background(t.BackgroundPanel()).
|
||||||
Foreground(t.TextMuted()).
|
Foreground(t.TextMuted()).
|
||||||
BorderStyle(lipgloss.ThickBorder())
|
BorderStyle(lipgloss.ThickBorder())
|
||||||
|
|
||||||
@@ -145,7 +146,7 @@ func renderContentBlock(content string, options ...renderingOption) string {
|
|||||||
align = *renderer.align
|
align = *renderer.align
|
||||||
}
|
}
|
||||||
|
|
||||||
borderColor := t.BackgroundSubtle()
|
borderColor := t.BackgroundPanel()
|
||||||
if renderer.borderColor != nil {
|
if renderer.borderColor != nil {
|
||||||
borderColor = *renderer.borderColor
|
borderColor = *renderer.borderColor
|
||||||
}
|
}
|
||||||
@@ -158,7 +159,7 @@ func renderContentBlock(content string, options ...renderingOption) string {
|
|||||||
AlignHorizontal(align).
|
AlignHorizontal(align).
|
||||||
BorderLeftForeground(borderColor).
|
BorderLeftForeground(borderColor).
|
||||||
BorderLeftBackground(t.Background()).
|
BorderLeftBackground(t.Background()).
|
||||||
BorderRightForeground(t.BackgroundSubtle()).
|
BorderRightForeground(t.BackgroundPanel()).
|
||||||
BorderRightBackground(t.Background())
|
BorderRightBackground(t.Background())
|
||||||
case lipgloss.Right:
|
case lipgloss.Right:
|
||||||
style = style.
|
style = style.
|
||||||
@@ -167,7 +168,7 @@ func renderContentBlock(content string, options ...renderingOption) string {
|
|||||||
AlignHorizontal(align).
|
AlignHorizontal(align).
|
||||||
BorderRightForeground(borderColor).
|
BorderRightForeground(borderColor).
|
||||||
BorderRightBackground(t.Background()).
|
BorderRightBackground(t.Background()).
|
||||||
BorderLeftForeground(t.BackgroundSubtle()).
|
BorderLeftForeground(t.BackgroundPanel()).
|
||||||
BorderLeftBackground(t.Background())
|
BorderLeftBackground(t.Background())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,17 +202,20 @@ func renderContentBlock(content string, options ...renderingOption) string {
|
|||||||
return content
|
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 {
|
func renderText(message client.MessageInfo, text string, author string) string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
width := layout.Current.Container.Width
|
width := layout.Current.Container.Width
|
||||||
padding := 0
|
padding := calculatePadding()
|
||||||
if layout.Current.Viewport.Width < 80 {
|
|
||||||
padding = 5
|
|
||||||
} else if layout.Current.Viewport.Width < 120 {
|
|
||||||
padding = 15
|
|
||||||
} else {
|
|
||||||
padding = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
|
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] {
|
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))
|
textWidth := max(lipgloss.Width(text), lipgloss.Width(info))
|
||||||
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
|
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
|
||||||
content := toMarkdown(text, markdownWidth, t.BackgroundSubtle())
|
if message.Role == client.Assistant {
|
||||||
|
markdownWidth = width - padding - 4
|
||||||
|
}
|
||||||
|
content := toMarkdown(text, markdownWidth, t.BackgroundPanel())
|
||||||
content = strings.Join([]string{content, info}, "\n")
|
content = strings.Join([]string{content, info}, "\n")
|
||||||
// content = lipgloss.JoinVertical(align, content, info)
|
|
||||||
|
|
||||||
switch message.Role {
|
switch message.Role {
|
||||||
case client.User:
|
case client.User:
|
||||||
@@ -245,9 +251,11 @@ func renderToolInvocation(
|
|||||||
toolCall client.MessageToolInvocationToolCall,
|
toolCall client.MessageToolInvocationToolCall,
|
||||||
result *string,
|
result *string,
|
||||||
metadata client.MessageInfo_Metadata_Tool_AdditionalProperties,
|
metadata client.MessageInfo_Metadata_Tool_AdditionalProperties,
|
||||||
showResult bool,
|
showDetails bool,
|
||||||
|
isLast bool,
|
||||||
|
contentOnly bool,
|
||||||
) string {
|
) string {
|
||||||
ignoredTools := []string{"opencode_todoread"}
|
ignoredTools := []string{"todoread"}
|
||||||
if slices.Contains(ignoredTools, toolCall.ToolName) {
|
if slices.Contains(ignoredTools, toolCall.ToolName) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -256,7 +264,7 @@ func renderToolInvocation(
|
|||||||
innerWidth := outerWidth - 6
|
innerWidth := outerWidth - 6
|
||||||
paddingTop := 0
|
paddingTop := 0
|
||||||
paddingBottom := 0
|
paddingBottom := 0
|
||||||
if showResult {
|
if showDetails {
|
||||||
paddingTop = 1
|
paddingTop = 1
|
||||||
if result == nil || *result == "" {
|
if result == nil || *result == "" {
|
||||||
paddingBottom = 1
|
paddingBottom = 1
|
||||||
@@ -266,7 +274,7 @@ func renderToolInvocation(
|
|||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
style := styles.Muted().
|
style := styles.Muted().
|
||||||
Width(outerWidth).
|
Width(outerWidth).
|
||||||
Background(t.BackgroundSubtle()).
|
Background(t.BackgroundPanel()).
|
||||||
PaddingTop(paddingTop).
|
PaddingTop(paddingTop).
|
||||||
PaddingBottom(paddingBottom).
|
PaddingBottom(paddingBottom).
|
||||||
PaddingLeft(2).
|
PaddingLeft(2).
|
||||||
@@ -274,26 +282,44 @@ func renderToolInvocation(
|
|||||||
BorderLeft(true).
|
BorderLeft(true).
|
||||||
BorderRight(true).
|
BorderRight(true).
|
||||||
BorderBackground(t.Background()).
|
BorderBackground(t.Background()).
|
||||||
BorderForeground(t.BackgroundSubtle()).
|
BorderForeground(t.BackgroundPanel()).
|
||||||
BorderStyle(lipgloss.ThickBorder())
|
BorderStyle(lipgloss.ThickBorder())
|
||||||
|
|
||||||
if toolCall.State == "partial-call" {
|
if toolCall.State == "partial-call" {
|
||||||
|
title := renderToolAction(toolCall.ToolName)
|
||||||
|
if !showDetails {
|
||||||
|
title = "∟ " + title
|
||||||
|
padding := calculatePadding()
|
||||||
|
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundPanel())
|
||||||
|
return renderContentBlock(style.Render(title),
|
||||||
|
WithAlign(lipgloss.Left),
|
||||||
|
WithBorderColor(t.Accent()),
|
||||||
|
WithPaddingTop(0),
|
||||||
|
WithPaddingBottom(1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
style = style.Foreground(t.TextMuted())
|
style = style.Foreground(t.TextMuted())
|
||||||
return style.Render(renderToolAction(toolCall.ToolName))
|
return style.Render(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
toolArgs := ""
|
toolArgs := ""
|
||||||
toolArgsMap := make(map[string]any)
|
toolArgsMap := make(map[string]any)
|
||||||
if toolCall.Args != nil {
|
if toolCall.Args != nil {
|
||||||
value := *toolCall.Args
|
value := *toolCall.Args
|
||||||
m, ok := value.(map[string]any)
|
if m, ok := value.(map[string]any); ok {
|
||||||
if ok {
|
|
||||||
toolArgsMap = m
|
toolArgsMap = m
|
||||||
firstKey := ""
|
|
||||||
|
keys := make([]string, 0, len(toolArgsMap))
|
||||||
for key := range toolArgsMap {
|
for key := range toolArgsMap {
|
||||||
firstKey = key
|
keys = append(keys, key)
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
slices.Sort(keys)
|
||||||
|
firstKey := ""
|
||||||
|
if len(keys) > 0 {
|
||||||
|
firstKey = keys[0]
|
||||||
|
}
|
||||||
|
|
||||||
toolArgs = renderArgs(&toolArgsMap, firstKey)
|
toolArgs = renderArgs(&toolArgsMap, firstKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,7 +332,7 @@ func renderToolInvocation(
|
|||||||
if m, ok := metadata.Get("message"); ok {
|
if m, ok := metadata.Get("message"); ok {
|
||||||
style = style.BorderLeftForeground(t.Error())
|
style = style.BorderLeftForeground(t.Error())
|
||||||
error = styles.BaseStyle().
|
error = styles.BaseStyle().
|
||||||
Background(t.BackgroundSubtle()).
|
Background(t.BackgroundPanel()).
|
||||||
Foreground(t.Error()).
|
Foreground(t.Error()).
|
||||||
Render(m.(string))
|
Render(m.(string))
|
||||||
error = renderContentBlock(
|
error = renderContentBlock(
|
||||||
@@ -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 := ""
|
title := ""
|
||||||
switch toolCall.ToolName {
|
switch toolCall.ToolName {
|
||||||
case "opencode_read":
|
case "read":
|
||||||
toolArgs = renderArgs(&toolArgsMap, "filePath")
|
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 {
|
if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
|
||||||
filename := toolArgsMap["filePath"].(string)
|
filename := toolArgsMap["filePath"].(string)
|
||||||
body = preview.(string)
|
body = preview.(string)
|
||||||
body = renderFile(filename, body, WithTruncate(6))
|
body = renderFile(filename, body, WithTruncate(6))
|
||||||
}
|
}
|
||||||
case "opencode_edit":
|
case "edit":
|
||||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
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 {
|
if d, ok := metadata.Get("diff"); ok {
|
||||||
patch := d.(string)
|
patch := d.(string)
|
||||||
var formattedDiff string
|
var formattedDiff string
|
||||||
@@ -359,12 +374,12 @@ func renderToolInvocation(
|
|||||||
formattedDiff = lipgloss.NewStyle().
|
formattedDiff = lipgloss.NewStyle().
|
||||||
BorderStyle(lipgloss.ThickBorder()).
|
BorderStyle(lipgloss.ThickBorder()).
|
||||||
BorderBackground(t.Background()).
|
BorderBackground(t.Background()).
|
||||||
BorderForeground(t.BackgroundSubtle()).
|
BorderForeground(t.BackgroundPanel()).
|
||||||
BorderLeft(true).
|
BorderLeft(true).
|
||||||
BorderRight(true).
|
BorderRight(true).
|
||||||
Render(formattedDiff)
|
Render(formattedDiff)
|
||||||
|
|
||||||
if showResult {
|
if showDetails {
|
||||||
style = style.Width(lipgloss.Width(formattedDiff))
|
style = style.Width(lipgloss.Width(formattedDiff))
|
||||||
title += "\n"
|
title += "\n"
|
||||||
}
|
}
|
||||||
@@ -380,37 +395,39 @@ func renderToolInvocation(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "opencode_write":
|
case "write":
|
||||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
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 {
|
if content, ok := toolArgsMap["content"].(string); ok {
|
||||||
body = renderFile(filename, content)
|
body = renderFile(filename, content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "opencode_bash":
|
case "bash":
|
||||||
if description, ok := toolArgsMap["description"].(string); ok {
|
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 {
|
if stdout, ok := metadata.Get("stdout"); ok {
|
||||||
command := toolArgsMap["command"].(string)
|
command := toolArgsMap["command"].(string)
|
||||||
stdout := stdout.(string)
|
stdout := stdout.(string)
|
||||||
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
|
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
|
||||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
body = toMarkdown(body, innerWidth, t.BackgroundPanel())
|
||||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||||
}
|
}
|
||||||
case "opencode_webfetch":
|
case "webfetch":
|
||||||
toolArgs = renderArgs(&toolArgsMap, "url")
|
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 {
|
if format, ok := toolArgsMap["format"].(string); ok {
|
||||||
body = *result
|
if result != nil {
|
||||||
body = truncateHeight(body, 10)
|
body = *result
|
||||||
if format == "html" || format == "markdown" {
|
body = truncateHeight(body, 10)
|
||||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
if format == "html" || format == "markdown" {
|
||||||
|
body = toMarkdown(body, innerWidth, t.BackgroundPanel())
|
||||||
|
}
|
||||||
|
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||||
}
|
}
|
||||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
|
||||||
}
|
}
|
||||||
case "opencode_todowrite":
|
case "todowrite":
|
||||||
title = fmt.Sprintf("Planning %s", elapsed)
|
title = fmt.Sprintf("PLAN")
|
||||||
|
|
||||||
if to, ok := metadata.Get("todos"); ok && finished {
|
if to, ok := metadata.Get("todos"); ok && finished {
|
||||||
todos := to.([]any)
|
todos := to.([]any)
|
||||||
@@ -426,17 +443,79 @@ func renderToolInvocation(
|
|||||||
body += fmt.Sprintf("- [ ] %s\n", content)
|
body += fmt.Sprintf("- [ ] %s\n", content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
body = toMarkdown(body, innerWidth, t.BackgroundPanel())
|
||||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
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:
|
default:
|
||||||
toolName := renderToolName(toolCall.ToolName)
|
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 = *result
|
||||||
body = truncateHeight(body, 10)
|
body = truncateHeight(body, 10)
|
||||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
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.BackgroundPanel())
|
||||||
|
paddingBottom := 0
|
||||||
|
if isLast {
|
||||||
|
paddingBottom = 1
|
||||||
|
}
|
||||||
|
return renderContentBlock(style.Render(title),
|
||||||
|
WithAlign(lipgloss.Left),
|
||||||
|
WithBorderColor(t.Accent()),
|
||||||
|
WithPaddingTop(0),
|
||||||
|
WithPaddingBottom(paddingBottom),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if body == "" && error == "" {
|
if body == "" && error == "" {
|
||||||
body = *result
|
body = *result
|
||||||
body = truncateHeight(body, 10)
|
body = truncateHeight(body, 10)
|
||||||
@@ -450,10 +529,10 @@ func renderToolInvocation(
|
|||||||
content,
|
content,
|
||||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||||
)
|
)
|
||||||
if showResult && body != "" && error == "" {
|
if showDetails && body != "" && error == "" {
|
||||||
content += "\n" + body
|
content += "\n" + body
|
||||||
}
|
}
|
||||||
if showResult && error != "" {
|
if showDetails && error != "" {
|
||||||
content += "\n" + error
|
content += "\n" + error
|
||||||
}
|
}
|
||||||
return content
|
return content
|
||||||
@@ -461,22 +540,18 @@ func renderToolInvocation(
|
|||||||
|
|
||||||
func renderToolName(name string) string {
|
func renderToolName(name string) string {
|
||||||
switch name {
|
switch name {
|
||||||
// case agent.AgentToolName:
|
case "list":
|
||||||
// return "Task"
|
return "LIST"
|
||||||
case "opencode_ls":
|
case "webfetch":
|
||||||
return "List"
|
return "FETCH"
|
||||||
case "opencode_webfetch":
|
case "todowrite":
|
||||||
return "Fetch"
|
return "PLAN"
|
||||||
case "opencode_todoread":
|
|
||||||
return "Planning"
|
|
||||||
case "opencode_todowrite":
|
|
||||||
return "Planning"
|
|
||||||
default:
|
default:
|
||||||
normalizedName := name
|
normalizedName := name
|
||||||
if strings.HasPrefix(name, "opencode_") {
|
if strings.HasPrefix(name, "opencode_") {
|
||||||
normalizedName = strings.TrimPrefix(name, "opencode_")
|
normalizedName = strings.TrimPrefix(name, "opencode_")
|
||||||
}
|
}
|
||||||
return cases.Title(language.Und).String(normalizedName)
|
return cases.Upper(language.Und).String(normalizedName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,34 +592,36 @@ func renderFile(filename string, content string, options ...fileRenderingOption)
|
|||||||
content = truncateHeight(content, renderer.height)
|
content = truncateHeight(content, renderer.height)
|
||||||
}
|
}
|
||||||
content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
|
content = fmt.Sprintf("```%s\n%s\n```", extension(renderer.filename), content)
|
||||||
content = toMarkdown(content, width, t.BackgroundSubtle())
|
content = toMarkdown(content, width, t.BackgroundPanel())
|
||||||
|
|
||||||
return renderContentBlock(content, WithFullWidth(), WithMarginBottom(1))
|
return renderContentBlock(content, WithFullWidth(), WithMarginBottom(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderToolAction(name string) string {
|
func renderToolAction(name string) string {
|
||||||
switch name {
|
switch name {
|
||||||
// case agent.AgentToolName:
|
case "task":
|
||||||
// return "Preparing prompt..."
|
return "Searching..."
|
||||||
case "opencode_bash":
|
case "bash":
|
||||||
return "Building command..."
|
return "Building command..."
|
||||||
case "opencode_edit":
|
case "edit":
|
||||||
return "Preparing edit..."
|
return "Preparing edit..."
|
||||||
case "opencode_fetch":
|
case "webfetch":
|
||||||
return "Writing fetch..."
|
return "Fetching from the web..."
|
||||||
case "opencode_glob":
|
case "glob":
|
||||||
return "Finding files..."
|
return "Finding files..."
|
||||||
case "opencode_grep":
|
case "grep":
|
||||||
return "Searching content..."
|
return "Searching content..."
|
||||||
case "opencode_ls":
|
case "list":
|
||||||
return "Listing directory..."
|
return "Listing directory..."
|
||||||
case "opencode_read":
|
case "read":
|
||||||
return "Reading file..."
|
return "Reading file..."
|
||||||
case "opencode_write":
|
case "write":
|
||||||
return "Preparing write..."
|
return "Preparing write..."
|
||||||
case "opencode_patch":
|
case "todowrite", "todoread":
|
||||||
|
return "Planning..."
|
||||||
|
case "patch":
|
||||||
return "Preparing patch..."
|
return "Preparing patch..."
|
||||||
case "opencode_batch":
|
case "batch":
|
||||||
return "Running batch operations..."
|
return "Running batch operations..."
|
||||||
}
|
}
|
||||||
return "Working..."
|
return "Working..."
|
||||||
@@ -554,9 +631,17 @@ func renderArgs(args *map[string]any, titleKey string) string {
|
|||||||
if args == nil || len(*args) == 0 {
|
if args == nil || len(*args) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(*args))
|
||||||
|
for key := range *args {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
slices.Sort(keys)
|
||||||
|
|
||||||
title := ""
|
title := ""
|
||||||
parts := []string{}
|
parts := []string{}
|
||||||
for key, value := range *args {
|
for _, key := range keys {
|
||||||
|
value := (*args)[key]
|
||||||
if value == nil {
|
if value == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +1,102 @@
|
|||||||
package chat
|
package chat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/key"
|
|
||||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
"github.com/charmbracelet/bubbles/v2/spinner"
|
||||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
"github.com/sst/opencode/internal/app"
|
"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/components/dialog"
|
||||||
"github.com/sst/opencode/internal/layout"
|
"github.com/sst/opencode/internal/layout"
|
||||||
"github.com/sst/opencode/internal/state"
|
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
"github.com/sst/opencode/pkg/client"
|
"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 {
|
type messagesComponent struct {
|
||||||
app *app.App
|
|
||||||
width, height int
|
width, height int
|
||||||
|
app *app.App
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
rendering bool
|
|
||||||
attachments viewport.Model
|
attachments viewport.Model
|
||||||
showToolResults bool
|
commands commands.CommandsComponent
|
||||||
cache *MessageCache
|
cache *MessageCache
|
||||||
|
rendering bool
|
||||||
|
showToolDetails bool
|
||||||
tail bool
|
tail bool
|
||||||
}
|
}
|
||||||
type renderFinishedMsg struct{}
|
type renderFinishedMsg struct{}
|
||||||
type ToggleToolMessagesMsg struct{}
|
type ToggleToolDetailsMsg 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"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *messagesComponent) Init() tea.Cmd {
|
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) {
|
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
switch msg := msg.(type) {
|
switch msg.(type) {
|
||||||
case SendMsg:
|
case app.SendMsg:
|
||||||
m.viewport.GotoBottom()
|
m.viewport.GotoBottom()
|
||||||
m.tail = true
|
m.tail = true
|
||||||
return m, nil
|
return m, nil
|
||||||
case dialog.ThemeChangedMsg:
|
case dialog.ThemeSelectedMsg:
|
||||||
m.cache.Clear()
|
m.cache.Clear()
|
||||||
m.renderView()
|
return m, m.Reload()
|
||||||
return m, nil
|
case ToggleToolDetailsMsg:
|
||||||
case ToggleToolMessagesMsg:
|
m.showToolDetails = !m.showToolDetails
|
||||||
m.showToolResults = !m.showToolResults
|
return m, m.Reload()
|
||||||
m.renderView()
|
case app.SessionSelectedMsg:
|
||||||
return m, nil
|
|
||||||
case state.SessionSelectedMsg:
|
|
||||||
m.cache.Clear()
|
m.cache.Clear()
|
||||||
cmd := m.Reload()
|
m.tail = true
|
||||||
m.viewport.GotoBottom()
|
return m, m.Reload()
|
||||||
return m, cmd
|
case app.SessionClearedMsg:
|
||||||
case state.SessionClearedMsg:
|
|
||||||
m.cache.Clear()
|
m.cache.Clear()
|
||||||
cmd := m.Reload()
|
cmd := m.Reload()
|
||||||
return m, cmd
|
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:
|
case renderFinishedMsg:
|
||||||
m.rendering = false
|
m.rendering = false
|
||||||
if m.tail {
|
if m.tail {
|
||||||
m.viewport.GotoBottom()
|
m.viewport.GotoBottom()
|
||||||
}
|
}
|
||||||
case state.StateUpdatedMsg:
|
case client.EventSessionUpdated, client.EventMessageUpdated:
|
||||||
m.renderView()
|
m.renderView()
|
||||||
if m.tail {
|
if m.tail {
|
||||||
m.viewport.GotoBottom()
|
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)
|
spinner, cmd := m.spinner.Update(msg)
|
||||||
m.spinner = spinner
|
m.spinner = spinner
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
updated, cmd := m.commands.Update(msg)
|
||||||
|
m.commands = updated.(commands.CommandsComponent)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +121,17 @@ func (m *messagesComponent) renderView() {
|
|||||||
for _, message := range m.app.Messages {
|
for _, message := range m.app.Messages {
|
||||||
var content string
|
var content string
|
||||||
var cached bool
|
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 := ""
|
author := ""
|
||||||
switch message.Role {
|
switch message.Role {
|
||||||
@@ -144,7 +141,7 @@ func (m *messagesComponent) renderView() {
|
|||||||
author = message.Metadata.Assistant.ModelID
|
author = message.Metadata.Assistant.ModelID
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range message.Parts {
|
for i, p := range message.Parts {
|
||||||
part, err := p.ValueByDiscriminator()
|
part, err := p.ValueByDiscriminator()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue //TODO: handle error?
|
continue //TODO: handle error?
|
||||||
@@ -171,6 +168,7 @@ func (m *messagesComponent) renderView() {
|
|||||||
previousBlockType = assistantTextBlock
|
previousBlockType = assistantTextBlock
|
||||||
}
|
}
|
||||||
case client.MessagePartToolInvocation:
|
case client.MessagePartToolInvocation:
|
||||||
|
isLastToolInvocation := slices.Contains(lastToolIndices, i)
|
||||||
toolInvocationPart := part.(client.MessagePartToolInvocation)
|
toolInvocationPart := part.(client.MessagePartToolInvocation)
|
||||||
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
|
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
|
||||||
metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{}
|
metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{}
|
||||||
@@ -186,20 +184,34 @@ func (m *messagesComponent) renderView() {
|
|||||||
if toolCall.State == "result" {
|
if toolCall.State == "result" {
|
||||||
key := m.cache.GenerateKey(message.Id,
|
key := m.cache.GenerateKey(message.Id,
|
||||||
toolCall.ToolCallId,
|
toolCall.ToolCallId,
|
||||||
m.showToolResults,
|
m.showToolDetails,
|
||||||
layout.Current.Viewport.Width,
|
layout.Current.Viewport.Width,
|
||||||
)
|
)
|
||||||
content, cached = m.cache.Get(key)
|
content, cached = m.cache.Get(key)
|
||||||
if !cached {
|
if !cached {
|
||||||
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
|
content = renderToolInvocation(
|
||||||
|
toolCall,
|
||||||
|
result,
|
||||||
|
metadata,
|
||||||
|
m.showToolDetails,
|
||||||
|
isLastToolInvocation,
|
||||||
|
false,
|
||||||
|
)
|
||||||
m.cache.Set(key, content)
|
m.cache.Set(key, content)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// if the tool call isn't finished, never cache
|
// if the tool call isn't finished, don't cache
|
||||||
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
|
content = renderToolInvocation(
|
||||||
|
toolCall,
|
||||||
|
result,
|
||||||
|
metadata,
|
||||||
|
m.showToolDetails,
|
||||||
|
isLastToolInvocation,
|
||||||
|
false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if previousBlockType != toolInvocationBlock {
|
if previousBlockType != toolInvocationBlock && m.showToolDetails {
|
||||||
blocks = append(blocks, "")
|
blocks = append(blocks, "")
|
||||||
}
|
}
|
||||||
blocks = append(blocks, content)
|
blocks = append(blocks, content)
|
||||||
@@ -261,7 +273,7 @@ func (m *messagesComponent) header() string {
|
|||||||
BorderLeft(true).
|
BorderLeft(true).
|
||||||
BorderRight(true).
|
BorderRight(true).
|
||||||
BorderBackground(t.Background()).
|
BorderBackground(t.Background()).
|
||||||
BorderForeground(t.BackgroundSubtle()).
|
BorderForeground(t.BackgroundElement()).
|
||||||
BorderStyle(lipgloss.ThickBorder()).
|
BorderStyle(lipgloss.ThickBorder()).
|
||||||
Render(header)
|
Render(header)
|
||||||
|
|
||||||
@@ -273,7 +285,13 @@ func (m *messagesComponent) View() string {
|
|||||||
return m.home()
|
return m.home()
|
||||||
}
|
}
|
||||||
if m.rendering {
|
if m.rendering {
|
||||||
return m.viewport.View()
|
return lipgloss.Place(
|
||||||
|
m.width,
|
||||||
|
m.height,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
"Loading session...",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
return lipgloss.JoinVertical(
|
return lipgloss.JoinVertical(
|
||||||
@@ -311,50 +329,43 @@ func (m *messagesComponent) home() string {
|
|||||||
// cwd := app.Info.Path.Cwd
|
// cwd := app.Info.Path.Cwd
|
||||||
// config := app.Info.Path.Config
|
// config := app.Info.Path.Config
|
||||||
|
|
||||||
commands := [][]string{
|
versionStyle := lipgloss.NewStyle().
|
||||||
{"/help", "show help"},
|
Background(t.Background()).
|
||||||
{"/sessions", "list sessions"},
|
Foreground(t.TextMuted()).
|
||||||
{"/new", "start a new session"},
|
Width(lipgloss.Width(logo)).
|
||||||
{"/model", "switch model"},
|
Align(lipgloss.Right)
|
||||||
{"/theme", "switch theme"},
|
version := versionStyle.Render(m.app.Version)
|
||||||
{"/quit", "exit the app"},
|
|
||||||
}
|
|
||||||
|
|
||||||
commandLines := []string{}
|
logoAndVersion := strings.Join([]string{logo, version}, "\n")
|
||||||
for _, command := range commands {
|
logoAndVersion = lipgloss.PlaceHorizontal(
|
||||||
commandLines = append(commandLines, (base(command[0]+" ") + muted(command[1])))
|
m.width,
|
||||||
}
|
lipgloss.Center,
|
||||||
|
logoAndVersion,
|
||||||
logoAndVersion := lipgloss.JoinVertical(
|
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||||
lipgloss.Right,
|
)
|
||||||
logo,
|
m.commands.SetBackgroundColor(t.Background())
|
||||||
muted(m.app.Version),
|
commands := lipgloss.PlaceHorizontal(
|
||||||
|
m.width,
|
||||||
|
lipgloss.Center,
|
||||||
|
m.commands.View(),
|
||||||
|
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||||
)
|
)
|
||||||
|
|
||||||
lines := []string{}
|
lines := []string{}
|
||||||
lines = append(lines, "")
|
|
||||||
lines = append(lines, "")
|
|
||||||
lines = append(lines, logoAndVersion)
|
lines = append(lines, logoAndVersion)
|
||||||
lines = append(lines, "")
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, "")
|
||||||
// lines = append(lines, base("cwd ")+muted(cwd))
|
// lines = append(lines, base("cwd ")+muted(cwd))
|
||||||
// lines = append(lines, base("config ")+muted(config))
|
// lines = append(lines, base("config ")+muted(config))
|
||||||
// lines = append(lines, "")
|
// lines = append(lines, "")
|
||||||
lines = append(lines, commandLines...)
|
lines = append(lines, commands)
|
||||||
lines = append(lines, "")
|
|
||||||
if m.rendering {
|
|
||||||
lines = append(lines, base("Loading session..."))
|
|
||||||
} else {
|
|
||||||
lines = append(lines, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.width,
|
m.width,
|
||||||
m.height,
|
m.height,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
baseStyle.Width(lipgloss.Width(logoAndVersion)).Render(
|
baseStyle.Render(strings.Join(lines, "\n")),
|
||||||
strings.Join(lines, "\n"),
|
|
||||||
),
|
|
||||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -373,6 +384,7 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
|
|||||||
m.viewport.SetHeight(height - lipgloss.Height(m.header()))
|
m.viewport.SetHeight(height - lipgloss.Height(m.header()))
|
||||||
m.attachments.SetWidth(width + 40)
|
m.attachments.SetWidth(width + 40)
|
||||||
m.attachments.SetHeight(3)
|
m.attachments.SetHeight(3)
|
||||||
|
m.commands.SetSize(width, height)
|
||||||
m.renderView()
|
m.renderView()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -389,7 +401,43 @@ 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{
|
customSpinner := spinner.Spinner{
|
||||||
Frames: []string{" ", "┃", "┃"},
|
Frames: []string{" ", "┃", "┃"},
|
||||||
FPS: time.Second / 3,
|
FPS: time.Second / 3,
|
||||||
@@ -398,17 +446,22 @@ func NewMessagesComponent(app *app.App) layout.ModelWithView {
|
|||||||
|
|
||||||
vp := viewport.New()
|
vp := viewport.New()
|
||||||
attachments := viewport.New()
|
attachments := viewport.New()
|
||||||
vp.KeyMap.PageUp = messageKeys.PageUp
|
vp.KeyMap = viewport.KeyMap{}
|
||||||
vp.KeyMap.PageDown = messageKeys.PageDown
|
|
||||||
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
|
t := theme.CurrentTheme()
|
||||||
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
|
commandsView := commands.New(
|
||||||
|
app,
|
||||||
|
commands.WithBackground(t.Background()),
|
||||||
|
commands.WithLimit(6),
|
||||||
|
)
|
||||||
|
|
||||||
return &messagesComponent{
|
return &messagesComponent{
|
||||||
app: app,
|
app: app,
|
||||||
viewport: vp,
|
viewport: vp,
|
||||||
spinner: s,
|
spinner: s,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
showToolResults: true,
|
commands: commandsView,
|
||||||
|
showToolDetails: true,
|
||||||
cache: NewMessageCache(),
|
cache: NewMessageCache(),
|
||||||
tail: true,
|
tail: true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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
|
||||||
|
SetBackgroundColor(color compat.AdaptiveColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) SetBackgroundColor(color compat.AdaptiveColor) {
|
||||||
|
c.background = &color
|
||||||
|
}
|
||||||
|
|
||||||
|
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,12 +1,13 @@
|
|||||||
package dialog
|
package dialog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/key"
|
"github.com/charmbracelet/bubbles/v2/key"
|
||||||
"github.com/charmbracelet/bubbles/v2/textarea"
|
"github.com/charmbracelet/bubbles/v2/textarea"
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
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/components/list"
|
||||||
"github.com/sst/opencode/internal/layout"
|
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
"github.com/sst/opencode/internal/util"
|
"github.com/sst/opencode/internal/util"
|
||||||
@@ -76,7 +77,8 @@ type CompletionDialogCompleteItemMsg struct {
|
|||||||
type CompletionDialogCloseMsg struct{}
|
type CompletionDialogCloseMsg struct{}
|
||||||
|
|
||||||
type CompletionDialog interface {
|
type CompletionDialog interface {
|
||||||
layout.ModelWithView
|
tea.Model
|
||||||
|
tea.ViewModel
|
||||||
SetWidth(width int)
|
SetWidth(width int)
|
||||||
IsEmpty() bool
|
IsEmpty() bool
|
||||||
SetProvider(provider CompletionProvider)
|
SetProvider(provider CompletionProvider)
|
||||||
@@ -98,10 +100,10 @@ type completionDialogKeyMap struct {
|
|||||||
|
|
||||||
var completionDialogKeys = completionDialogKeyMap{
|
var completionDialogKeys = completionDialogKeyMap{
|
||||||
Complete: key.NewBinding(
|
Complete: key.NewBinding(
|
||||||
key.WithKeys("tab", "enter"),
|
key.WithKeys("tab", "enter", "right"),
|
||||||
),
|
),
|
||||||
Cancel: key.NewBinding(
|
Cancel: key.NewBinding(
|
||||||
key.WithKeys(" ", "esc", "backspace"),
|
key.WithKeys(" ", "esc", "backspace", "ctrl+c"),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,39 +111,13 @@ func (c *completionDialogComponent) Init() tea.Cmd {
|
|||||||
return nil
|
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) {
|
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case []CompletionItemI:
|
case []CompletionItemI:
|
||||||
c.list.SetItems(msg)
|
c.list.SetItems(msg)
|
||||||
|
case app.CompletionDialogTriggerdMsg:
|
||||||
|
c.pseudoSearchTextArea.SetValue(msg.InitialValue)
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if c.pseudoSearchTextArea.Focused() {
|
if c.pseudoSearchTextArea.Focused() {
|
||||||
if !key.Matches(msg, completionDialogKeys.Complete) {
|
if !key.Matches(msg, completionDialogKeys.Complete) {
|
||||||
@@ -160,9 +136,8 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmd = func() tea.Msg {
|
cmd = func() tea.Msg {
|
||||||
items, err := c.completionProvider.GetChildEntries(query)
|
items, err := c.completionProvider.GetChildEntries(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// status.Error(err.Error())
|
slog.Error("Failed to get completion items", "error", err)
|
||||||
}
|
}
|
||||||
// c.list.SetItems(items)
|
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
@@ -192,13 +167,12 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmd := func() tea.Msg {
|
cmd := func() tea.Msg {
|
||||||
items, err := c.completionProvider.GetChildEntries("")
|
items, err := c.completionProvider.GetChildEntries("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// status.Error(err.Error())
|
slog.Error("Failed to get completion items", "error", err)
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
|
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
|
||||||
c.pseudoSearchTextArea.SetValue(msg.String())
|
|
||||||
return c, tea.Batch(cmds...)
|
return c, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
@@ -227,13 +201,6 @@ func (c *completionDialogComponent) View() string {
|
|||||||
|
|
||||||
return baseStyle.Padding(0, 0).
|
return baseStyle.Padding(0, 0).
|
||||||
Background(t.BackgroundElement()).
|
Background(t.BackgroundElement()).
|
||||||
Border(lipgloss.ThickBorder()).
|
|
||||||
BorderTop(false).
|
|
||||||
BorderBottom(false).
|
|
||||||
BorderRight(true).
|
|
||||||
BorderLeft(true).
|
|
||||||
BorderBackground(t.Background()).
|
|
||||||
BorderForeground(t.BackgroundSubtle()).
|
|
||||||
Width(c.width).
|
Width(c.width).
|
||||||
Render(c.list.View())
|
Render(c.list.View())
|
||||||
}
|
}
|
||||||
@@ -250,9 +217,35 @@ func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
|
|||||||
if c.completionProvider.GetId() != provider.GetId() {
|
if c.completionProvider.GetId() != provider.GetId() {
|
||||||
c.completionProvider = provider
|
c.completionProvider = provider
|
||||||
c.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
|
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 {
|
func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
|
||||||
ti := textarea.New()
|
ti := textarea.New()
|
||||||
|
|
||||||
@@ -266,7 +259,7 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
|
|||||||
go func() {
|
go func() {
|
||||||
items, err := completionProvider.GetChildEntries("")
|
items, err := completionProvider.GetChildEntries("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// status.Error(err.Error())
|
slog.Error("Failed to get completion items", "error", err)
|
||||||
}
|
}
|
||||||
li.SetItems(items)
|
li.SetItems(items)
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package dialog
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/key"
|
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
|
"github.com/sst/opencode/internal/commands"
|
||||||
"github.com/sst/opencode/internal/components/modal"
|
"github.com/sst/opencode/internal/components/modal"
|
||||||
"github.com/sst/opencode/internal/layout"
|
"github.com/sst/opencode/internal/layout"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
@@ -15,28 +15,9 @@ type helpDialog struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
modal *modal.Modal
|
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 {
|
func (h *helpDialog) Init() tea.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -63,19 +44,24 @@ func (h *helpDialog) View() string {
|
|||||||
PaddingLeft(1).Background(t.BackgroundElement())
|
PaddingLeft(1).Background(t.BackgroundElement())
|
||||||
|
|
||||||
lines := []string{}
|
lines := []string{}
|
||||||
for _, b := range h.bindings {
|
for _, b := range h.commands {
|
||||||
content := keyStyle.Render(b.Help().Key)
|
// Only interested in slash commands
|
||||||
content += descStyle.Render(" " + b.Help().Desc)
|
if b.Trigger == "" {
|
||||||
for i, key := range b.Keys() {
|
continue
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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))
|
lines = append(lines, contentStyle.Render(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,9 +80,9 @@ type HelpDialog interface {
|
|||||||
layout.Modal
|
layout.Modal
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHelpDialog(bindings ...key.Binding) HelpDialog {
|
func NewHelpDialog(commands []commands.Command) HelpDialog {
|
||||||
return &helpDialog{
|
return &helpDialog{
|
||||||
bindings: bindings,
|
commands: commands,
|
||||||
modal: modal.New(),
|
modal: modal.New(modal.WithTitle("Help")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/components/modal"
|
"github.com/sst/opencode/internal/components/modal"
|
||||||
"github.com/sst/opencode/internal/layout"
|
"github.com/sst/opencode/internal/layout"
|
||||||
"github.com/sst/opencode/internal/state"
|
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
"github.com/sst/opencode/internal/util"
|
"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(
|
return m, tea.Sequence(
|
||||||
util.CmdHandler(modal.CloseModalMsg{}),
|
util.CmdHandler(modal.CloseModalMsg{}),
|
||||||
util.CmdHandler(
|
util.CmdHandler(
|
||||||
state.ModelSelectedMsg{
|
app.ModelSelectedMsg{
|
||||||
Provider: m.provider,
|
Provider: m.provider,
|
||||||
Model: models[m.selectedIdx],
|
Model: models[m.selectedIdx],
|
||||||
}),
|
}),
|
||||||
@@ -181,6 +180,7 @@ func (m *modelDialog) switchProvider(offset int) {
|
|||||||
|
|
||||||
m.hScrollOffset = newOffset
|
m.hScrollOffset = newOffset
|
||||||
m.provider = m.availableProviders[m.hScrollOffset]
|
m.provider = m.availableProviders[m.hScrollOffset]
|
||||||
|
m.modal.SetTitle(fmt.Sprintf("Select %s Model", m.provider.Name))
|
||||||
m.setupModelsForProvider(m.provider.Id)
|
m.setupModelsForProvider(m.provider.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,14 +190,6 @@ func (m *modelDialog) View() string {
|
|||||||
Background(t.BackgroundElement()).
|
Background(t.BackgroundElement()).
|
||||||
Foreground(t.Text())
|
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
|
// Render visible models
|
||||||
endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models))
|
endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models))
|
||||||
modelItems := make([]string, 0, endIdx-m.scrollOffset)
|
modelItems := make([]string, 0, endIdx-m.scrollOffset)
|
||||||
@@ -218,8 +210,9 @@ func (m *modelDialog) View() string {
|
|||||||
|
|
||||||
content := lipgloss.JoinVertical(
|
content := lipgloss.JoinVertical(
|
||||||
lipgloss.Left,
|
lipgloss.Left,
|
||||||
title,
|
baseStyle.
|
||||||
baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
|
Width(maxDialogWidth).
|
||||||
|
Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
|
||||||
scrollIndicator,
|
scrollIndicator,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -239,12 +232,7 @@ func (m *modelDialog) getScrollIndicators(maxWidth int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.hScrollPossible {
|
if m.hScrollPossible {
|
||||||
if m.hScrollOffset > 0 {
|
indicator = "← " + indicator + "→"
|
||||||
indicator = "← " + indicator
|
|
||||||
}
|
|
||||||
if m.hScrollOffset < len(m.availableProviders)-1 {
|
|
||||||
indicator += "→"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if indicator == "" {
|
if indicator == "" {
|
||||||
@@ -314,6 +302,6 @@ func NewModelDialog(app *app.App) ModelDialog {
|
|||||||
hScrollOffset: 0,
|
hScrollOffset: 0,
|
||||||
hScrollPossible: len(availableProviders) > 1,
|
hScrollPossible: len(availableProviders) > 1,
|
||||||
provider: availableProviders[0],
|
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"
|
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
"github.com/sst/opencode/internal/layout"
|
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
"github.com/sst/opencode/internal/util"
|
"github.com/sst/opencode/internal/util"
|
||||||
@@ -30,7 +29,8 @@ type PermissionResponseMsg struct {
|
|||||||
|
|
||||||
// PermissionDialogComponent interface for permission dialog component
|
// PermissionDialogComponent interface for permission dialog component
|
||||||
type PermissionDialogComponent interface {
|
type PermissionDialogComponent interface {
|
||||||
layout.ModelWithView
|
tea.Model
|
||||||
|
tea.ViewModel
|
||||||
// SetPermissions(permission permission.PermissionRequest) tea.Cmd
|
// SetPermissions(permission permission.PermissionRequest) tea.Cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"github.com/sst/opencode/internal/components/list"
|
"github.com/sst/opencode/internal/components/list"
|
||||||
"github.com/sst/opencode/internal/components/modal"
|
"github.com/sst/opencode/internal/components/modal"
|
||||||
"github.com/sst/opencode/internal/layout"
|
"github.com/sst/opencode/internal/layout"
|
||||||
"github.com/sst/opencode/internal/state"
|
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
"github.com/sst/opencode/internal/util"
|
"github.com/sst/opencode/internal/util"
|
||||||
@@ -20,14 +19,12 @@ type SessionDialog interface {
|
|||||||
layout.Modal
|
layout.Modal
|
||||||
}
|
}
|
||||||
|
|
||||||
type sessionItem struct {
|
type sessionItem client.SessionInfo
|
||||||
session client.SessionInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s sessionItem) Render(selected bool, width int) string {
|
func (s sessionItem) Render(selected bool, width int) string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
baseStyle := styles.BaseStyle().
|
baseStyle := styles.BaseStyle().
|
||||||
Width(width - 2).
|
Width(width - 4).
|
||||||
Background(t.BackgroundElement())
|
Background(t.BackgroundElement())
|
||||||
|
|
||||||
if selected {
|
if selected {
|
||||||
@@ -40,7 +37,7 @@ func (s sessionItem) Render(selected bool, width int) string {
|
|||||||
Foreground(t.Text())
|
Foreground(t.Text())
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseStyle.Padding(0, 1).Render(s.session.Title)
|
return baseStyle.Padding(0, 1).Render(s.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
type sessionDialog struct {
|
type sessionDialog struct {
|
||||||
@@ -61,15 +58,14 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
s.width = msg.Width
|
s.width = msg.Width
|
||||||
s.height = msg.Height
|
s.height = msg.Height
|
||||||
s.list.SetMaxWidth(layout.Current.Container.Width - 12)
|
s.list.SetMaxWidth(layout.Current.Container.Width - 12)
|
||||||
case tea.KeyMsg:
|
case tea.KeyPressMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "enter":
|
case "enter":
|
||||||
if item, idx := s.list.GetSelectedItem(); idx >= 0 {
|
if item, idx := s.list.GetSelectedItem(); idx >= 0 {
|
||||||
selectedSession := item.session
|
s.selectedSessionID = item.Id
|
||||||
s.selectedSessionID = selectedSession.Id
|
|
||||||
return s, tea.Sequence(
|
return s, tea.Sequence(
|
||||||
util.CmdHandler(modal.CloseModalMsg{}),
|
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
|
var sessionItems []sessionItem
|
||||||
for _, sess := range sessions {
|
for _, sess := range sessions {
|
||||||
sessionItems = append(sessionItems, sessionItem{session: sess})
|
if sess.ParentID != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sessionItems = append(sessionItems, sessionItem(sess))
|
||||||
}
|
}
|
||||||
|
|
||||||
list := list.NewListComponent(
|
list := list.NewListComponent(
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"github.com/sst/opencode/internal/util"
|
"github.com/sst/opencode/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ThemeChangedMsg is sent when the theme is changed
|
// ThemeSelectedMsg is sent when the theme is changed
|
||||||
type ThemeChangedMsg struct {
|
type ThemeSelectedMsg struct {
|
||||||
ThemeName string
|
ThemeName string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return t, tea.Sequence(
|
return t, tea.Sequence(
|
||||||
util.CmdHandler(modal.CloseModalMsg{}),
|
util.CmdHandler(modal.CloseModalMsg{}),
|
||||||
util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
|
util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,10 +441,10 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.C
|
|||||||
<entry type="TextWhitespace" style="%s"/>
|
<entry type="TextWhitespace" style="%s"/>
|
||||||
</style>
|
</style>
|
||||||
`,
|
`,
|
||||||
getColor(t.BackgroundSubtle()), // Background
|
getColor(t.BackgroundPanel()), // Background
|
||||||
getColor(t.Text()), // Text
|
getColor(t.Text()), // Text
|
||||||
getColor(t.Text()), // Other
|
getColor(t.Text()), // Other
|
||||||
getColor(t.Error()), // Error
|
getColor(t.Error()), // Error
|
||||||
|
|
||||||
getColor(t.SyntaxKeyword()), // Keyword
|
getColor(t.SyntaxKeyword()), // Keyword
|
||||||
getColor(t.SyntaxKeyword()), // KeywordConstant
|
getColor(t.SyntaxKeyword()), // KeywordConstant
|
||||||
@@ -614,7 +614,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
|
|||||||
|
|
||||||
// Get the appropriate color based on terminal background
|
// Get the appropriate color based on terminal background
|
||||||
bgColor := lipgloss.Color(getColor(highlightBg))
|
bgColor := lipgloss.Color(getColor(highlightBg))
|
||||||
fgColor := lipgloss.Color(getColor(theme.CurrentTheme().BackgroundSubtle()))
|
fgColor := lipgloss.Color(getColor(theme.CurrentTheme().BackgroundPanel()))
|
||||||
|
|
||||||
for i := 0; i < len(content); {
|
for i := 0; i < len(content); {
|
||||||
// Check if we're at an ANSI sequence
|
// Check if we're at an ANSI sequence
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package list
|
package list
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/key"
|
"github.com/charmbracelet/bubbles/v2/key"
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
|
||||||
"github.com/sst/opencode/internal/layout"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ListItem interface {
|
type ListItem interface {
|
||||||
@@ -12,7 +12,8 @@ type ListItem interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type List[T ListItem] interface {
|
type List[T ListItem] interface {
|
||||||
layout.ModelWithView
|
tea.Model
|
||||||
|
tea.ViewModel
|
||||||
SetMaxWidth(maxWidth int)
|
SetMaxWidth(maxWidth int)
|
||||||
GetSelectedItem() (item T, idx int)
|
GetSelectedItem() (item T, idx int)
|
||||||
SetItems(items []T)
|
SetItems(items []T)
|
||||||
@@ -148,7 +149,7 @@ func (c *listComponent[T]) View() string {
|
|||||||
listItems = append(listItems, title)
|
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] {
|
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package modal
|
package modal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
"github.com/sst/opencode/internal/layout"
|
"github.com/sst/opencode/internal/layout"
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
@@ -66,6 +68,10 @@ func New(opts ...ModalOption) *Modal {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Modal) SetTitle(title string) {
|
||||||
|
m.title = title
|
||||||
|
}
|
||||||
|
|
||||||
// Render renders the modal centered on the screen
|
// Render renders the modal centered on the screen
|
||||||
func (m *Modal) Render(contentView string, background string) string {
|
func (m *Modal) Render(contentView string, background string) string {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
@@ -95,15 +101,20 @@ func (m *Modal) Render(contentView string, background string) string {
|
|||||||
titleStyle := baseStyle.
|
titleStyle := baseStyle.
|
||||||
Foreground(t.Primary()).
|
Foreground(t.Primary()).
|
||||||
Bold(true).
|
Bold(true).
|
||||||
Width(innerWidth).
|
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
titleView := titleStyle.Render(m.title)
|
escStyle := baseStyle.Foreground(t.TextMuted()).Bold(false)
|
||||||
finalContent = lipgloss.JoinVertical(
|
escText := escStyle.Render("esc")
|
||||||
lipgloss.Left,
|
|
||||||
titleView,
|
// Calculate position for esc text
|
||||||
contentView,
|
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 {
|
} else {
|
||||||
finalContent = contentView
|
finalContent = contentView
|
||||||
}
|
}
|
||||||
@@ -112,14 +123,7 @@ func (m *Modal) Render(contentView string, background string) string {
|
|||||||
PaddingTop(1).
|
PaddingTop(1).
|
||||||
PaddingBottom(1).
|
PaddingBottom(1).
|
||||||
PaddingLeft(2).
|
PaddingLeft(2).
|
||||||
PaddingRight(2).
|
PaddingRight(2)
|
||||||
BorderStyle(lipgloss.ThickBorder()).
|
|
||||||
BorderLeft(true).
|
|
||||||
BorderRight(true).
|
|
||||||
BorderLeftForeground(t.BackgroundSubtle()).
|
|
||||||
BorderLeftBackground(t.Background()).
|
|
||||||
BorderRightForeground(t.BackgroundSubtle()).
|
|
||||||
BorderRightBackground(t.Background())
|
|
||||||
|
|
||||||
modalView := modalStyle.
|
modalView := modalStyle.
|
||||||
Width(outerWidth).
|
Width(outerWidth).
|
||||||
@@ -139,5 +143,7 @@ func (m *Modal) Render(contentView string, background string) string {
|
|||||||
row,
|
row,
|
||||||
modalView,
|
modalView,
|
||||||
background,
|
background,
|
||||||
|
layout.WithOverlayBorder(),
|
||||||
|
layout.WithOverlayBorderColor(t.Primary()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -1,4 +1,4 @@
|
|||||||
package core
|
package status
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -7,13 +7,13 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
"github.com/sst/opencode/internal/app"
|
"github.com/sst/opencode/internal/app"
|
||||||
"github.com/sst/opencode/internal/layout"
|
|
||||||
"github.com/sst/opencode/internal/styles"
|
"github.com/sst/opencode/internal/styles"
|
||||||
"github.com/sst/opencode/internal/theme"
|
"github.com/sst/opencode/internal/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StatusComponent interface {
|
type StatusComponent interface {
|
||||||
layout.ModelWithView
|
tea.Model
|
||||||
|
tea.ViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
type statusComponent struct {
|
type statusComponent struct {
|
||||||
@@ -88,7 +88,7 @@ func (m statusComponent) View() string {
|
|||||||
|
|
||||||
cwd := styles.Padded().
|
cwd := styles.Padded().
|
||||||
Foreground(t.TextMuted()).
|
Foreground(t.TextMuted()).
|
||||||
Background(t.BackgroundSubtle()).
|
Background(t.BackgroundPanel()).
|
||||||
Render(m.app.Info.Path.Cwd)
|
Render(m.app.Info.Path.Cwd)
|
||||||
|
|
||||||
sessionInfo := ""
|
sessionInfo := ""
|
||||||
@@ -123,7 +123,7 @@ func (m statusComponent) View() string {
|
|||||||
0,
|
0,
|
||||||
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
|
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
|
||||||
)
|
)
|
||||||
spacer := lipgloss.NewStyle().Background(t.BackgroundSubtle()).Width(space).Render("")
|
spacer := lipgloss.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("")
|
||||||
|
|
||||||
status := logo + cwd + spacer + sessionInfo
|
status := logo + cwd + spacer + sessionInfo
|
||||||
|
|
||||||
@@ -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,25 +7,31 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
|
"github.com/sst/opencode/pkg/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type State struct {
|
||||||
Theme string `toml:"theme"`
|
Theme string `toml:"theme"`
|
||||||
Provider string `toml:"provider"`
|
Provider string `toml:"provider"`
|
||||||
Model string `toml:"model"`
|
Model string `toml:"model"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig creates a new Config instance with default values.
|
func NewState() *State {
|
||||||
// This can be useful for initializing a new configuration file.
|
return &State{
|
||||||
func NewConfig() *Config {
|
|
||||||
return &Config{
|
|
||||||
Theme: "opencode",
|
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.
|
// 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)
|
file, err := os.Create(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create/open config file %s: %w", filePath, err)
|
return fmt.Errorf("failed to create/open config file %s: %w", filePath, err)
|
||||||
@@ -34,26 +40,26 @@ func SaveConfig(filePath string, config *Config) error {
|
|||||||
|
|
||||||
writer := bufio.NewWriter(file)
|
writer := bufio.NewWriter(file)
|
||||||
encoder := toml.NewEncoder(writer)
|
encoder := toml.NewEncoder(writer)
|
||||||
if err := encoder.Encode(config); err != nil {
|
if err := encoder.Encode(state); err != nil {
|
||||||
return fmt.Errorf("failed to encode config to TOML file %s: %w", filePath, err)
|
return fmt.Errorf("failed to encode state to TOML file %s: %w", filePath, err)
|
||||||
}
|
}
|
||||||
if err := writer.Flush(); err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig reads a Config struct from the specified TOML file.
|
// LoadState loads the state from the specified TOML file.
|
||||||
// It returns a pointer to the Config struct and an error if any issues occur.
|
// It returns a pointer to the State struct and an error if any issues occur.
|
||||||
func LoadConfig(filePath string) (*Config, error) {
|
func LoadState(filePath string) (*State, error) {
|
||||||
var config Config
|
var state State
|
||||||
if _, err := toml.DecodeFile(filePath, &config); err != nil {
|
if _, err := toml.DecodeFile(filePath, &state); err != nil {
|
||||||
if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
|
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 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"
|
"github.com/sst/opencode/internal/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelWithView interface {
|
type Container interface {
|
||||||
tea.Model
|
tea.Model
|
||||||
tea.ViewModel
|
tea.ViewModel
|
||||||
}
|
|
||||||
|
|
||||||
type Container interface {
|
|
||||||
ModelWithView
|
|
||||||
Sizeable
|
Sizeable
|
||||||
Focus()
|
Focusable
|
||||||
Blur()
|
Alignable
|
||||||
MaxWidth() int
|
|
||||||
Alignment() lipgloss.Position
|
|
||||||
GetPosition() (x, y int)
|
|
||||||
GetContent() ModelWithView
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type container struct {
|
type container struct {
|
||||||
@@ -28,7 +20,7 @@ type container struct {
|
|||||||
x int
|
x int
|
||||||
y int
|
y int
|
||||||
|
|
||||||
content ModelWithView
|
content tea.ViewModel
|
||||||
|
|
||||||
paddingTop int
|
paddingTop int
|
||||||
paddingRight int
|
paddingRight int
|
||||||
@@ -48,13 +40,19 @@ type container struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *container) Init() tea.Cmd {
|
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) {
|
func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
u, cmd := c.content.Update(msg)
|
if model, ok := c.content.(tea.Model); ok {
|
||||||
c.content = u.(ModelWithView)
|
u, cmd := model.Update(msg)
|
||||||
return c, cmd
|
c.content = u.(tea.ViewModel)
|
||||||
|
return c, cmd
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *container) View() string {
|
func (c *container) View() string {
|
||||||
@@ -156,21 +154,28 @@ func (c *container) Alignment() lipgloss.Position {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Focus sets the container as focused
|
// Focus sets the container as focused
|
||||||
func (c *container) Focus() {
|
func (c *container) Focus() tea.Cmd {
|
||||||
c.focused = true
|
c.focused = true
|
||||||
// Pass focus to content if it supports it
|
if focusable, ok := c.content.(Focusable); ok {
|
||||||
if focusable, ok := c.content.(interface{ Focus() }); ok {
|
return focusable.Focus()
|
||||||
focusable.Focus()
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blur removes focus from the container
|
// Blur removes focus from the container
|
||||||
func (c *container) Blur() {
|
func (c *container) Blur() tea.Cmd {
|
||||||
c.focused = false
|
c.focused = false
|
||||||
// Remove focus from content if it supports it
|
if blurable, ok := c.content.(Focusable); ok {
|
||||||
if blurable, ok := c.content.(interface{ Blur() }); ok {
|
return blurable.Blur()
|
||||||
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
|
// 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
|
return c.x, c.y
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetContent returns the content of the container
|
func (c *container) SetPosition(x, y int) {
|
||||||
func (c *container) GetContent() ModelWithView {
|
c.x = x
|
||||||
return c.content
|
c.y = y
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContainerOption func(*container)
|
type ContainerOption func(*container)
|
||||||
|
|
||||||
func NewContainer(content ModelWithView, options ...ContainerOption) Container {
|
func NewContainer(content tea.ViewModel, options ...ContainerOption) Container {
|
||||||
c := &container{
|
c := &container{
|
||||||
content: content,
|
content: content,
|
||||||
borderStyle: lipgloss.NormalBorder(),
|
borderStyle: lipgloss.NormalBorder(),
|
||||||
|
|||||||
@@ -13,22 +13,22 @@ const (
|
|||||||
FlexDirectionVertical
|
FlexDirectionVertical
|
||||||
)
|
)
|
||||||
|
|
||||||
type FlexPaneSize struct {
|
type FlexChildSize struct {
|
||||||
Fixed bool
|
Fixed bool
|
||||||
Size int
|
Size int
|
||||||
}
|
}
|
||||||
|
|
||||||
var FlexPaneSizeGrow = FlexPaneSize{Fixed: false}
|
var FlexChildSizeGrow = FlexChildSize{Fixed: false}
|
||||||
|
|
||||||
func FlexPaneSizeFixed(size int) FlexPaneSize {
|
func FlexChildSizeFixed(size int) FlexChildSize {
|
||||||
return FlexPaneSize{Fixed: true, Size: size}
|
return FlexChildSize{Fixed: true, Size: size}
|
||||||
}
|
}
|
||||||
|
|
||||||
type FlexLayout interface {
|
type FlexLayout interface {
|
||||||
ModelWithView
|
tea.ViewModel
|
||||||
Sizeable
|
Sizeable
|
||||||
SetPanes(panes []Container) tea.Cmd
|
SetChildren(panes []tea.ViewModel) tea.Cmd
|
||||||
SetPaneSizes(sizes []FlexPaneSize) tea.Cmd
|
SetSizes(sizes []FlexChildSize) tea.Cmd
|
||||||
SetDirection(direction FlexDirection) tea.Cmd
|
SetDirection(direction FlexDirection) tea.Cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,94 +36,69 @@ type flexLayout struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
direction FlexDirection
|
direction FlexDirection
|
||||||
panes []Container
|
children []tea.ViewModel
|
||||||
sizes []FlexPaneSize
|
sizes []FlexChildSize
|
||||||
}
|
}
|
||||||
|
|
||||||
type FlexLayoutOption func(*flexLayout)
|
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 {
|
func (f *flexLayout) View() string {
|
||||||
if len(f.panes) == 0 {
|
if len(f.children) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
views := make([]string, 0, len(f.panes))
|
views := make([]string, 0, len(f.children))
|
||||||
for i, pane := range f.panes {
|
for i, child := range f.children {
|
||||||
if pane == nil {
|
if child == nil {
|
||||||
continue
|
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 {
|
if f.direction == FlexDirectionHorizontal {
|
||||||
paneWidth, paneHeight = f.calculatePaneSize(i)
|
childWidth, childHeight = f.calculateChildSize(i)
|
||||||
view := lipgloss.PlaceHorizontal(
|
view := lipgloss.PlaceHorizontal(
|
||||||
paneWidth,
|
childWidth,
|
||||||
pane.Alignment(),
|
alignment,
|
||||||
pane.View(),
|
child.View(),
|
||||||
|
// TODO: make configurable WithBackgroundStyle
|
||||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||||
)
|
)
|
||||||
views = append(views, view)
|
views = append(views, view)
|
||||||
} else {
|
} else {
|
||||||
paneWidth, paneHeight = f.calculatePaneSize(i)
|
childWidth, childHeight = f.calculateChildSize(i)
|
||||||
view := lipgloss.Place(
|
view := lipgloss.Place(
|
||||||
f.width,
|
f.width,
|
||||||
paneHeight,
|
childHeight,
|
||||||
lipgloss.Center,
|
lipgloss.Center,
|
||||||
pane.Alignment(),
|
alignment,
|
||||||
pane.View(),
|
child.View(),
|
||||||
|
// TODO: make configurable WithBackgroundStyle
|
||||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||||
)
|
)
|
||||||
views = append(views, view)
|
views = append(views, view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.direction == FlexDirectionHorizontal {
|
if f.direction == FlexDirectionHorizontal {
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Center, views...)
|
return lipgloss.JoinHorizontal(lipgloss.Center, views...)
|
||||||
}
|
}
|
||||||
return lipgloss.JoinVertical(lipgloss.Center, views...)
|
return lipgloss.JoinVertical(lipgloss.Center, views...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *flexLayout) calculatePaneSize(index int) (width, height int) {
|
func (f *flexLayout) calculateChildSize(index int) (width, height int) {
|
||||||
if index >= len(f.panes) {
|
if index >= len(f.children) {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
totalFixed := 0
|
totalFixed := 0
|
||||||
flexCount := 0
|
flexCount := 0
|
||||||
|
|
||||||
for i, pane := range f.panes {
|
for i, child := range f.children {
|
||||||
if pane == nil {
|
if child == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if i < len(f.sizes) && f.sizes[i].Fixed {
|
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
|
var cmds []tea.Cmd
|
||||||
currentX, currentY := 0, 0
|
currentX, currentY := 0, 0
|
||||||
|
|
||||||
for i, pane := range f.panes {
|
for i, child := range f.children {
|
||||||
if pane != nil {
|
if child != nil {
|
||||||
paneWidth, paneHeight := f.calculatePaneSize(i)
|
paneWidth, paneHeight := f.calculateChildSize(i)
|
||||||
|
alignment := lipgloss.Center
|
||||||
|
if alignable, ok := child.(Alignable); ok {
|
||||||
|
alignment = alignable.Alignment()
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate actual position based on alignment
|
// Calculate actual position based on alignment
|
||||||
actualX, actualY := currentX, currentY
|
actualX, actualY := currentX, currentY
|
||||||
@@ -179,11 +158,13 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
|
|||||||
} else {
|
} else {
|
||||||
// In vertical layout, horizontal alignment affects X position
|
// In vertical layout, horizontal alignment affects X position
|
||||||
contentWidth := paneWidth
|
contentWidth := paneWidth
|
||||||
if pane.MaxWidth() > 0 && contentWidth > pane.MaxWidth() {
|
if alignable, ok := child.(Alignable); ok {
|
||||||
contentWidth = pane.MaxWidth()
|
if alignable.MaxWidth() > 0 && contentWidth > alignable.MaxWidth() {
|
||||||
|
contentWidth = alignable.MaxWidth()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch pane.Alignment() {
|
switch alignment {
|
||||||
case lipgloss.Center:
|
case lipgloss.Center:
|
||||||
actualX = (f.width - contentWidth) / 2
|
actualX = (f.width - contentWidth) / 2
|
||||||
case lipgloss.Right:
|
case lipgloss.Right:
|
||||||
@@ -193,14 +174,15 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set position if the pane is a *container
|
// Set position if the pane is Alignable
|
||||||
if c, ok := pane.(*container); ok {
|
if c, ok := child.(Alignable); ok {
|
||||||
c.x = actualX
|
c.SetPosition(actualX, actualY)
|
||||||
c.y = actualY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := pane.SetSize(paneWidth, paneHeight)
|
if sizeable, ok := child.(Sizeable); ok {
|
||||||
cmds = append(cmds, cmd)
|
cmd := sizeable.SetSize(paneWidth, paneHeight)
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
// Update position for next pane
|
// Update position for next pane
|
||||||
if f.direction == FlexDirectionHorizontal {
|
if f.direction == FlexDirectionHorizontal {
|
||||||
@@ -217,15 +199,15 @@ func (f *flexLayout) GetSize() (int, int) {
|
|||||||
return f.width, f.height
|
return f.width, f.height
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *flexLayout) SetPanes(panes []Container) tea.Cmd {
|
func (f *flexLayout) SetChildren(children []tea.ViewModel) tea.Cmd {
|
||||||
f.panes = panes
|
f.children = children
|
||||||
if f.width > 0 && f.height > 0 {
|
if f.width > 0 && f.height > 0 {
|
||||||
return f.SetSize(f.width, f.height)
|
return f.SetSize(f.width, f.height)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *flexLayout) SetPaneSizes(sizes []FlexPaneSize) tea.Cmd {
|
func (f *flexLayout) SetSizes(sizes []FlexChildSize) tea.Cmd {
|
||||||
f.sizes = sizes
|
f.sizes = sizes
|
||||||
if f.width > 0 && f.height > 0 {
|
if f.width > 0 && f.height > 0 {
|
||||||
return f.SetSize(f.width, f.height)
|
return f.SetSize(f.width, f.height)
|
||||||
@@ -241,11 +223,11 @@ func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFlexLayout(options ...FlexLayoutOption) FlexLayout {
|
func NewFlexLayout(children []tea.ViewModel, options ...FlexLayoutOption) FlexLayout {
|
||||||
layout := &flexLayout{
|
layout := &flexLayout{
|
||||||
|
children: children,
|
||||||
direction: FlexDirectionHorizontal,
|
direction: FlexDirectionHorizontal,
|
||||||
panes: []Container{},
|
sizes: []FlexChildSize{},
|
||||||
sizes: []FlexPaneSize{},
|
|
||||||
}
|
}
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
option(layout)
|
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) {
|
return func(f *flexLayout) {
|
||||||
f.panes = panes
|
f.children = children
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithPaneSizes(sizes ...FlexPaneSize) FlexLayoutOption {
|
func WithSizes(sizes ...FlexChildSize) FlexLayoutOption {
|
||||||
return func(f *flexLayout) {
|
return func(f *flexLayout) {
|
||||||
f.sizes = sizes
|
f.sizes = sizes
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/key"
|
"github.com/charmbracelet/bubbles/v2/key"
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Current *LayoutInfo
|
var Current *LayoutInfo
|
||||||
@@ -45,6 +46,13 @@ type Sizeable interface {
|
|||||||
GetSize() (int, int)
|
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) {
|
func KeyMapToSlice(t any) (bindings []key.Binding) {
|
||||||
typ := reflect.TypeOf(t)
|
typ := reflect.TypeOf(t)
|
||||||
if typ.Kind() != reflect.Struct {
|
if typ.Kind() != reflect.Struct {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package layout
|
package layout
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
|
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||||
chAnsi "github.com/charmbracelet/x/ansi"
|
chAnsi "github.com/charmbracelet/x/ansi"
|
||||||
"github.com/muesli/ansi"
|
"github.com/muesli/ansi"
|
||||||
"github.com/muesli/reflow/truncate"
|
"github.com/muesli/reflow/truncate"
|
||||||
@@ -23,29 +27,58 @@ func getLines(s string) (lines []string, widest int) {
|
|||||||
return lines, widest
|
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.
|
// PlaceOverlay places fg on top of bg.
|
||||||
func PlaceOverlay(
|
func PlaceOverlay(
|
||||||
x, y int,
|
x, y int,
|
||||||
fg, bg string,
|
fg, bg string,
|
||||||
opts ...WhitespaceOption,
|
opts ...OverlayOption,
|
||||||
) string {
|
) string {
|
||||||
fgLines, fgWidth := getLines(fg)
|
fgLines, fgWidth := getLines(fg)
|
||||||
bgLines, bgWidth := getLines(bg)
|
bgLines, bgWidth := getLines(bg)
|
||||||
bgHeight := len(bgLines)
|
bgHeight := len(bgLines)
|
||||||
fgHeight := len(fgLines)
|
fgHeight := len(fgLines)
|
||||||
|
|
||||||
if fgWidth >= bgWidth && fgHeight >= bgHeight {
|
// Parse options
|
||||||
// FIXME: return fg or bg?
|
options := &overlayOptions{
|
||||||
return fg
|
whitespace: &whitespace{},
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: allow placement outside of the bg box?
|
// Adjust for borders if enabled
|
||||||
x = util.Clamp(x, 0, bgWidth-fgWidth)
|
if options.border {
|
||||||
y = util.Clamp(y, 0, bgHeight-fgHeight)
|
// 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{}
|
// Pad all foreground lines to the same width for consistent borders
|
||||||
for _, opt := range opts {
|
for i := range fgLines {
|
||||||
opt(ws)
|
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
|
var b strings.Builder
|
||||||
@@ -59,25 +92,62 @@ func PlaceOverlay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pos := 0
|
pos := 0
|
||||||
|
|
||||||
|
// Handle left side of the line up to the overlay
|
||||||
if x > 0 {
|
if x > 0 {
|
||||||
left := truncate.String(bgLine, uint(x))
|
left := truncate.String(bgLine, uint(x))
|
||||||
pos = ansi.PrintableRuneWidth(left)
|
pos = ansi.PrintableRuneWidth(left)
|
||||||
b.WriteString(left)
|
b.WriteString(left)
|
||||||
if pos < x {
|
if pos < x {
|
||||||
b.WriteString(ws.render(x - pos))
|
b.WriteString(options.whitespace.render(x - pos))
|
||||||
pos = x
|
pos = x
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fgLine := fgLines[i-y]
|
// Render the overlay content with optional borders
|
||||||
b.WriteString(fgLine)
|
if options.border {
|
||||||
pos += ansi.PrintableRuneWidth(fgLine)
|
// 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)
|
right := cutLeft(bgLine, pos)
|
||||||
bgWidth := ansi.PrintableRuneWidth(bgLine)
|
bgWidth := ansi.PrintableRuneWidth(bgLine)
|
||||||
rightWidth := ansi.PrintableRuneWidth(right)
|
rightWidth := ansi.PrintableRuneWidth(right)
|
||||||
if rightWidth <= bgWidth-pos {
|
if rightWidth <= bgWidth-pos {
|
||||||
b.WriteString(ws.render(bgWidth - rightWidth - pos))
|
b.WriteString(options.whitespace.render(bgWidth - rightWidth - pos))
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(right)
|
b.WriteString(right)
|
||||||
@@ -92,6 +162,146 @@ func cutLeft(s string, cutWidth int) string {
|
|||||||
return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
|
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 {
|
type whitespace struct {
|
||||||
style termenv.Style
|
style termenv.Style
|
||||||
chars string
|
chars string
|
||||||
@@ -129,3 +339,26 @@ func (w whitespace) render(width int) string {
|
|||||||
|
|
||||||
// WhitespaceOption sets a styling rule for rendering whitespace.
|
// WhitespaceOption sets a styling rule for rendering whitespace.
|
||||||
type WhitespaceOption func(*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,187 +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
|
|
||||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
|
||||||
if keyMsg.String() == "enter" {
|
|
||||||
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() {
|
func init() {
|
||||||
Terminal = &TerminalInfo{
|
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 = "🖼"
|
|
||||||
)
|
|
||||||
@@ -15,7 +15,7 @@ func BaseStyle() lipgloss.Style {
|
|||||||
func Panel() lipgloss.Style {
|
func Panel() lipgloss.Style {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
return lipgloss.NewStyle().
|
return lipgloss.NewStyle().
|
||||||
Background(t.BackgroundSubtle()).
|
Background(t.BackgroundPanel()).
|
||||||
Border(lipgloss.NormalBorder(), true, false, true, false).
|
Border(lipgloss.NormalBorder(), true, false, true, false).
|
||||||
BorderForeground(t.BorderSubtle()).
|
BorderForeground(t.BorderSubtle()).
|
||||||
Foreground(t.Text())
|
Foreground(t.Text())
|
||||||
@@ -131,9 +131,9 @@ func BackgroundColor() compat.AdaptiveColor {
|
|||||||
return theme.CurrentTheme().Background()
|
return theme.CurrentTheme().Background()
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackgroundSubtleColor returns the subtle background color from the current theme
|
// BackgroundPanelColor returns the subtle background color from the current theme
|
||||||
func BackgroundSubtleColor() compat.AdaptiveColor {
|
func BackgroundPanelColor() compat.AdaptiveColor {
|
||||||
return theme.CurrentTheme().BackgroundSubtle()
|
return theme.CurrentTheme().BackgroundPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackgroundElementColor returns the darker background color from the current theme
|
// BackgroundElementColor returns the darker background color from the current theme
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func NewAyuTheme() *AyuTheme {
|
|||||||
Dark: lipgloss.Color(darkBg),
|
Dark: lipgloss.Color(darkBg),
|
||||||
Light: lipgloss.Color(darkBg),
|
Light: lipgloss.Color(darkBg),
|
||||||
}
|
}
|
||||||
theme.BackgroundSubtleColor = compat.AdaptiveColor{
|
theme.BackgroundPanelColor = compat.AdaptiveColor{
|
||||||
Dark: lipgloss.Color(darkBgAlt),
|
Dark: lipgloss.Color(darkBgAlt),
|
||||||
Light: lipgloss.Color(darkBgAlt),
|
Light: lipgloss.Color(darkBgAlt),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.BackgroundPanelColor = 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())
|
||||||
|
}
|
||||||
@@ -146,7 +146,7 @@ func LoadCustomTheme(customTheme map[string]any) (Theme, error) {
|
|||||||
case "background":
|
case "background":
|
||||||
theme.BackgroundColor = adaptiveColor
|
theme.BackgroundColor = adaptiveColor
|
||||||
case "backgroundsubtle":
|
case "backgroundsubtle":
|
||||||
theme.BackgroundSubtleColor = adaptiveColor
|
theme.BackgroundPanelColor = adaptiveColor
|
||||||
case "backgroundelement":
|
case "backgroundelement":
|
||||||
theme.BackgroundElementColor = adaptiveColor
|
theme.BackgroundElementColor = adaptiveColor
|
||||||
case "border":
|
case "border":
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func NewOpenCodeTheme() *OpenCodeTheme {
|
|||||||
Dark: lipgloss.Color(darkStep1),
|
Dark: lipgloss.Color(darkStep1),
|
||||||
Light: lipgloss.Color(lightStep1),
|
Light: lipgloss.Color(lightStep1),
|
||||||
}
|
}
|
||||||
theme.BackgroundSubtleColor = compat.AdaptiveColor{
|
theme.BackgroundPanelColor = compat.AdaptiveColor{
|
||||||
Dark: lipgloss.Color(darkStep2),
|
Dark: lipgloss.Color(darkStep2),
|
||||||
Light: lipgloss.Color(lightStep2),
|
Light: lipgloss.Color(lightStep2),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
type Theme interface {
|
type Theme interface {
|
||||||
// Background colors
|
// Background colors
|
||||||
Background() compat.AdaptiveColor // Radix 1
|
Background() compat.AdaptiveColor // Radix 1
|
||||||
BackgroundSubtle() compat.AdaptiveColor // Radix 2
|
BackgroundPanel() compat.AdaptiveColor // Radix 2
|
||||||
BackgroundElement() compat.AdaptiveColor // Radix 3
|
BackgroundElement() compat.AdaptiveColor // Radix 3
|
||||||
|
|
||||||
// Border colors
|
// Border colors
|
||||||
@@ -84,7 +84,7 @@ type Theme interface {
|
|||||||
type BaseTheme struct {
|
type BaseTheme struct {
|
||||||
// Background colors
|
// Background colors
|
||||||
BackgroundColor compat.AdaptiveColor
|
BackgroundColor compat.AdaptiveColor
|
||||||
BackgroundSubtleColor compat.AdaptiveColor
|
BackgroundPanelColor compat.AdaptiveColor
|
||||||
BackgroundElementColor compat.AdaptiveColor
|
BackgroundElementColor compat.AdaptiveColor
|
||||||
|
|
||||||
// Border colors
|
// Border colors
|
||||||
@@ -163,7 +163,7 @@ func (t *BaseTheme) Text() compat.AdaptiveColor { return t.TextColor }
|
|||||||
func (t *BaseTheme) TextMuted() compat.AdaptiveColor { return t.TextMutedColor }
|
func (t *BaseTheme) TextMuted() compat.AdaptiveColor { return t.TextMutedColor }
|
||||||
|
|
||||||
func (t *BaseTheme) Background() compat.AdaptiveColor { return t.BackgroundColor }
|
func (t *BaseTheme) Background() compat.AdaptiveColor { return t.BackgroundColor }
|
||||||
func (t *BaseTheme) BackgroundSubtle() compat.AdaptiveColor { return t.BackgroundSubtleColor }
|
func (t *BaseTheme) BackgroundPanel() compat.AdaptiveColor { return t.BackgroundPanelColor }
|
||||||
func (t *BaseTheme) BackgroundElement() compat.AdaptiveColor { return t.BackgroundElementColor }
|
func (t *BaseTheme) BackgroundElement() compat.AdaptiveColor { return t.BackgroundElementColor }
|
||||||
|
|
||||||
func (t *BaseTheme) Border() compat.AdaptiveColor { return t.BorderColor }
|
func (t *BaseTheme) Border() compat.AdaptiveColor { return t.BorderColor }
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func NewTokyoNightTheme() *TokyoNightTheme {
|
|||||||
Dark: lipgloss.Color(darkStep1),
|
Dark: lipgloss.Color(darkStep1),
|
||||||
Light: lipgloss.Color(lightStep1),
|
Light: lipgloss.Color(lightStep1),
|
||||||
}
|
}
|
||||||
theme.BackgroundSubtleColor = compat.AdaptiveColor{
|
theme.BackgroundPanelColor = compat.AdaptiveColor{
|
||||||
Dark: lipgloss.Color(darkStep2),
|
Dark: lipgloss.Color(darkStep2),
|
||||||
Light: lipgloss.Color(lightStep2),
|
Light: lipgloss.Color(lightStep2),
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user