mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-17 09:34:21 +00:00
Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88211d8c5b | ||
|
|
a812f95b9d | ||
|
|
3728a12bee | ||
|
|
af07e51213 | ||
|
|
3113788c92 | ||
|
|
efb5fe6d4e | ||
|
|
54dd6c644d | ||
|
|
39ad8f2667 | ||
|
|
c4a2c84e53 | ||
|
|
44fe012812 | ||
|
|
f5e7f079ea | ||
|
|
15a8936806 | ||
|
|
4e4cff49c0 | ||
|
|
5540503bee | ||
|
|
193718034b | ||
|
|
72108c0296 | ||
|
|
ec1c9f8cd1 | ||
|
|
a85b0a370e | ||
|
|
e7784d2864 | ||
|
|
97c4815444 | ||
|
|
7d1a1663c8 | ||
|
|
24c0ce6e53 | ||
|
|
4cdc86612c | ||
|
|
f1f3f8d12c | ||
|
|
e78d3b54bf | ||
|
|
f8a7cd372d | ||
|
|
f48eac638d | ||
|
|
e1f12f93eb | ||
|
|
7ca8334a8b | ||
|
|
f1a2b2eba4 | ||
|
|
4b132656df | ||
|
|
26bab00dab | ||
|
|
568c04753e | ||
|
|
4a06e164d2 | ||
|
|
c57b52c300 | ||
|
|
0b8f48f17f | ||
|
|
3862184ccb | ||
|
|
8619c50976 | ||
|
|
bb6b56b72a | ||
|
|
1252b65166 | ||
|
|
6840276dad | ||
|
|
bd8c3cd0f1 | ||
|
|
e5e9b3e3c0 | ||
|
|
1e8a681de9 | ||
|
|
a834bedc17 | ||
|
|
6a3392385e | ||
|
|
6a00e063c4 | ||
|
|
73a0ce2b7d | ||
|
|
4d1afd01fa | ||
|
|
801d5f47bd | ||
|
|
b6caae9708 | ||
|
|
183ca64ef9 | ||
|
|
8c32cfe829 | ||
|
|
73dcc88da1 | ||
|
|
14bded65dc | ||
|
|
87d1d3fb62 | ||
|
|
e054454109 | ||
|
|
a6142cf975 | ||
|
|
69332e5fa3 | ||
|
|
20201ba3c4 | ||
|
|
658067186a | ||
|
|
ac777b77cf | ||
|
|
5944ae2023 | ||
|
|
2f10961ba8 | ||
|
|
fae97978a3 | ||
|
|
3423415e49 | ||
|
|
1d0bfc2b2a | ||
|
|
bd46cf0f86 | ||
|
|
d4157d9a96 | ||
|
|
6e4ef585d8 | ||
|
|
e05c3b7a76 | ||
|
|
f99904bc1c | ||
|
|
b796d6763f | ||
|
|
c1250abdf8 | ||
|
|
ebe51534a1 | ||
|
|
b8bbee4718 | ||
|
|
8f852b396f | ||
|
|
ae4d089c06 | ||
|
|
5110fbdaf9 | ||
|
|
e6ddb474fc | ||
|
|
0dc71774ce | ||
|
|
b470466e30 | ||
|
|
d1f9311931 | ||
|
|
1c58023df9 | ||
|
|
4e0aa58b7e | ||
|
|
23ee34b35f | ||
|
|
674c9a5220 | ||
|
|
54c86ed43a | ||
|
|
676d75ee75 | ||
|
|
70dc0a12f2 | ||
|
|
d579c5e8aa | ||
|
|
ee91f31313 | ||
|
|
57b3051024 | ||
|
|
ae5cf3cc23 | ||
|
|
68e1b3c46c | ||
|
|
2d68814abc | ||
|
|
a5da5127fa | ||
|
|
b5a4439704 | ||
|
|
9c5616521d | ||
|
|
3fe163416d | ||
|
|
d054f88130 | ||
|
|
b929b4f4b9 | ||
|
|
4c0c83b02d | ||
|
|
d6d45bdc63 | ||
|
|
13a83721b0 | ||
|
|
f0edffbae9 | ||
|
|
8131bee49a | ||
|
|
b5f44ae13f | ||
|
|
0d23f2a7fd | ||
|
|
ac096d84ad | ||
|
|
fcaf0e6dbf | ||
|
|
19e259d90d | ||
|
|
2c9fd1e776 | ||
|
|
63996c4189 | ||
|
|
c7bb7ce4de | ||
|
|
c8eb1b24c3 | ||
|
|
b9f894f1e9 | ||
|
|
7c0d10a4ce | ||
|
|
06af406146 | ||
|
|
0e3458b112 | ||
|
|
2d15c683e0 | ||
|
|
3c94d26570 | ||
|
|
1a553e525f | ||
|
|
3c4e966216 | ||
|
|
0721620ed8 | ||
|
|
9fc6734f32 | ||
|
|
e1733a423d | ||
|
|
d42e3db7e0 | ||
|
|
cdb26f6d83 | ||
|
|
fe05edaa79 | ||
|
|
7d174767b0 | ||
|
|
c5eefd1752 | ||
|
|
77a6b3bdd6 | ||
|
|
7effff56c0 | ||
|
|
e30fba0d3c | ||
|
|
7fbb2ca9a6 | ||
|
|
230d0a1510 | ||
|
|
46ff2c0ae0 | ||
|
|
b8a89dab0f | ||
|
|
7351e12886 | ||
|
|
38879dee2d | ||
|
|
c4ff8dd205 | ||
|
|
0e035b3115 |
5
.github/workflows/deploy.yml
vendored
5
.github/workflows/deploy.yml
vendored
@@ -3,7 +3,8 @@ name: deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dontlook
|
||||
- dev
|
||||
- production
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -20,6 +21,6 @@ jobs:
|
||||
|
||||
- run: bun install
|
||||
|
||||
- run: bun sst deploy --stage=dev
|
||||
- run: bun sst deploy --stage=${{ github.ref_name }}
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@ node_modules
|
||||
.opencode
|
||||
.sst
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
149
README.md
149
README.md
@@ -1,8 +1,23 @@
|
||||
[](https://github.com/sst/opencode)
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai">
|
||||
<picture>
|
||||
<source srcset="packages/web/src/assets/logo-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="packages/web/src/assets/logo-light.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="packages/web/src/assets/logo-light.svg" alt="opencode logo">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/docs"><img alt="View docs" src="https://img.shields.io/badge/view-docs-blue?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
AI coding agent, built for the terminal.
|
||||
|
||||
⚠️ **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
|
||||
|
||||
@@ -16,119 +31,40 @@ brew install sst/tap/opencode # macOS
|
||||
paru -S opencode-bin # Arch Linux
|
||||
```
|
||||
|
||||
> **Note:** Remove previous versions < 0.1.x first if installed
|
||||
> **Note:** Remove versions older than 0.1.x before installing
|
||||
|
||||
### Providers
|
||||
### Documentation
|
||||
|
||||
The recommended approach is to sign up for claude pro or max and do `opencode auth login` and select Anthropic. It is the most cost effective way to use this tool.
|
||||
|
||||
Additionally opencode is powered by the provider list at [models.dev](https://models.dev) so you can use `opencode auth login` to configure api keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`
|
||||
|
||||
```bash
|
||||
$ opencode auth login
|
||||
|
||||
┌ Add credential
|
||||
│
|
||||
◆ Select provider
|
||||
│ ● Anthropic (recommended)
|
||||
│ ○ OpenAI
|
||||
│ ○ Google
|
||||
│ ○ Amazon Bedrock
|
||||
│ ○ Azure
|
||||
│ ○ DeepSeek
|
||||
│ ○ Groq
|
||||
│ ...
|
||||
└
|
||||
```
|
||||
|
||||
The models.dev dataset is also used to detect common environment variables like `OPENAI_API_KEY` to autoload that provider.
|
||||
|
||||
If there are additional providers you want to use you can submit a PR to the [models.dev repo](https://github.com/sst/models.dev). If configuring just for yourself check out the Config section below
|
||||
|
||||
### Project Config
|
||||
|
||||
Project configuration is optional. You can place an `opencode.json` file in the root of your repo and it will be loaded.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "http://opencode.ai/config.json"
|
||||
}
|
||||
```
|
||||
|
||||
#### MCP
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "http://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"localmcp": {
|
||||
"type": "local",
|
||||
"command": ["bun", "x", "my-mcp-command"],
|
||||
"environment": {
|
||||
"MY_ENV_VAR": "my_env_var_value"
|
||||
}
|
||||
},
|
||||
"remotemcp": {
|
||||
"type": "remote",
|
||||
"url": "https://my-mcp-server.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Providers
|
||||
|
||||
You can use opencode with any provider listed at [here](https://ai-sdk.dev/providers/ai-sdk-providers). Use the npm package name as the key in your config. Note we use v5 of the ai-sdk and not all providers support that yet.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"@ai-sdk/openai-compatible": {
|
||||
"name": "ollama",
|
||||
"options": {
|
||||
"baseURL": "http://localhost:11434/v1"
|
||||
},
|
||||
"models": {
|
||||
"llama2": {
|
||||
"name": "llama2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
For more info on how to configure opencode [**head over to our docs**](https://opencode.ai/docs).
|
||||
|
||||
### Contributing
|
||||
|
||||
To run opencode locally you need
|
||||
To run opencode locally you need.
|
||||
|
||||
- bun
|
||||
- golang 1.24.x
|
||||
- Bun
|
||||
- Golang 1.24.x
|
||||
|
||||
To run
|
||||
And run.
|
||||
|
||||
```
|
||||
```bash
|
||||
$ bun install
|
||||
$ cd packages/opencode
|
||||
$ bun run src/index.ts
|
||||
$ bun run packages/opencode/src/index.ts
|
||||
```
|
||||
|
||||
### FAQ
|
||||
|
||||
#### How do I use this with OpenRouter
|
||||
#### How do I use this with OpenRouter?
|
||||
|
||||
OpenRouter is not yet in the models.dev database but you can configure it manually.
|
||||
OpenRouter is not in the Models.dev database yet, but you can configure it manually.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"@openrouter/ai-sdk-provider": {
|
||||
"openrouter": {
|
||||
"npm": "@openrouter/ai-sdk-provider",
|
||||
"name": "OpenRouter",
|
||||
"options": {
|
||||
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
},
|
||||
"options": {},
|
||||
"models": {
|
||||
"anthropic/claude-3.5-sonnet": {
|
||||
"name": "Claude 3.5 Sonnet"
|
||||
@@ -138,3 +74,26 @@ OpenRouter is not yet in the models.dev database but you can configure it manual
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And then to configure an api key you can do `opencode auth login` and select "Other -> 'openrouter'"
|
||||
|
||||
#### How is this different than Claude Code?
|
||||
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although Anthropic is recommended, opencode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider agnostic is important.
|
||||
- A focus on TUI. opencode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
||||
- A client/server architecture. This for example can allow opencode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
|
||||
|
||||
#### What about Windows support?
|
||||
|
||||
There are some minor problems blocking opencode from working on windows. We are working on on them now. You'll need to use WSL for now.
|
||||
|
||||
#### What's the other repo?
|
||||
|
||||
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466).
|
||||
|
||||
---
|
||||
|
||||
**Join our community** [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
|
||||
|
||||
31
bun.lock
31
bun.lock
@@ -5,7 +5,7 @@
|
||||
"name": "opencode",
|
||||
"devDependencies": {
|
||||
"prettier": "3.5.3",
|
||||
"sst": "3.17.4",
|
||||
"sst": "3.17.6",
|
||||
},
|
||||
},
|
||||
"packages/function": {
|
||||
@@ -45,6 +45,7 @@
|
||||
"zod-openapi": "4.2.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/anthropic": "1.2.12",
|
||||
"@tsconfig/bun": "1.0.7",
|
||||
"@types/bun": "latest",
|
||||
"@types/turndown": "5.0.5",
|
||||
@@ -74,10 +75,11 @@
|
||||
"sharp": "0.32.5",
|
||||
"shiki": "3.4.2",
|
||||
"solid-js": "1.9.7",
|
||||
"toolbeam-docs-theme": "0.2.4",
|
||||
"toolbeam-docs-theme": "0.3.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"opencode": "workspace:*",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -86,6 +88,9 @@
|
||||
"sharp",
|
||||
"esbuild",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"ai@4.3.16": "patches/ai@4.3.16.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"zod": "3.24.2",
|
||||
},
|
||||
@@ -96,6 +101,8 @@
|
||||
"zod": "3.24.2",
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
|
||||
@@ -1418,23 +1425,23 @@
|
||||
|
||||
"split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="],
|
||||
|
||||
"sst": ["sst@3.17.4", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.4", "sst-darwin-x64": "3.17.4", "sst-linux-arm64": "3.17.4", "sst-linux-x64": "3.17.4", "sst-linux-x86": "3.17.4", "sst-win32-arm64": "3.17.4", "sst-win32-x64": "3.17.4", "sst-win32-x86": "3.17.4" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-WpAws1ASJIilKC9/DGBhZ5wk2I4gtlzHXKpuwPC25bHWjqllv1jZiehIYhhN0PpV2pV8xCvqzyN8Gdm3J4EWQg=="],
|
||||
"sst": ["sst@3.17.6", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.6", "sst-darwin-x64": "3.17.6", "sst-linux-arm64": "3.17.6", "sst-linux-x64": "3.17.6", "sst-linux-x86": "3.17.6", "sst-win32-arm64": "3.17.6", "sst-win32-x64": "3.17.6", "sst-win32-x86": "3.17.6" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-p+AcqwfYQUdkxeRjCikQoTMviPCBiGoU7M0vcV6GDVmVis8hzhVw4EFfHTafZC+aWfy1Ke2UQi66vZlEVWuEqA=="],
|
||||
|
||||
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IJansQWlPdiaQNsJw3FQ+Q/ZXN1hzrq2Q31xG4l2HhA1doj1C3y+6s57vu4cTRDFo2OwBlC4+zlQBJHsOYGhrA=="],
|
||||
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6tb7KlcPR7PTi3ofQv8dX/n6Jf7pNP9VfrnYL4HBWnWrcYaZeJ5MWobILfIJ/y2jHgoqmg9e5C3266Eds0JQyw=="],
|
||||
|
||||
"sst-darwin-x64": ["sst-darwin-x64@3.17.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-mHd26/AtaQ79ajqzsutRhgEjkCxX+bXgW4KJIN0AGT3110fo2OL0x2UXmfX+sxSWOFHvJQsjFjFm4CLtQSxyBg=="],
|
||||
"sst-darwin-x64": ["sst-darwin-x64@3.17.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-lFakq6/EgTuBSjbl8Kry4pfgAPEIyn6o7ZkyRz3hz5331wUaX88yfjs3tL9JQ8Ey6jBUYxwhP/Q1n7fzIG046g=="],
|
||||
|
||||
"sst-linux-arm64": ["sst-linux-arm64@3.17.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-unaNWOY3oEI/jUUG47/2Gbreaoi/D/rLsTPeKyYEWhWEBWCojns7LfMQs1bgW0qjBGmazB2IJD4NVYhYqYQxqQ=="],
|
||||
"sst-linux-arm64": ["sst-linux-arm64@3.17.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-SdTxXMbTEdiwOqp37w31kXv97vHqSx3oK9h/76lKg7V9k5JxPJ6JMefPLhoKWwK0Zh6AndY2zo2oRoEv4SIaDw=="],
|
||||
|
||||
"sst-linux-x64": ["sst-linux-x64@3.17.4", "", { "os": "linux", "cpu": "x64" }, "sha512-zoErI6dVoRxWcmoVVrzNJWKEqfUF/MyQInEkGROGY2YsFFzOM5RD5Dsdm9q6oDGwx+NxFAhQWc8/8C+OmoW1nA=="],
|
||||
"sst-linux-x64": ["sst-linux-x64@3.17.6", "", { "os": "linux", "cpu": "x64" }, "sha512-qneh7uWDiTUYx8X1Y3h2YVw3SJ0ybBBlRrVybIvCM09JqQ8+qq/XjKXGzA/3/EF0Jr7Ug8cARSn9CwxhdQGN7Q=="],
|
||||
|
||||
"sst-linux-x86": ["sst-linux-x86@3.17.4", "", { "os": "linux", "cpu": "none" }, "sha512-7ZHS2rxzxVAxMFW3u5+GMRGGACaBMuLht8JYxqruD8mFVqk9UaPQgrFKIHGKWHLBJLVnF2AdwmlHOcEKP+UJWA=="],
|
||||
"sst-linux-x86": ["sst-linux-x86@3.17.6", "", { "os": "linux", "cpu": "none" }, "sha512-pU3D5OeqnmfxGqN31DxuwWnc1OayxhkErnITHhZ39D0MTiwbIgCapH26FuLW8B08/uxJWG8djUlOboCRhSBvWA=="],
|
||||
|
||||
"sst-win32-arm64": ["sst-win32-arm64@3.17.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-q4cedr6WD3NqeQkDvmAsIgMgPIjziIWy81wA3ZmnY6UT0jFgFus23ppLIi6F4BFJfOygvAP2PeGrRR3o8giclw=="],
|
||||
"sst-win32-arm64": ["sst-win32-arm64@3.17.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rr3RTYWAsH9sM9CbM/sAZCk7dB1OsSAljjJuuHMvdSAYW3RDpXEza0PBJGxnBID2eOrpswEchzMPL2d8LtL7oA=="],
|
||||
|
||||
"sst-win32-x64": ["sst-win32-x64@3.17.4", "", { "os": "win32", "cpu": "x64" }, "sha512-sSQL041YCusZ8/0ynYGe9DCmPYVZOFsemXKUA9tX4IGSDqXae1FN0Sj7HQ17JyY24UUirY1zR7LFk+7KrP6wiA=="],
|
||||
"sst-win32-x64": ["sst-win32-x64@3.17.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yZ3roxwI0Wve9PFzdrrF1kfzCmIMFCCoa8qKeXY7LxCJ4QQIqHbCOccLK1Wv/MIU/mcZHWXTQVCLHw77uaa0GQ=="],
|
||||
|
||||
"sst-win32-x86": ["sst-win32-x86@3.17.4", "", { "os": "win32", "cpu": "none" }, "sha512-WhjsD2dkA2fbQ03CgwIJb+2p0osll2PTXlr7HC3L+H8wG2DgLFPjoE+6N8n6r2dVMVaDzuNwy/7J8hRB29blaw=="],
|
||||
"sst-win32-x86": ["sst-win32-x86@3.17.6", "", { "os": "win32", "cpu": "none" }, "sha512-zV7TJWPJN9PmIXr15iXFSs0tbGsa52oBR3+xiKrUj2qj9XsZe7HBFwskRnHyiFq0durZY9kk9ZtoVlpuUuzr1g=="],
|
||||
|
||||
"stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="],
|
||||
|
||||
@@ -1488,7 +1495,7 @@
|
||||
|
||||
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
|
||||
|
||||
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.2.4", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-W5mdbcgRpTBDFyEdcU81USs3MFZoXMInpSznc/AFZCwqz8atk4iBNDIlhvihpGHY54Nf5crKmZwJjxVojkHFvA=="],
|
||||
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.3.0", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-qlBkKRp8HVYV7p7jaG9lT2lvQY7c8b9czZ0tnsJUrN2TBTtEyFJymCdkhhpZNC9U4oGZ7lLk0glRJHrndWvVsg=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
|
||||
17
infra/app.ts
17
infra/app.ts
@@ -23,25 +23,14 @@ export const api = new sst.cloudflare.Worker("Api", {
|
||||
},
|
||||
])
|
||||
args.migrations = {
|
||||
oldTag: "v1",
|
||||
newTag: "v1",
|
||||
//newSqliteClasses: ["SyncServer"],
|
||||
// oldTag: "v1",
|
||||
// newTag: "v1",
|
||||
newSqliteClasses: ["SyncServer"],
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// new sst.cloudflare.StaticSite("Web", {
|
||||
// path: "packages/web",
|
||||
// domain,
|
||||
// environment: {
|
||||
// VITE_API_URL: api.url,
|
||||
// },
|
||||
// build: {
|
||||
// command: "bun run build",
|
||||
// output: "dist",
|
||||
// },
|
||||
// })
|
||||
new sst.cloudflare.x.Astro("Web", {
|
||||
domain,
|
||||
path: "packages/web",
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"@ai-sdk/openai-compatible": {
|
||||
"name": "ollama",
|
||||
"options": {
|
||||
"baseURL": "http://localhost:11434/v1"
|
||||
},
|
||||
"models": {
|
||||
"qwen3": {},
|
||||
"deepseek-r1": {},
|
||||
"llama2": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"keybinds": {},
|
||||
"mcp": {}
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.2.14",
|
||||
"scripts": {
|
||||
"typecheck": "bun run --filter='*' typecheck"
|
||||
"typecheck": "bun run --filter='*' typecheck",
|
||||
"postinstall": "./scripts/hooks"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
@@ -20,7 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "3.5.3",
|
||||
"sst": "3.17.4"
|
||||
"sst": "3.17.6"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -37,5 +38,8 @@
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
"sharp"
|
||||
]
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"ai@4.3.16": "patches/ai@4.3.16.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ export class SyncServer extends DurableObject<Env> {
|
||||
this.ctx.acceptWebSocket(server)
|
||||
|
||||
const data = await this.ctx.storage.list()
|
||||
for (const [key, content] of data.entries()) {
|
||||
server.send(JSON.stringify({ key, content }))
|
||||
}
|
||||
Array.from(data.entries())
|
||||
.filter(([key, _]) => key.startsWith("session/"))
|
||||
.map(([key, content]) => server.send(JSON.stringify({ key, content })))
|
||||
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
@@ -71,11 +71,9 @@ export class SyncServer extends DurableObject<Env> {
|
||||
|
||||
public async getData() {
|
||||
const data = await this.ctx.storage.list()
|
||||
const messages = []
|
||||
for (const [key, content] of data.entries()) {
|
||||
messages.push({ key, content })
|
||||
}
|
||||
return messages
|
||||
return Array.from(data.entries())
|
||||
.filter(([key, _]) => key.startsWith("session/"))
|
||||
.map(([key, content]) => ({ key, content }))
|
||||
}
|
||||
|
||||
private async getSecret() {
|
||||
@@ -122,7 +120,7 @@ export default {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
secret,
|
||||
url: "https://dev.opencode.ai/s/" + short,
|
||||
url: "https://opencode.ai/s/" + short,
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# OpenCode Agent Guidelines
|
||||
# opencode agent guidelines
|
||||
|
||||
## Build/Test Commands
|
||||
|
||||
@@ -16,9 +16,19 @@
|
||||
- **Naming**: camelCase for variables/functions, PascalCase for classes/namespaces
|
||||
- **Error handling**: Use Result patterns, avoid throwing exceptions in tools
|
||||
- **File structure**: Namespace-based organization (e.g., `Tool.define()`, `Session.create()`)
|
||||
|
||||
## IMPORTANT
|
||||
|
||||
- Try to keep things in one function unless composable or reusable
|
||||
- DO NOT do unnecessary destructuring of variables
|
||||
- DO NOT use else statements unless necessary
|
||||
- DO NOT use try catch if it can be avoided
|
||||
- DO NOT use `else` statements unless necessary
|
||||
- DO NOT use `try`/`catch` if it can be avoided
|
||||
- AVOID `try`/`catch` where possible
|
||||
- AVOID `else` statements
|
||||
- AVOID using `any` type
|
||||
- AVOID `let` statements
|
||||
- PREFER single word variable names where possible
|
||||
- Use as many bun apis as possible like Bun.file()
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -27,4 +37,3 @@
|
||||
- **Validation**: All inputs validated with Zod schemas
|
||||
- **Logging**: Use `Log.create({ service: "name" })` pattern
|
||||
- **Storage**: Use `Storage` namespace for persistence
|
||||
|
||||
|
||||
@@ -2,13 +2,155 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "JSON schema reference for configuration validation"
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"description": "Theme name to use for the interface"
|
||||
},
|
||||
"keybinds": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"leader": {
|
||||
"type": "string",
|
||||
"description": "Leader key for keybind combinations"
|
||||
},
|
||||
"help": {
|
||||
"type": "string",
|
||||
"description": "Show help dialog"
|
||||
},
|
||||
"editor_open": {
|
||||
"type": "string",
|
||||
"description": "Open external editor"
|
||||
},
|
||||
"session_new": {
|
||||
"type": "string",
|
||||
"description": "Create a new session"
|
||||
},
|
||||
"session_list": {
|
||||
"type": "string",
|
||||
"description": "List all sessions"
|
||||
},
|
||||
"session_share": {
|
||||
"type": "string",
|
||||
"description": "Share current session"
|
||||
},
|
||||
"session_interrupt": {
|
||||
"type": "string",
|
||||
"description": "Interrupt current session"
|
||||
},
|
||||
"session_compact": {
|
||||
"type": "string",
|
||||
"description": "Toggle compact mode for session"
|
||||
},
|
||||
"tool_details": {
|
||||
"type": "string",
|
||||
"description": "Show tool details"
|
||||
},
|
||||
"model_list": {
|
||||
"type": "string",
|
||||
"description": "List available models"
|
||||
},
|
||||
"theme_list": {
|
||||
"type": "string",
|
||||
"description": "List available themes"
|
||||
},
|
||||
"project_init": {
|
||||
"type": "string",
|
||||
"description": "Initialize project configuration"
|
||||
},
|
||||
"input_clear": {
|
||||
"type": "string",
|
||||
"description": "Clear input field"
|
||||
},
|
||||
"input_paste": {
|
||||
"type": "string",
|
||||
"description": "Paste from clipboard"
|
||||
},
|
||||
"input_submit": {
|
||||
"type": "string",
|
||||
"description": "Submit input"
|
||||
},
|
||||
"input_newline": {
|
||||
"type": "string",
|
||||
"description": "Insert newline in input"
|
||||
},
|
||||
"history_previous": {
|
||||
"type": "string",
|
||||
"description": "Navigate to previous history item"
|
||||
},
|
||||
"history_next": {
|
||||
"type": "string",
|
||||
"description": "Navigate to next history item"
|
||||
},
|
||||
"messages_page_up": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages up by one page"
|
||||
},
|
||||
"messages_page_down": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages down by one page"
|
||||
},
|
||||
"messages_half_page_up": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages up by half page"
|
||||
},
|
||||
"messages_half_page_down": {
|
||||
"type": "string",
|
||||
"description": "Scroll messages down by half page"
|
||||
},
|
||||
"messages_previous": {
|
||||
"type": "string",
|
||||
"description": "Navigate to previous message"
|
||||
},
|
||||
"messages_next": {
|
||||
"type": "string",
|
||||
"description": "Navigate to next message"
|
||||
},
|
||||
"messages_first": {
|
||||
"type": "string",
|
||||
"description": "Navigate to first message"
|
||||
},
|
||||
"messages_last": {
|
||||
"type": "string",
|
||||
"description": "Navigate to last message"
|
||||
},
|
||||
"app_exit": {
|
||||
"type": "string",
|
||||
"description": "Exit the application"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Custom keybind configurations"
|
||||
},
|
||||
"autoshare": {
|
||||
"type": "boolean",
|
||||
"description": "Share newly created sessions automatically"
|
||||
},
|
||||
"autoupdate": {
|
||||
"type": "boolean",
|
||||
"description": "Automatically update to the latest version"
|
||||
},
|
||||
"disabled_providers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Disable providers that are loaded automatically"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model to use in the format of provider/model, eg anthropic/claude-2"
|
||||
},
|
||||
"provider": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -21,6 +163,9 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"npm": {
|
||||
"type": "string"
|
||||
},
|
||||
"models": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -47,18 +192,16 @@
|
||||
"output": {
|
||||
"type": "number"
|
||||
},
|
||||
"inputCached": {
|
||||
"cache_read": {
|
||||
"type": "number"
|
||||
},
|
||||
"outputCached": {
|
||||
"cache_write": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input",
|
||||
"output",
|
||||
"inputCached",
|
||||
"outputCached"
|
||||
"output"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -94,7 +237,8 @@
|
||||
"models"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"description": "Custom provider configurations and model overrides"
|
||||
},
|
||||
"mcp": {
|
||||
"type": "object",
|
||||
@@ -105,19 +249,22 @@
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "local"
|
||||
"const": "local",
|
||||
"description": "Type of MCP server connection"
|
||||
},
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Command and arguments to run the MCP server"
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Environment variables to set when running the MCP server"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -131,10 +278,12 @@
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "remote"
|
||||
"const": "remote",
|
||||
"description": "Type of MCP server connection"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "URL of the remote MCP server"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -144,7 +293,8 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "MCP (Model Context Protocol) server configurations"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
"dev": "bun run ./src/index.ts"
|
||||
},
|
||||
"exports": {
|
||||
"./*": [
|
||||
"./src/*.ts",
|
||||
"./src/*/index.ts"
|
||||
]
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "1.0.7",
|
||||
@@ -20,7 +17,8 @@
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"typescript": "catalog:",
|
||||
"zod-to-json-schema": "3.24.5"
|
||||
"zod-to-json-schema": "3.24.5",
|
||||
"@ai-sdk/anthropic": "1.2.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "0.11.0",
|
||||
|
||||
@@ -80,9 +80,9 @@ function main() {
|
||||
|
||||
// Create symlink to the actual binary
|
||||
fs.symlinkSync(binaryPath, binScript)
|
||||
console.log(`OpenCode binary symlinked: ${binScript} -> ${binaryPath}`)
|
||||
console.log(`opencode binary symlinked: ${binScript} -> ${binaryPath}`)
|
||||
} catch (error) {
|
||||
console.error("Failed to create OpenCode binary symlink:", error.message)
|
||||
console.error("Failed to create opencode binary symlink:", error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ const targets = [
|
||||
["linux", "x64"],
|
||||
["darwin", "x64"],
|
||||
["darwin", "arm64"],
|
||||
["windows", "x64"],
|
||||
// ["windows", "x64"],
|
||||
]
|
||||
|
||||
await $`rm -rf dist`
|
||||
@@ -64,7 +64,7 @@ for (const [os, arch] of targets) {
|
||||
|
||||
await $`mkdir -p ./dist/${pkg.name}`
|
||||
await $`cp -r ./bin ./dist/${pkg.name}/bin`
|
||||
await $`cp ./script/postinstall.js ./dist/${pkg.name}/postinstall.js`
|
||||
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
|
||||
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
@@ -73,7 +73,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
[pkg.name]: `./bin/${pkg.name}`,
|
||||
},
|
||||
scripts: {
|
||||
postinstall: "node ./postinstall.js",
|
||||
postinstall: "node ./postinstall.mjs",
|
||||
},
|
||||
version,
|
||||
optionalDependencies,
|
||||
|
||||
@@ -18,6 +18,7 @@ export namespace App {
|
||||
data: z.string(),
|
||||
root: z.string(),
|
||||
cwd: z.string(),
|
||||
state: z.string(),
|
||||
}),
|
||||
time: z.object({
|
||||
initialized: z.number().optional(),
|
||||
@@ -32,7 +33,7 @@ export namespace App {
|
||||
|
||||
const APP_JSON = "app.json"
|
||||
|
||||
async function create(input: { cwd: string; version: string }) {
|
||||
async function create(input: { cwd: string }) {
|
||||
log.info("creating", {
|
||||
cwd: input.cwd,
|
||||
})
|
||||
@@ -49,9 +50,7 @@ export namespace App {
|
||||
const stateFile = Bun.file(path.join(data, APP_JSON))
|
||||
const state = (await stateFile.json().catch(() => ({}))) as {
|
||||
initialized: number
|
||||
version: string
|
||||
}
|
||||
state.version = input.version
|
||||
await stateFile.write(JSON.stringify(state))
|
||||
|
||||
const services = new Map<
|
||||
@@ -70,13 +69,13 @@ export namespace App {
|
||||
git: git !== undefined,
|
||||
path: {
|
||||
config: Global.Path.config,
|
||||
state: Global.Path.state,
|
||||
data,
|
||||
root: git ?? input.cwd,
|
||||
cwd: input.cwd,
|
||||
},
|
||||
}
|
||||
const result = {
|
||||
version: input.version,
|
||||
services,
|
||||
info,
|
||||
}
|
||||
@@ -108,7 +107,7 @@ export namespace App {
|
||||
}
|
||||
|
||||
export async function provide<T>(
|
||||
input: { cwd: string; version: string },
|
||||
input: { cwd: string },
|
||||
cb: (app: Info) => Promise<T>,
|
||||
) {
|
||||
const app = await create(input)
|
||||
@@ -124,12 +123,11 @@ export namespace App {
|
||||
}
|
||||
|
||||
export async function initialize() {
|
||||
const { info, version } = ctx.use()
|
||||
const { info } = ctx.use()
|
||||
info.time.initialized = Date.now()
|
||||
await Bun.write(
|
||||
path.join(info.path.data, APP_JSON),
|
||||
JSON.stringify({
|
||||
version,
|
||||
initialized: Date.now(),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { generatePKCE } from "@openauthjs/openauth/pkce"
|
||||
import fs from "fs/promises"
|
||||
import { Auth } from "./index"
|
||||
|
||||
export namespace AuthAnthropic {
|
||||
@@ -49,6 +48,7 @@ export namespace AuthAnthropic {
|
||||
await Auth.set("anthropic", {
|
||||
type: "oauth",
|
||||
refresh: json.refresh_token as string,
|
||||
access: json.access_token as string,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
})
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export namespace AuthAnthropic {
|
||||
export async function access() {
|
||||
const info = await Auth.get("anthropic")
|
||||
if (!info || info.type !== "oauth") return
|
||||
if (info.access && info.expires > Date.now()) return info.access
|
||||
const response = await fetch(
|
||||
"https://console.anthropic.com/v1/oauth/token",
|
||||
{
|
||||
@@ -75,6 +76,7 @@ export namespace AuthAnthropic {
|
||||
await Auth.set("anthropic", {
|
||||
type: "oauth",
|
||||
refresh: json.refresh_token as string,
|
||||
access: json.access_token as string,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
})
|
||||
return json.access_token as string
|
||||
|
||||
@@ -7,6 +7,7 @@ export namespace Auth {
|
||||
export const Oauth = z.object({
|
||||
type: z.literal("oauth"),
|
||||
refresh: z.string(),
|
||||
access: z.string(),
|
||||
expires: z.number(),
|
||||
})
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export namespace BunProc {
|
||||
version: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export async function install(pkg: string, version = "latest") {
|
||||
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
||||
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
|
||||
|
||||
@@ -5,18 +5,18 @@ import * as prompts from "@clack/prompts"
|
||||
import open from "open"
|
||||
import { UI } from "../ui"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
import { map, pipe, sort, sortBy, values } from "remeda"
|
||||
import { map, pipe, sortBy, values } from "remeda"
|
||||
|
||||
export const AuthCommand = cmd({
|
||||
command: "auth",
|
||||
describe: "manage credentials",
|
||||
describe: "Manage credentials",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(AuthLoginCommand)
|
||||
.command(AuthLogoutCommand)
|
||||
.command(AuthListCommand)
|
||||
.demandCommand(),
|
||||
async handler(args) {},
|
||||
async handler() { },
|
||||
})
|
||||
|
||||
export const AuthListCommand = cmd({
|
||||
@@ -78,9 +78,16 @@ export const AuthLoginCommand = cmd({
|
||||
|
||||
if (provider === "other") {
|
||||
provider = await prompts.text({
|
||||
message: "Enter provider - must match @ai-sdk/<provider>",
|
||||
message: "Enter provider id",
|
||||
validate: (x) =>
|
||||
x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only",
|
||||
})
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
provider = provider.replace(/^@ai-sdk\//, "")
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
prompts.log.warn(
|
||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "amazon-bedrock") {
|
||||
@@ -115,7 +122,9 @@ export const AuthLoginCommand = cmd({
|
||||
try {
|
||||
await open(url)
|
||||
} catch (e) {
|
||||
prompts.log.error("Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:")
|
||||
prompts.log.error(
|
||||
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
|
||||
)
|
||||
}
|
||||
prompts.log.info(url)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Server } from "../../server/server"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import type { CommandModule } from "yargs"
|
||||
import { Config } from "../../config/config"
|
||||
|
||||
export const GenerateCommand = {
|
||||
command: "generate",
|
||||
|
||||
@@ -6,68 +6,102 @@ import { Session } from "../../session"
|
||||
import { Share } from "../../share/share"
|
||||
import { Message } from "../../session/message"
|
||||
import { UI } from "../ui"
|
||||
import { VERSION } from "../version"
|
||||
|
||||
const COLOR = [
|
||||
UI.Style.TEXT_SUCCESS_BOLD,
|
||||
UI.Style.TEXT_INFO_BOLD,
|
||||
UI.Style.TEXT_HIGHLIGHT_BOLD,
|
||||
UI.Style.TEXT_WARNING_BOLD,
|
||||
]
|
||||
import { cmd } from "./cmd"
|
||||
import { Flag } from "../../flag/flag"
|
||||
import { Config } from "../../config/config"
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
opencode_todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
opencode_todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
opencode_bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
||||
opencode_edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
||||
opencode_glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
||||
opencode_grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
|
||||
opencode_list: ["List", UI.Style.TEXT_INFO_BOLD],
|
||||
opencode_read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
|
||||
opencode_write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
||||
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
||||
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
||||
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
|
||||
list: ["List", UI.Style.TEXT_INFO_BOLD],
|
||||
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
|
||||
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
|
||||
websearch: ["Search", UI.Style.TEXT_DIM_BOLD],
|
||||
}
|
||||
|
||||
export const RunCommand = {
|
||||
export const RunCommand = cmd({
|
||||
command: "run [message..]",
|
||||
describe: "Run OpenCode with a message",
|
||||
describe: "Run opencode with a message",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
.positional("message", {
|
||||
describe: "Message to send",
|
||||
describe: "message to send",
|
||||
type: "string",
|
||||
array: true,
|
||||
default: [],
|
||||
})
|
||||
.option("continue", {
|
||||
alias: ["c"],
|
||||
describe: "Continue the last session",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("session", {
|
||||
alias: ["s"],
|
||||
describe: "Session ID to continue",
|
||||
type: "string",
|
||||
})
|
||||
.option("share", {
|
||||
type: "boolean",
|
||||
describe: "share the session",
|
||||
})
|
||||
.option("model", {
|
||||
type: "string",
|
||||
alias: ["m"],
|
||||
describe: "Model to use in the format of provider/model",
|
||||
})
|
||||
},
|
||||
handler: async (args: {
|
||||
message: string[]
|
||||
session?: string
|
||||
printLogs?: boolean
|
||||
}) => {
|
||||
handler: async (args) => {
|
||||
const message = args.message.join(" ")
|
||||
await App.provide(
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
version: VERSION,
|
||||
},
|
||||
async () => {
|
||||
await Share.init()
|
||||
const session = args.session
|
||||
? await Session.get(args.session)
|
||||
: await Session.create()
|
||||
const session = await (async () => {
|
||||
if (args.continue) {
|
||||
const first = await Session.list().next()
|
||||
if (first.done) return
|
||||
return first.value
|
||||
}
|
||||
|
||||
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
|
||||
if (args.session) return Session.get(args.session)
|
||||
|
||||
return Session.create()
|
||||
})()
|
||||
|
||||
if (!session) {
|
||||
UI.error("Session not found")
|
||||
return
|
||||
}
|
||||
|
||||
UI.empty()
|
||||
UI.println(UI.logo())
|
||||
UI.empty()
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
||||
UI.empty()
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD +
|
||||
"~ https://dev.opencode.ai/s/" +
|
||||
|
||||
const cfg = await Config.get()
|
||||
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
|
||||
await Session.share(session.id)
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD +
|
||||
"~ https://opencode.ai/s/" +
|
||||
session.id.slice(-8),
|
||||
)
|
||||
}
|
||||
UI.empty()
|
||||
|
||||
const { providerID, modelID } = args.model
|
||||
? Provider.parseModel(args.model)
|
||||
: await Provider.defaultModel()
|
||||
UI.println(
|
||||
UI.Style.TEXT_NORMAL_BOLD + "@ ",
|
||||
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
|
||||
)
|
||||
UI.empty()
|
||||
|
||||
@@ -75,8 +109,8 @@ export const RunCommand = {
|
||||
UI.println(
|
||||
color + `|`,
|
||||
UI.Style.TEXT_NORMAL +
|
||||
UI.Style.TEXT_DIM +
|
||||
` ${type.padEnd(7, " ")}`,
|
||||
UI.Style.TEXT_DIM +
|
||||
` ${type.padEnd(7, " ")}`,
|
||||
"",
|
||||
UI.Style.TEXT_NORMAL + title,
|
||||
)
|
||||
@@ -113,8 +147,6 @@ export const RunCommand = {
|
||||
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
||||
}
|
||||
})
|
||||
|
||||
const { providerID, modelID } = await Provider.defaultModel()
|
||||
await Session.chat({
|
||||
sessionID: session.id,
|
||||
providerID,
|
||||
@@ -130,4 +162,4 @@ export const RunCommand = {
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { App } from "../../app/app"
|
||||
import { LSP } from "../../lsp"
|
||||
import { VERSION } from "../version"
|
||||
import { cmd } from "./cmd"
|
||||
|
||||
export const ScrapCommand = cmd({
|
||||
@@ -8,9 +7,12 @@ export const ScrapCommand = cmd({
|
||||
builder: (yargs) =>
|
||||
yargs.positional("file", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await App.provide({ cwd: process.cwd(), version: VERSION }, async (app) => {
|
||||
await LSP.touchFile(args.file, true)
|
||||
console.log(await LSP.diagnostics())
|
||||
})
|
||||
await App.provide(
|
||||
{ cwd: process.cwd() },
|
||||
async () => {
|
||||
await LSP.touchFile(args.file, true)
|
||||
console.log(await LSP.diagnostics())
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
44
packages/opencode/src/cli/cmd/upgrade.ts
Normal file
44
packages/opencode/src/cli/cmd/upgrade.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { Installation } from "../../installation"
|
||||
|
||||
export const UpgradeCommand = {
|
||||
command: "upgrade [target]",
|
||||
describe: "upgrade opencode to the latest version or a specific version",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs.positional("target", {
|
||||
describe: "specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')",
|
||||
type: "string",
|
||||
})
|
||||
},
|
||||
handler: async (args: { target?: string }) => {
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
prompts.intro("Upgrade")
|
||||
const method = await Installation.method()
|
||||
if (method === "unknown") {
|
||||
prompts.log.error(
|
||||
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
|
||||
)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
const target = args.target ?? (await Installation.latest())
|
||||
prompts.log.info(`From ${Installation.VERSION} → ${target}`)
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Upgrading...")
|
||||
const err = await Installation.upgrade(method, target).catch((err) => err)
|
||||
if (err) {
|
||||
spinner.stop("Upgrade failed")
|
||||
if (err instanceof Installation.UpgradeFailedError)
|
||||
prompts.log.error(err.data.stderr)
|
||||
else if (err instanceof Error) prompts.log.error(err.message)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
spinner.stop("Upgrade complete")
|
||||
prompts.outro("Done")
|
||||
},
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { createCli, type TrpcCliMeta } from "trpc-cli"
|
||||
import { initTRPC } from "@trpc/server"
|
||||
import { z } from "zod"
|
||||
import { Server } from "../server/server"
|
||||
import { AuthAnthropic } from "../auth/anthropic"
|
||||
import { UI } from "./ui"
|
||||
import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { Session } from "../session"
|
||||
import { Share } from "../share/share"
|
||||
import { Message } from "../session/message"
|
||||
import { VERSION } from "./version"
|
||||
import { LSP } from "../lsp"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
const t = initTRPC.meta<TrpcCliMeta>().create()
|
||||
|
||||
export const router = t.router({
|
||||
generate: t.procedure
|
||||
.meta({
|
||||
description: "Generate OpenAPI and event specs",
|
||||
})
|
||||
.input(z.object({}))
|
||||
.mutation(async () => {
|
||||
const specs = await Server.openapi()
|
||||
const dir = "gen"
|
||||
await fs.rmdir(dir, { recursive: true }).catch(() => {})
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(dir, "openapi.json"),
|
||||
JSON.stringify(specs, null, 2),
|
||||
)
|
||||
return "Generated OpenAPI specs in gen/ directory"
|
||||
}),
|
||||
|
||||
run: t.procedure
|
||||
.meta({
|
||||
description: "Run OpenCode with a message",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
message: z.array(z.string()).default([]).describe("Message to send"),
|
||||
session: z.string().optional().describe("Session ID to continue"),
|
||||
}),
|
||||
)
|
||||
.mutation(
|
||||
async ({ input }: { input: { message: string[]; session?: string } }) => {
|
||||
const message = input.message.join(" ")
|
||||
await App.provide(
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
version: "0.0.0",
|
||||
},
|
||||
async () => {
|
||||
await Share.init()
|
||||
const session = input.session
|
||||
? await Session.get(input.session)
|
||||
: await Session.create()
|
||||
|
||||
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION)
|
||||
UI.empty()
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
||||
UI.empty()
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD +
|
||||
"~ https://dev.opencode.ai/s?id=" +
|
||||
session.id.slice(-8),
|
||||
)
|
||||
UI.empty()
|
||||
|
||||
function printEvent(color: string, type: string, title: string) {
|
||||
UI.println(
|
||||
color + `|`,
|
||||
UI.Style.TEXT_NORMAL +
|
||||
UI.Style.TEXT_DIM +
|
||||
` ${type.padEnd(7, " ")}`,
|
||||
"",
|
||||
UI.Style.TEXT_NORMAL + title,
|
||||
)
|
||||
}
|
||||
|
||||
Bus.subscribe(Message.Event.PartUpdated, async (message) => {
|
||||
const part = message.properties.part
|
||||
if (
|
||||
part.type === "tool-invocation" &&
|
||||
part.toolInvocation.state === "result"
|
||||
) {
|
||||
if (part.toolInvocation.toolName === "opencode_todowrite")
|
||||
return
|
||||
|
||||
const args = part.toolInvocation.args as any
|
||||
const tool = part.toolInvocation.toolName
|
||||
|
||||
if (tool === "opencode_edit")
|
||||
printEvent(UI.Style.TEXT_SUCCESS_BOLD, "Edit", args.filePath)
|
||||
if (tool === "opencode_bash")
|
||||
printEvent(
|
||||
UI.Style.TEXT_WARNING_BOLD,
|
||||
"Execute",
|
||||
args.command,
|
||||
)
|
||||
if (tool === "opencode_read")
|
||||
printEvent(UI.Style.TEXT_INFO_BOLD, "Read", args.filePath)
|
||||
if (tool === "opencode_write")
|
||||
printEvent(
|
||||
UI.Style.TEXT_SUCCESS_BOLD,
|
||||
"Create",
|
||||
args.filePath,
|
||||
)
|
||||
if (tool === "opencode_list")
|
||||
printEvent(UI.Style.TEXT_INFO_BOLD, "List", args.path)
|
||||
if (tool === "opencode_glob")
|
||||
printEvent(
|
||||
UI.Style.TEXT_INFO_BOLD,
|
||||
"Glob",
|
||||
args.pattern + (args.path ? " in " + args.path : ""),
|
||||
)
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
if (part.text.includes("\n")) {
|
||||
UI.empty()
|
||||
UI.println(part.text)
|
||||
UI.empty()
|
||||
return
|
||||
}
|
||||
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
||||
}
|
||||
})
|
||||
|
||||
const { providerID, modelID } = await Provider.defaultModel()
|
||||
await Session.chat({
|
||||
sessionID: session.id,
|
||||
providerID,
|
||||
modelID,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
})
|
||||
UI.empty()
|
||||
},
|
||||
)
|
||||
return "Session completed"
|
||||
},
|
||||
),
|
||||
|
||||
scrap: t.procedure
|
||||
.meta({
|
||||
description: "Test command for scraping files",
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
file: z.string().describe("File to process"),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }: { input: { file: string } }) => {
|
||||
await App.provide({ cwd: process.cwd(), version: VERSION }, async () => {
|
||||
await LSP.touchFile(input.file, true)
|
||||
await LSP.diagnostics()
|
||||
})
|
||||
return `Processed file: ${input.file}`
|
||||
}),
|
||||
|
||||
login: t.router({
|
||||
anthropic: t.procedure
|
||||
.meta({
|
||||
description: "Login to Anthropic",
|
||||
})
|
||||
.input(z.object({}))
|
||||
.mutation(async () => {
|
||||
const { url, verifier } = await AuthAnthropic.authorize()
|
||||
|
||||
UI.println("Login to Anthropic")
|
||||
UI.println("Open the following URL in your browser:")
|
||||
UI.println(url)
|
||||
UI.println("")
|
||||
|
||||
const code = await UI.input("Paste the authorization code here: ")
|
||||
await AuthAnthropic.exchange(code, verifier)
|
||||
return "Successfully logged in to Anthropic"
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export function createOpenCodeCli() {
|
||||
return createCli({ router })
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { NamedError } from "../util/error"
|
||||
|
||||
export namespace UI {
|
||||
const LOGO = [
|
||||
`█▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀`,
|
||||
`█░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀`,
|
||||
`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`,
|
||||
[`█▀▀█ █▀▀█ █▀▀ █▀▀▄ `, `█▀▀ █▀▀█ █▀▀▄ █▀▀`],
|
||||
[`█░░█ █░░█ █▀▀ █░░█ `, `█░░ █░░█ █░░█ █▀▀`],
|
||||
[`▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `, `▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`],
|
||||
]
|
||||
|
||||
export const CancelledError = NamedError.create("UICancelledError", z.void())
|
||||
@@ -48,12 +48,10 @@ export namespace UI {
|
||||
const result = []
|
||||
for (const row of LOGO) {
|
||||
if (pad) result.push(pad)
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const color =
|
||||
i > 18 ? Bun.color("white", "ansi") : Bun.color("gray", "ansi")
|
||||
const char = row[i]
|
||||
result.push(color + char)
|
||||
}
|
||||
result.push(Bun.color("gray", "ansi"))
|
||||
result.push(row[0])
|
||||
result.push("\x1b[0m")
|
||||
result.push(row[1])
|
||||
result.push("\n")
|
||||
}
|
||||
return result.join("").trimEnd()
|
||||
@@ -73,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,14 +1,19 @@
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
import { App } from "../app/app"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
import { mergeDeep } from "remeda"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import { lazy } from "../util/lazy"
|
||||
|
||||
export namespace Config {
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
export const state = App.state("config", async (app) => {
|
||||
let result: Info = {}
|
||||
let result = await global()
|
||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||
const [resolved] = await Filesystem.findUp(
|
||||
file,
|
||||
@@ -17,7 +22,10 @@ export namespace Config {
|
||||
)
|
||||
if (!resolved) continue
|
||||
try {
|
||||
result = await import(resolved).then((mod) => Info.parse(mod.default))
|
||||
result = mergeDeep(
|
||||
result,
|
||||
await import(resolved).then((mod) => Info.parse(mod.default)),
|
||||
)
|
||||
log.info("found", { path: resolved })
|
||||
break
|
||||
} catch (e) {
|
||||
@@ -34,23 +42,84 @@ export namespace Config {
|
||||
return result
|
||||
})
|
||||
|
||||
export const McpLocal = z.object({
|
||||
type: z.literal("local"),
|
||||
command: z.string().array(),
|
||||
environment: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
export const McpLocal = z
|
||||
.object({
|
||||
type: z.literal("local").describe("Type of MCP server connection"),
|
||||
command: z.string().array().describe("Command and arguments to run the MCP server"),
|
||||
environment: z.record(z.string(), z.string()).optional().describe("Environment variables to set when running the MCP server"),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Config.McpLocal",
|
||||
})
|
||||
|
||||
export const McpRemote = z.object({
|
||||
type: z.literal("remote"),
|
||||
url: z.string(),
|
||||
})
|
||||
export const McpRemote = z
|
||||
.object({
|
||||
type: z.literal("remote").describe("Type of MCP server connection"),
|
||||
url: z.string().describe("URL of the remote MCP server"),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Config.McpRemote",
|
||||
})
|
||||
|
||||
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
|
||||
export type Mcp = z.infer<typeof Mcp>
|
||||
|
||||
export const Keybinds = z
|
||||
.object({
|
||||
leader: z.string().optional().describe("Leader key for keybind combinations"),
|
||||
help: z.string().optional().describe("Show help dialog"),
|
||||
editor_open: z.string().optional().describe("Open external editor"),
|
||||
session_new: z.string().optional().describe("Create a new session"),
|
||||
session_list: z.string().optional().describe("List all sessions"),
|
||||
session_share: z.string().optional().describe("Share current session"),
|
||||
session_interrupt: z.string().optional().describe("Interrupt current session"),
|
||||
session_compact: z.string().optional().describe("Toggle compact mode for session"),
|
||||
tool_details: z.string().optional().describe("Show tool details"),
|
||||
model_list: z.string().optional().describe("List available models"),
|
||||
theme_list: z.string().optional().describe("List available themes"),
|
||||
project_init: z.string().optional().describe("Initialize project configuration"),
|
||||
input_clear: z.string().optional().describe("Clear input field"),
|
||||
input_paste: z.string().optional().describe("Paste from clipboard"),
|
||||
input_submit: z.string().optional().describe("Submit input"),
|
||||
input_newline: z.string().optional().describe("Insert newline in input"),
|
||||
history_previous: z.string().optional().describe("Navigate to previous history item"),
|
||||
history_next: z.string().optional().describe("Navigate to next history item"),
|
||||
messages_page_up: z.string().optional().describe("Scroll messages up by one page"),
|
||||
messages_page_down: z.string().optional().describe("Scroll messages down by one page"),
|
||||
messages_half_page_up: z.string().optional().describe("Scroll messages up by half page"),
|
||||
messages_half_page_down: z.string().optional().describe("Scroll messages down by half page"),
|
||||
messages_previous: z.string().optional().describe("Navigate to previous message"),
|
||||
messages_next: z.string().optional().describe("Navigate to next message"),
|
||||
messages_first: z.string().optional().describe("Navigate to first message"),
|
||||
messages_last: z.string().optional().describe("Navigate to last message"),
|
||||
app_exit: z.string().optional().describe("Exit the application"),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Config.Keybinds",
|
||||
})
|
||||
export const Info = z
|
||||
.object({
|
||||
$schema: z.string().optional(),
|
||||
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
||||
theme: z.string().optional().describe("Theme name to use for the interface"),
|
||||
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
|
||||
autoshare: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Share newly created sessions automatically"),
|
||||
autoupdate: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Automatically update to the latest version"),
|
||||
disabled_providers: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Disable providers that are loaded automatically"),
|
||||
model: z
|
||||
.string()
|
||||
.describe(
|
||||
"Model to use in the format of provider/model, eg anthropic/claude-2",
|
||||
)
|
||||
.optional(),
|
||||
provider: z
|
||||
.record(
|
||||
ModelsDev.Provider.partial().extend({
|
||||
@@ -58,13 +127,42 @@ export namespace Config {
|
||||
options: z.record(z.any()).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
mcp: z.record(z.string(), Mcp).optional(),
|
||||
.optional()
|
||||
.describe("Custom provider configurations and model overrides"),
|
||||
mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Config.Info",
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
export const global = lazy(async () => {
|
||||
let result = await Bun.file(path.join(Global.Path.config, "config.json"))
|
||||
.json()
|
||||
.then((mod) => Info.parse(mod))
|
||||
.catch(() => ({}) as Info)
|
||||
|
||||
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 Info.parse(result)
|
||||
})
|
||||
|
||||
export function get() {
|
||||
return state()
|
||||
}
|
||||
|
||||
10
packages/opencode/src/external/fzf.ts
vendored
10
packages/opencode/src/external/fzf.ts
vendored
@@ -116,11 +116,15 @@ export namespace Fzf {
|
||||
return filepath
|
||||
}
|
||||
|
||||
export async function search(cwd: string, query: string) {
|
||||
const results = await $`${await filepath()} --filter ${query}`
|
||||
export async function search(input: {
|
||||
cwd: string
|
||||
query: string
|
||||
limit?: number
|
||||
}) {
|
||||
const results = await $`${await filepath()} --filter=${input.query}`
|
||||
.quiet()
|
||||
.throws(false)
|
||||
.cwd(cwd)
|
||||
.cwd(input.cwd)
|
||||
.text()
|
||||
const split = results
|
||||
.trim()
|
||||
|
||||
17
packages/opencode/src/external/ripgrep.ts
vendored
17
packages/opencode/src/external/ripgrep.ts
vendored
@@ -1,10 +1,11 @@
|
||||
import { App } from "../app/app"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import { z } from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { $ } from "bun"
|
||||
import { Fzf } from "./fzf"
|
||||
|
||||
export namespace Ripgrep {
|
||||
const PLATFORM = {
|
||||
@@ -111,4 +112,18 @@ export namespace Ripgrep {
|
||||
const { filepath } = await state()
|
||||
return filepath
|
||||
}
|
||||
|
||||
export async function files(input: {
|
||||
cwd: string
|
||||
query?: string
|
||||
limit?: number
|
||||
}) {
|
||||
const commands = [`${await filepath()} --files --hidden --glob='!.git/*'`]
|
||||
if (input.query)
|
||||
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
|
||||
if (input.limit) commands.push(`head -n ${input.limit}`)
|
||||
const joined = commands.join(" | ")
|
||||
const result = await $`${{ raw: joined }}`.cwd(input.cwd).text()
|
||||
return result.split("\n").filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export namespace File {
|
||||
const glob = new Bun.Glob("**/*")
|
||||
export async function search(path: string, query: string) {
|
||||
for await (const entry of glob.scan({
|
||||
cwd: path,
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from "fs/promises"
|
||||
import { xdgData, xdgCache, xdgConfig } from "xdg-basedir"
|
||||
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
|
||||
import path from "path"
|
||||
|
||||
const app = "opencode"
|
||||
@@ -7,18 +7,23 @@ const app = "opencode"
|
||||
const data = path.join(xdgData!, app)
|
||||
const cache = path.join(xdgCache!, app)
|
||||
const config = path.join(xdgConfig!, app)
|
||||
|
||||
await Promise.all([
|
||||
fs.mkdir(data, { recursive: true }),
|
||||
fs.mkdir(config, { recursive: true }),
|
||||
fs.mkdir(cache, { recursive: true }),
|
||||
])
|
||||
const state = path.join(xdgState!, app)
|
||||
|
||||
export namespace Global {
|
||||
export const Path = {
|
||||
data,
|
||||
bin: path.join(data, "bin"),
|
||||
providers: path.join(config, "providers"),
|
||||
cache,
|
||||
config,
|
||||
state,
|
||||
} as const
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fs.mkdir(Global.Path.data, { recursive: true }),
|
||||
fs.mkdir(Global.Path.config, { recursive: true }),
|
||||
fs.mkdir(Global.Path.cache, { recursive: true }),
|
||||
fs.mkdir(Global.Path.providers, { recursive: true }),
|
||||
fs.mkdir(Global.Path.state, { recursive: true }),
|
||||
])
|
||||
|
||||
@@ -3,25 +3,25 @@ import { App } from "./app/app"
|
||||
import { Server } from "./server/server"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
import { Share } from "./share/share"
|
||||
|
||||
import { Global } from "./global"
|
||||
|
||||
import yargs from "yargs"
|
||||
import { hideBin } from "yargs/helpers"
|
||||
import { RunCommand } from "./cli/cmd/run"
|
||||
import { GenerateCommand } from "./cli/cmd/generate"
|
||||
import { VERSION } from "./cli/version"
|
||||
import { ScrapCommand } from "./cli/cmd/scrap"
|
||||
import { Log } from "./util/log"
|
||||
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
|
||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||
import { Provider } from "./provider/provider"
|
||||
import { UI } from "./cli/ui"
|
||||
import { Installation } from "./installation"
|
||||
import { Bus } from "./bus"
|
||||
import { Config } from "./config/config"
|
||||
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
.scriptName("opencode")
|
||||
.version(VERSION)
|
||||
.version(Installation.VERSION)
|
||||
.option("print-logs", {
|
||||
describe: "Print logs to stderr",
|
||||
type: "boolean",
|
||||
@@ -29,14 +29,14 @@ const cli = yargs(hideBin(process.argv))
|
||||
.middleware(async () => {
|
||||
await Log.init({ print: process.argv.includes("--print-logs") })
|
||||
Log.Default.info("opencode", {
|
||||
version: VERSION,
|
||||
version: Installation.VERSION,
|
||||
args: process.argv.slice(2),
|
||||
})
|
||||
})
|
||||
.usage("\n" + UI.logo())
|
||||
.command({
|
||||
command: "$0 [project]",
|
||||
describe: "Start OpenCode TUI",
|
||||
describe: "start opencode TUI",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("project", {
|
||||
type: "string",
|
||||
@@ -46,51 +46,65 @@ const cli = yargs(hideBin(process.argv))
|
||||
while (true) {
|
||||
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
||||
process.chdir(cwd)
|
||||
const result = await App.provide(
|
||||
{ cwd, version: VERSION },
|
||||
async () => {
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return "needs_provider"
|
||||
const result = await App.provide({ cwd }, async (app) => {
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return "needs_provider"
|
||||
}
|
||||
|
||||
await Share.init()
|
||||
const server = Server.listen()
|
||||
|
||||
let cmd = ["go", "run", "./main.go"]
|
||||
let cwd = new URL("../../tui/cmd/opencode", import.meta.url).pathname
|
||||
if (Bun.embeddedFiles.length > 0) {
|
||||
const blob = Bun.embeddedFiles[0] as File
|
||||
const binary = path.join(Global.Path.cache, "tui", blob.name)
|
||||
const file = Bun.file(binary)
|
||||
if (!(await file.exists())) {
|
||||
await Bun.write(file, blob, { mode: 0o755 })
|
||||
await fs.chmod(binary, 0o755)
|
||||
}
|
||||
cwd = process.cwd()
|
||||
cmd = [binary]
|
||||
}
|
||||
const proc = Bun.spawn({
|
||||
cmd: [...cmd, ...process.argv.slice(2)],
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
OPENCODE_APP_INFO: JSON.stringify(app),
|
||||
},
|
||||
onExit: () => {
|
||||
server.stop()
|
||||
},
|
||||
})
|
||||
|
||||
await Share.init()
|
||||
const server = Server.listen()
|
||||
; (async () => {
|
||||
if (Installation.VERSION === "dev") return
|
||||
if (Installation.isSnapshot()) return
|
||||
const config = await Config.global()
|
||||
if (config.autoupdate === false) return
|
||||
const latest = await Installation.latest()
|
||||
if (Installation.VERSION === latest) return
|
||||
const method = await Installation.method()
|
||||
if (method === "unknown") return
|
||||
await Installation.upgrade(method, latest)
|
||||
.then(() => {
|
||||
Bus.publish(Installation.Event.Updated, { version: latest })
|
||||
})
|
||||
.catch(() => { })
|
||||
})()
|
||||
|
||||
let cmd = ["go", "run", "./main.go"]
|
||||
let cwd = new URL("../../tui/cmd/opencode", import.meta.url)
|
||||
.pathname
|
||||
if (Bun.embeddedFiles.length > 0) {
|
||||
const blob = Bun.embeddedFiles[0] as File
|
||||
const binary = path.join(Global.Path.cache, "tui", blob.name)
|
||||
const file = Bun.file(binary)
|
||||
if (!(await file.exists())) {
|
||||
await Bun.write(file, blob, { mode: 0o755 })
|
||||
await fs.chmod(binary, 0o755)
|
||||
}
|
||||
cwd = process.cwd()
|
||||
cmd = [binary]
|
||||
}
|
||||
const proc = Bun.spawn({
|
||||
cmd: [...cmd, ...process.argv.slice(2)],
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
},
|
||||
onExit: () => {
|
||||
server.stop()
|
||||
},
|
||||
})
|
||||
await proc.exited
|
||||
await server.stop()
|
||||
await proc.exited
|
||||
server.stop()
|
||||
|
||||
return "done"
|
||||
},
|
||||
)
|
||||
return "done"
|
||||
})
|
||||
if (result === "done") break
|
||||
if (result === "needs_provider") {
|
||||
UI.empty()
|
||||
@@ -105,21 +119,21 @@ const cli = yargs(hideBin(process.argv))
|
||||
.command(GenerateCommand)
|
||||
.command(ScrapCommand)
|
||||
.command(AuthCommand)
|
||||
.fail((msg, err) => {
|
||||
.command(UpgradeCommand)
|
||||
.fail((msg) => {
|
||||
if (
|
||||
msg.startsWith("Unknown argument") ||
|
||||
msg.startsWith("Not enough non-option arguments")
|
||||
) {
|
||||
cli.showHelp("log")
|
||||
}
|
||||
Log.Default.error(msg, {
|
||||
err,
|
||||
})
|
||||
})
|
||||
.strict()
|
||||
|
||||
try {
|
||||
await cli.parse()
|
||||
} catch (e) {
|
||||
Log.Default.error(e)
|
||||
Log.Default.error(e, {
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
126
packages/opencode/src/installation/index.ts
Normal file
126
packages/opencode/src/installation/index.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import path from "path"
|
||||
import { $ } from "bun"
|
||||
import { z } from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Bus } from "../bus"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_VERSION: string
|
||||
}
|
||||
|
||||
export namespace Installation {
|
||||
export type Method = Awaited<ReturnType<typeof method>>
|
||||
|
||||
export const Event = {
|
||||
Updated: Bus.event(
|
||||
"installation.updated",
|
||||
z.object({
|
||||
version: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
version: z.string(),
|
||||
latest: z.string(),
|
||||
})
|
||||
.openapi({
|
||||
ref: "InstallationInfo",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export async function info() {
|
||||
return {
|
||||
version: VERSION,
|
||||
latest: await latest(),
|
||||
}
|
||||
}
|
||||
|
||||
export function isSnapshot() {
|
||||
return VERSION.startsWith("0.0.0")
|
||||
}
|
||||
|
||||
export function isDev() {
|
||||
return VERSION === "dev"
|
||||
}
|
||||
|
||||
export async function method() {
|
||||
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
|
||||
const exec = process.execPath.toLowerCase()
|
||||
|
||||
const checks = [
|
||||
{
|
||||
name: "npm" as const,
|
||||
command: () => $`npm list -g --depth=0`.throws(false).text(),
|
||||
},
|
||||
{
|
||||
name: "yarn" as const,
|
||||
command: () => $`yarn global list`.throws(false).text(),
|
||||
},
|
||||
{
|
||||
name: "pnpm" as const,
|
||||
command: () => $`pnpm list -g --depth=0`.throws(false).text(),
|
||||
},
|
||||
{
|
||||
name: "bun" as const,
|
||||
command: () => $`bun pm ls -g`.throws(false).text(),
|
||||
},
|
||||
]
|
||||
|
||||
checks.sort((a, b) => {
|
||||
const aMatches = exec.includes(a.name)
|
||||
const bMatches = exec.includes(b.name)
|
||||
if (aMatches && !bMatches) return -1
|
||||
if (!aMatches && bMatches) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
for (const check of checks) {
|
||||
const output = await check.command()
|
||||
if (output.includes("opencode-ai")) {
|
||||
return check.name
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
export const UpgradeFailedError = NamedError.create(
|
||||
"UpgradeFailedError",
|
||||
z.object({
|
||||
stderr: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export async function upgrade(method: Method, target: string) {
|
||||
const cmd = (() => {
|
||||
switch (method) {
|
||||
case "curl":
|
||||
return $`curl -fsSL https://opencode.ai/install | bash`
|
||||
case "npm":
|
||||
return $`npm install -g opencode-ai@${target}`
|
||||
case "pnpm":
|
||||
return $`pnpm install -g opencode-ai@${target}`
|
||||
case "bun":
|
||||
return $`bun install -g opencode-ai@${target}`
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`)
|
||||
}
|
||||
})()
|
||||
const result = await cmd.quiet().throws(false)
|
||||
if (result.exitCode !== 0)
|
||||
throw new UpgradeFailedError({
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
})
|
||||
}
|
||||
|
||||
export const VERSION =
|
||||
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
|
||||
|
||||
export async function latest() {
|
||||
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.tag_name.slice(1) as string)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { LANGUAGE_EXTENSIONS } from "./language"
|
||||
import { Bus } from "../bus"
|
||||
import z from "zod"
|
||||
import type { LSPServer } from "./server"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace LSPClient {
|
||||
const log = Log.create({ service: "lsp.client" })
|
||||
@@ -19,6 +20,13 @@ export namespace LSPClient {
|
||||
|
||||
export type Diagnostic = VSCodeDiagnostic
|
||||
|
||||
export const InitializeError = NamedError.create(
|
||||
"LSPInitializeError",
|
||||
z.object({
|
||||
serverID: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const Event = {
|
||||
Diagnostics: Bus.event(
|
||||
"lsp.client.diagnostics",
|
||||
@@ -52,32 +60,40 @@ export namespace LSPClient {
|
||||
})
|
||||
connection.listen()
|
||||
|
||||
await connection.sendRequest("initialize", {
|
||||
processId: server.process.pid,
|
||||
workspaceFolders: [
|
||||
{
|
||||
name: "workspace",
|
||||
uri: "file://" + app.path.cwd,
|
||||
},
|
||||
],
|
||||
initializationOptions: {
|
||||
...server.initialization,
|
||||
},
|
||||
capabilities: {
|
||||
workspace: {
|
||||
configuration: true,
|
||||
},
|
||||
textDocument: {
|
||||
synchronization: {
|
||||
didOpen: true,
|
||||
didChange: true,
|
||||
log.info("sending initialize", { id: serverID })
|
||||
await Promise.race([
|
||||
connection.sendRequest("initialize", {
|
||||
processId: server.process.pid,
|
||||
workspaceFolders: [
|
||||
{
|
||||
name: "workspace",
|
||||
uri: "file://" + app.path.cwd,
|
||||
},
|
||||
publishDiagnostics: {
|
||||
versionSupport: true,
|
||||
],
|
||||
initializationOptions: {
|
||||
...server.initialization,
|
||||
},
|
||||
capabilities: {
|
||||
workspace: {
|
||||
configuration: true,
|
||||
},
|
||||
textDocument: {
|
||||
synchronization: {
|
||||
didOpen: true,
|
||||
didChange: true,
|
||||
},
|
||||
publishDiagnostics: {
|
||||
versionSupport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new InitializeError({ serverID }))
|
||||
}, 5_000)
|
||||
}),
|
||||
])
|
||||
await connection.sendNotification("initialized", {})
|
||||
log.info("initialized")
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ export namespace LSP {
|
||||
async () => {
|
||||
log.info("initializing")
|
||||
const clients = new Map<string, LSPClient.Info>()
|
||||
|
||||
const skip = new Set<string>()
|
||||
return {
|
||||
clients,
|
||||
skip,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
@@ -31,11 +32,19 @@ export namespace LSP {
|
||||
x.extensions.includes(extension),
|
||||
)
|
||||
for (const match of matches) {
|
||||
if (s.skip.has(match.id)) continue
|
||||
const existing = s.clients.get(match.id)
|
||||
if (existing) continue
|
||||
const handle = await match.spawn(App.info())
|
||||
if (!handle) continue
|
||||
const client = await LSPClient.create(match.id, handle)
|
||||
if (!handle) {
|
||||
s.skip.add(match.id)
|
||||
continue
|
||||
}
|
||||
const client = await LSPClient.create(match.id, handle).catch(() => {})
|
||||
if (!client) {
|
||||
s.skip.add(match.id)
|
||||
continue
|
||||
}
|
||||
s.clients.set(match.id, client)
|
||||
}
|
||||
if (waitForDiagnostics) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Global } from "../global"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
@@ -17,8 +16,8 @@ export namespace ModelsDev {
|
||||
cost: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
inputCached: z.number(),
|
||||
outputCached: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
}),
|
||||
limit: z.object({
|
||||
context: z.number(),
|
||||
@@ -33,9 +32,11 @@ export namespace ModelsDev {
|
||||
|
||||
export const Provider = z
|
||||
.object({
|
||||
api: z.string().optional(),
|
||||
name: z.string(),
|
||||
env: z.array(z.string()),
|
||||
id: z.string(),
|
||||
npm: z.string().optional(),
|
||||
models: z.record(Model),
|
||||
})
|
||||
.openapi({
|
||||
@@ -63,30 +64,4 @@ export namespace ModelsDev {
|
||||
throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
|
||||
await Bun.write(file, result)
|
||||
}
|
||||
|
||||
const aisdk = lazy(async () => {
|
||||
log.info("fetching ai-sdk")
|
||||
const response = await fetch(
|
||||
"https://registry.npmjs.org/-/v1/search?text=scope:@ai-sdk",
|
||||
)
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Failed to fetch ai-sdk information: ${response.statusText}`,
|
||||
)
|
||||
const result = await response.json()
|
||||
log.info("found ai-sdk", result.objects.length)
|
||||
return result.objects
|
||||
.filter((obj: any) => obj.package.name.startsWith("@ai-sdk/"))
|
||||
.reduce((acc: any, obj: any) => {
|
||||
acc[obj.package.name] = obj
|
||||
return acc
|
||||
}, {})
|
||||
})
|
||||
|
||||
export async function pkg(providerID: string): Promise<[string, string]> {
|
||||
const packages = await aisdk()
|
||||
const match = packages[`@ai-sdk/${providerID}`]
|
||||
if (match) return [match.package.name, "latest"]
|
||||
return [providerID, "latest"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,16 +40,23 @@ export namespace Provider {
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
input: 0,
|
||||
inputCached: 0,
|
||||
output: 0,
|
||||
outputCached: 0,
|
||||
}
|
||||
}
|
||||
return {
|
||||
apiKey: "",
|
||||
headers: {
|
||||
authorization: `Bearer ${access}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
async fetch(input: any, init: any) {
|
||||
const access = await AuthAnthropic.access()
|
||||
const headers = {
|
||||
...init.headers,
|
||||
authorization: `Bearer ${access}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
}
|
||||
delete headers["x-api-key"]
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -82,7 +89,7 @@ export namespace Provider {
|
||||
>()
|
||||
const sdk = new Map<string, SDK>()
|
||||
|
||||
log.info("loading")
|
||||
log.info("init")
|
||||
|
||||
function mergeProvider(
|
||||
id: string,
|
||||
@@ -91,9 +98,12 @@ export namespace Provider {
|
||||
) {
|
||||
const provider = providers[id]
|
||||
if (!provider) {
|
||||
const info = database[id]
|
||||
if (!info) return
|
||||
if (info.api) options["baseURL"] = info.api
|
||||
providers[id] = {
|
||||
source,
|
||||
info: database[id],
|
||||
info,
|
||||
options,
|
||||
}
|
||||
return
|
||||
@@ -102,12 +112,13 @@ export namespace Provider {
|
||||
provider.source = source
|
||||
}
|
||||
|
||||
for (const [providerID, provider] of Object.entries(
|
||||
config.provider ?? {},
|
||||
)) {
|
||||
const configProviders = Object.entries(config.provider ?? {})
|
||||
|
||||
for (const [providerID, provider] of configProviders) {
|
||||
const existing = database[providerID]
|
||||
const parsed: ModelsDev.Provider = {
|
||||
id: providerID,
|
||||
npm: provider.npm ?? existing?.npm,
|
||||
name: provider.name ?? existing?.name ?? providerID,
|
||||
env: provider.env ?? existing?.env ?? [],
|
||||
models: existing?.models ?? {},
|
||||
@@ -139,8 +150,12 @@ export namespace Provider {
|
||||
database[providerID] = parsed
|
||||
}
|
||||
|
||||
const disabled = await Config.get().then(
|
||||
(cfg) => new Set(cfg.disabled_providers ?? []),
|
||||
)
|
||||
// load env
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
if (disabled.has(providerID)) continue
|
||||
if (provider.env.some((item) => process.env[item])) {
|
||||
mergeProvider(providerID, {}, "env")
|
||||
}
|
||||
@@ -148,6 +163,7 @@ export namespace Provider {
|
||||
|
||||
// load apikeys
|
||||
for (const [providerID, provider] of Object.entries(await Auth.all())) {
|
||||
if (disabled.has(providerID)) continue
|
||||
if (provider.type === "api") {
|
||||
mergeProvider(providerID, { apiKey: provider.key }, "api")
|
||||
}
|
||||
@@ -155,19 +171,18 @@ export namespace Provider {
|
||||
|
||||
// load custom
|
||||
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||
if (disabled.has(providerID)) continue
|
||||
const result = await fn(database[providerID])
|
||||
if (result) mergeProvider(providerID, result, "custom")
|
||||
}
|
||||
|
||||
// load config
|
||||
for (const [providerID, provider] of Object.entries(
|
||||
config.provider ?? {},
|
||||
)) {
|
||||
for (const [providerID, provider] of configProviders) {
|
||||
mergeProvider(providerID, provider.options ?? {}, "config")
|
||||
}
|
||||
|
||||
for (const providerID of Object.keys(providers)) {
|
||||
log.info("loaded", { providerID })
|
||||
log.info("found", { providerID })
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -181,19 +196,22 @@ export namespace Provider {
|
||||
return state().then((state) => state.providers)
|
||||
}
|
||||
|
||||
async function getSDK(providerID: string) {
|
||||
async function getSDK(provider: ModelsDev.Provider) {
|
||||
return (async () => {
|
||||
using _ = log.time("getSDK", {
|
||||
providerID: provider.id,
|
||||
})
|
||||
const s = await state()
|
||||
const existing = s.sdk.get(providerID)
|
||||
const existing = s.sdk.get(provider.id)
|
||||
if (existing) return existing
|
||||
const [pkg, version] = await ModelsDev.pkg(providerID)
|
||||
const mod = await import(await BunProc.install(pkg, version))
|
||||
const pkg = provider.npm ?? provider.id
|
||||
const mod = await import(await BunProc.install(pkg, "latest"))
|
||||
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
||||
const loaded = fn(s.providers[providerID]?.options)
|
||||
s.sdk.set(providerID, loaded)
|
||||
const loaded = fn(s.providers[provider.id]?.options)
|
||||
s.sdk.set(provider.id, loaded)
|
||||
return loaded as SDK
|
||||
})().catch((e) => {
|
||||
throw new InitError({ providerID: providerID }, { cause: e })
|
||||
throw new InitError({ providerID: provider.id }, { cause: e })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -202,7 +220,7 @@ export namespace Provider {
|
||||
const s = await state()
|
||||
if (s.models.has(key)) return s.models.get(key)!
|
||||
|
||||
log.info("loading", {
|
||||
log.info("getModel", {
|
||||
providerID,
|
||||
modelID,
|
||||
})
|
||||
@@ -211,8 +229,7 @@ export namespace Provider {
|
||||
if (!provider) throw new ModelNotFoundError({ providerID, modelID })
|
||||
const info = provider.info.models[modelID]
|
||||
if (!info) throw new ModelNotFoundError({ providerID, modelID })
|
||||
|
||||
const sdk = await getSDK(providerID)
|
||||
const sdk = await getSDK(provider.info)
|
||||
|
||||
try {
|
||||
const language =
|
||||
@@ -254,7 +271,15 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
export async function defaultModel() {
|
||||
const [provider] = await list().then((val) => Object.values(val))
|
||||
const cfg = await Config.get()
|
||||
if (cfg.model) return parseModel(cfg.model)
|
||||
const provider = await list()
|
||||
.then((val) => Object.values(val))
|
||||
.then((x) =>
|
||||
x.find(
|
||||
(p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id),
|
||||
),
|
||||
)
|
||||
if (!provider) throw new Error("no providers found")
|
||||
const [model] = sort(Object.values(provider.info.models))
|
||||
if (!model) throw new Error("no models found")
|
||||
@@ -264,6 +289,14 @@ export namespace Provider {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseModel(model: string) {
|
||||
const [providerID, ...rest] = model.split("/")
|
||||
return {
|
||||
providerID: providerID,
|
||||
modelID: rest.join("/"),
|
||||
}
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
BashTool,
|
||||
EditTool,
|
||||
@@ -282,11 +315,16 @@ export namespace Provider {
|
||||
TaskTool,
|
||||
TodoReadTool,
|
||||
]
|
||||
|
||||
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
|
||||
anthropic: TOOLS.filter((t) => t.id !== "opencode.patch"),
|
||||
openai: TOOLS,
|
||||
anthropic: TOOLS.filter((t) => t.id !== "patch"),
|
||||
openai: TOOLS.map((t) => ({
|
||||
...t,
|
||||
parameters: optionalToNullable(t.parameters),
|
||||
})),
|
||||
google: TOOLS,
|
||||
}
|
||||
|
||||
export async function tools(providerID: string) {
|
||||
/*
|
||||
const cfg = await Config.get()
|
||||
@@ -298,6 +336,38 @@ export namespace Provider {
|
||||
return TOOL_MAPPING[providerID] ?? TOOLS
|
||||
}
|
||||
|
||||
function optionalToNullable(schema: z.ZodTypeAny): z.ZodTypeAny {
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const shape = schema.shape
|
||||
const newShape: Record<string, z.ZodTypeAny> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(shape)) {
|
||||
const zodValue = value as z.ZodTypeAny
|
||||
if (zodValue instanceof z.ZodOptional) {
|
||||
newShape[key] = zodValue.unwrap().nullable()
|
||||
} else {
|
||||
newShape[key] = optionalToNullable(zodValue)
|
||||
}
|
||||
}
|
||||
|
||||
return z.object(newShape)
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodArray) {
|
||||
return z.array(optionalToNullable(schema.element))
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodUnion) {
|
||||
return z.union(
|
||||
schema.options.map((option: z.ZodTypeAny) =>
|
||||
optionalToNullable(option),
|
||||
) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]],
|
||||
)
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
export const ModelNotFoundError = NamedError.create(
|
||||
"ProviderModelNotFoundError",
|
||||
z.object({
|
||||
|
||||
24
packages/opencode/src/provider/transform.ts
Normal file
24
packages/opencode/src/provider/transform.ts
Normal file
@@ -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,8 +12,10 @@ import { App } from "../app/app"
|
||||
import { Global } from "../global"
|
||||
import { mapValues } from "remeda"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Fzf } from "../external/fzf"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
import { Ripgrep } from "../external/ripgrep"
|
||||
import { Installation } from "../installation"
|
||||
import { Config } from "../config/config"
|
||||
|
||||
const ERRORS = {
|
||||
400: {
|
||||
@@ -139,6 +141,25 @@ export namespace Server {
|
||||
return c.json(App.info())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/config_get",
|
||||
describeRoute({
|
||||
description: "Get config info",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get config info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await Config.get())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/app_initialize",
|
||||
describeRoute({
|
||||
@@ -457,10 +478,33 @@ export namespace Server {
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const app = App.info()
|
||||
const result = await Fzf.search(app.path.cwd, body.query)
|
||||
const result = await Ripgrep.files({
|
||||
cwd: app.path.cwd,
|
||||
query: body.query,
|
||||
limit: 10,
|
||||
})
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"installation_info",
|
||||
describeRoute({
|
||||
description: "Get installation info",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get installation info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Installation.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(Installation.info())
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type LanguageModelUsage,
|
||||
type CoreMessage,
|
||||
type UIMessage,
|
||||
type ProviderMetadata,
|
||||
} from "ai"
|
||||
import { z, ZodSchema } from "zod"
|
||||
import { Decimal } from "decimal.js"
|
||||
@@ -29,6 +30,9 @@ import type { Tool } from "../tool/tool"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Flag } from "../flag/flag"
|
||||
import type { ModelsDev } from "../provider/models"
|
||||
import { Installation } from "../installation"
|
||||
import { Config } from "../config/config"
|
||||
import { ProviderTransform } from "../provider/transform"
|
||||
|
||||
export namespace Session {
|
||||
const log = Log.create({ service: "session" })
|
||||
@@ -39,11 +43,11 @@ export namespace Session {
|
||||
parentID: Identifier.schema("session").optional(),
|
||||
share: z
|
||||
.object({
|
||||
secret: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
title: z.string(),
|
||||
version: z.string(),
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
updated: z.number(),
|
||||
@@ -54,6 +58,12 @@ export namespace Session {
|
||||
})
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
export const ShareInfo = z.object({
|
||||
secret: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
export type ShareInfo = z.output<typeof ShareInfo>
|
||||
|
||||
export const Event = {
|
||||
Updated: Bus.event(
|
||||
"session.updated",
|
||||
@@ -69,19 +79,30 @@ export namespace Session {
|
||||
),
|
||||
}
|
||||
|
||||
const state = App.state("session", () => {
|
||||
const sessions = new Map<string, Info>()
|
||||
const messages = new Map<string, Message.Info[]>()
|
||||
const state = App.state(
|
||||
"session",
|
||||
() => {
|
||||
const sessions = new Map<string, Info>()
|
||||
const messages = new Map<string, Message.Info[]>()
|
||||
const pending = new Map<string, AbortController>()
|
||||
|
||||
return {
|
||||
sessions,
|
||||
messages,
|
||||
}
|
||||
})
|
||||
return {
|
||||
sessions,
|
||||
messages,
|
||||
pending,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
for (const [_, controller] of state.pending) {
|
||||
controller.abort()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export async function create(parentID?: string) {
|
||||
const result: Info = {
|
||||
id: Identifier.descending("session"),
|
||||
version: Installation.VERSION,
|
||||
parentID,
|
||||
title:
|
||||
(parentID ? "Child session - " : "New Session - ") +
|
||||
@@ -94,7 +115,8 @@ export namespace Session {
|
||||
log.info("created", result)
|
||||
state().sessions.set(result.id, result)
|
||||
await Storage.writeJSON("session/info/" + result.id, result)
|
||||
if (!result.parentID && Flag.OPENCODE_AUTO_SHARE)
|
||||
const cfg = await Config.get()
|
||||
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.autoshare))
|
||||
share(result.id).then((share) => {
|
||||
update(result.id, (draft) => {
|
||||
draft.share = share
|
||||
@@ -116,13 +138,21 @@ export namespace Session {
|
||||
return read as Info
|
||||
}
|
||||
|
||||
export async function getShare(id: string) {
|
||||
return Storage.readJSON<ShareInfo>("session/share/" + id)
|
||||
}
|
||||
|
||||
export async function share(id: string) {
|
||||
const session = await get(id)
|
||||
if (session.share) return session.share
|
||||
const share = await Share.create(id)
|
||||
await update(id, (draft) => {
|
||||
draft.share = share
|
||||
draft.share = {
|
||||
url: share.url,
|
||||
}
|
||||
})
|
||||
await Storage.writeJSON<ShareInfo>("session/share/" + id, share)
|
||||
await Share.sync("session/info/" + id, session)
|
||||
for (const msg of await messages(id)) {
|
||||
await Share.sync("session/message/" + id + "/" + msg.id, msg)
|
||||
}
|
||||
@@ -168,10 +198,10 @@ export namespace Session {
|
||||
}
|
||||
|
||||
export function abort(sessionID: string) {
|
||||
const controller = pending.get(sessionID)
|
||||
const controller = state().pending.get(sessionID)
|
||||
if (!controller) return false
|
||||
controller.abort()
|
||||
pending.delete(sessionID)
|
||||
state().pending.delete(sessionID)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -203,10 +233,13 @@ export namespace Session {
|
||||
if (previous?.metadata.assistant) {
|
||||
const tokens =
|
||||
previous.metadata.assistant.tokens.input +
|
||||
previous.metadata.assistant.tokens.cache.read +
|
||||
previous.metadata.assistant.tokens.cache.write +
|
||||
previous.metadata.assistant.tokens.output
|
||||
if (
|
||||
model.info.limit.context &&
|
||||
tokens >
|
||||
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
|
||||
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
|
||||
) {
|
||||
await summarize({
|
||||
sessionID: input.sessionID,
|
||||
@@ -243,7 +276,9 @@ export namespace Session {
|
||||
parts: toParts(input.parts),
|
||||
},
|
||||
]),
|
||||
],
|
||||
].map((msg, i) =>
|
||||
ProviderTransform.message(msg, i, input.providerID, input.modelID),
|
||||
),
|
||||
model: model.language,
|
||||
})
|
||||
.then((result) => {
|
||||
@@ -252,7 +287,7 @@ export namespace Session {
|
||||
draft.title = result.text
|
||||
})
|
||||
})
|
||||
.catch((e) => {})
|
||||
.catch(() => {})
|
||||
}
|
||||
const msg: Message.Info = {
|
||||
role: "user",
|
||||
@@ -270,7 +305,7 @@ export namespace Session {
|
||||
msgs.push(msg)
|
||||
|
||||
const system = input.system ?? SystemPrompt.provider(input.providerID)
|
||||
system.push(...(await SystemPrompt.environment(input.sessionID)))
|
||||
system.push(...(await SystemPrompt.environment()))
|
||||
system.push(...(await SystemPrompt.custom()))
|
||||
|
||||
const next: Message.Info = {
|
||||
@@ -289,6 +324,7 @@ export namespace Session {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: input.modelID,
|
||||
providerID: input.providerID,
|
||||
@@ -315,6 +351,16 @@ export namespace Session {
|
||||
sessionID: input.sessionID,
|
||||
abort: abort.signal,
|
||||
messageID: next.id,
|
||||
metadata: async (val) => {
|
||||
next.metadata.tool[opts.toolCallId] = {
|
||||
...val,
|
||||
time: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
},
|
||||
}
|
||||
await updateMessage(next)
|
||||
},
|
||||
})
|
||||
next.metadata!.tool![opts.toolCallId] = {
|
||||
...result.metadata,
|
||||
@@ -379,13 +425,29 @@ export namespace Session {
|
||||
}
|
||||
|
||||
let text: Message.TextPart | undefined
|
||||
await Bun.write(
|
||||
"/tmp/message.json",
|
||||
JSON.stringify(
|
||||
[
|
||||
...system.map(
|
||||
(x): CoreMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...convertToCoreMessages(
|
||||
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
|
||||
),
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
const result = streamText({
|
||||
onStepFinish: async (step) => {
|
||||
log.info("step finish", {
|
||||
finishReason: step.finishReason,
|
||||
})
|
||||
log.info("step finish", { finishReason: step.finishReason })
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(step.usage, model.info)
|
||||
const usage = getUsage(model.info, step.usage, step.providerMetadata)
|
||||
assistant.cost += usage.cost
|
||||
assistant.tokens = usage.tokens
|
||||
await updateMessage(next)
|
||||
@@ -399,13 +461,16 @@ export namespace Session {
|
||||
text = undefined
|
||||
},
|
||||
async onFinish(input) {
|
||||
log.info("message finish", {
|
||||
reason: input.finishReason,
|
||||
})
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(input.usage, model.info)
|
||||
const usage = getUsage(model.info, input.usage, input.providerMetadata)
|
||||
assistant.cost = usage.cost
|
||||
await updateMessage(next)
|
||||
},
|
||||
onError(err) {
|
||||
log.error("error", err)
|
||||
log.error("callback error", err)
|
||||
switch (true) {
|
||||
case LoadAPIKeyError.isInstance(err.error):
|
||||
next.metadata.error = new Provider.AuthError(
|
||||
@@ -452,108 +517,141 @@ export namespace Session {
|
||||
...convertToCoreMessages(
|
||||
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
|
||||
),
|
||||
],
|
||||
temperature: model.info.id === "codex-mini-latest" ? undefined : 0,
|
||||
].map((msg, i) =>
|
||||
ProviderTransform.message(msg, i, input.providerID, input.modelID),
|
||||
),
|
||||
temperature: model.info.temperature ? 0 : undefined,
|
||||
tools: {
|
||||
...(await MCP.tools()),
|
||||
...tools,
|
||||
},
|
||||
model: model.language,
|
||||
})
|
||||
for await (const value of result.fullStream) {
|
||||
l.info("part", {
|
||||
type: value.type,
|
||||
})
|
||||
switch (value.type) {
|
||||
case "step-start":
|
||||
next.parts.push({
|
||||
type: "step-start",
|
||||
})
|
||||
break
|
||||
case "text-delta":
|
||||
if (!text) {
|
||||
text = {
|
||||
type: "text",
|
||||
text: value.textDelta,
|
||||
}
|
||||
next.parts.push(text)
|
||||
try {
|
||||
for await (const value of result.fullStream) {
|
||||
l.info("part", {
|
||||
type: value.type,
|
||||
})
|
||||
switch (value.type) {
|
||||
case "step-start":
|
||||
next.parts.push({
|
||||
type: "step-start",
|
||||
})
|
||||
break
|
||||
case "text-delta":
|
||||
if (!text) {
|
||||
text = {
|
||||
type: "text",
|
||||
text: value.textDelta,
|
||||
}
|
||||
next.parts.push(text)
|
||||
break
|
||||
} else text.text += value.textDelta
|
||||
break
|
||||
} else text.text += value.textDelta
|
||||
break
|
||||
|
||||
case "tool-call": {
|
||||
const [match] = next.parts.flatMap((p) =>
|
||||
p.type === "tool-invocation" &&
|
||||
p.toolInvocation.toolCallId === value.toolCallId
|
||||
? [p]
|
||||
: [],
|
||||
)
|
||||
if (!match) break
|
||||
match.toolInvocation.args = value.args
|
||||
match.toolInvocation.state = "call"
|
||||
Bus.publish(Message.Event.PartUpdated, {
|
||||
part: match,
|
||||
messageID: next.id,
|
||||
sessionID: next.metadata.sessionID,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "tool-call-streaming-start":
|
||||
next.parts.push({
|
||||
type: "tool-invocation",
|
||||
toolInvocation: {
|
||||
state: "partial-call",
|
||||
toolName: value.toolName,
|
||||
toolCallId: value.toolCallId,
|
||||
args: {},
|
||||
},
|
||||
})
|
||||
Bus.publish(Message.Event.PartUpdated, {
|
||||
part: next.parts[next.parts.length - 1],
|
||||
messageID: next.id,
|
||||
sessionID: next.metadata.sessionID,
|
||||
})
|
||||
break
|
||||
|
||||
case "tool-call-delta":
|
||||
break
|
||||
|
||||
// for some reason ai sdk claims to not send this part but it does
|
||||
// @ts-expect-error
|
||||
case "tool-result":
|
||||
const match = next.parts.find(
|
||||
(p) =>
|
||||
case "tool-call": {
|
||||
const [match] = next.parts.flatMap((p) =>
|
||||
p.type === "tool-invocation" &&
|
||||
// @ts-expect-error
|
||||
p.toolInvocation.toolCallId === value.toolCallId,
|
||||
)
|
||||
if (match && match.type === "tool-invocation") {
|
||||
match.toolInvocation = {
|
||||
// @ts-expect-error
|
||||
args: value.args,
|
||||
// @ts-expect-error
|
||||
toolCallId: value.toolCallId,
|
||||
// @ts-expect-error
|
||||
toolName: value.toolName,
|
||||
state: "result",
|
||||
// @ts-expect-error
|
||||
result: value.result as string,
|
||||
}
|
||||
p.toolInvocation.toolCallId === value.toolCallId
|
||||
? [p]
|
||||
: [],
|
||||
)
|
||||
if (!match) break
|
||||
match.toolInvocation.args = value.args
|
||||
match.toolInvocation.state = "call"
|
||||
Bus.publish(Message.Event.PartUpdated, {
|
||||
part: match,
|
||||
messageID: next.id,
|
||||
sessionID: next.metadata.sessionID,
|
||||
})
|
||||
break
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
l.info("unhandled", {
|
||||
type: value.type,
|
||||
})
|
||||
case "tool-call-streaming-start":
|
||||
next.parts.push({
|
||||
type: "tool-invocation",
|
||||
toolInvocation: {
|
||||
state: "partial-call",
|
||||
toolName: value.toolName,
|
||||
toolCallId: value.toolCallId,
|
||||
args: {},
|
||||
},
|
||||
})
|
||||
Bus.publish(Message.Event.PartUpdated, {
|
||||
part: next.parts[next.parts.length - 1],
|
||||
messageID: next.id,
|
||||
sessionID: next.metadata.sessionID,
|
||||
})
|
||||
break
|
||||
|
||||
case "tool-call-delta":
|
||||
continue
|
||||
|
||||
// for some reason ai sdk claims to not send this part but it does
|
||||
// @ts-expect-error
|
||||
case "tool-result":
|
||||
const match = next.parts.find(
|
||||
(p) =>
|
||||
p.type === "tool-invocation" &&
|
||||
// @ts-expect-error
|
||||
p.toolInvocation.toolCallId === value.toolCallId,
|
||||
)
|
||||
if (match && match.type === "tool-invocation") {
|
||||
match.toolInvocation = {
|
||||
// @ts-expect-error
|
||||
args: value.args,
|
||||
// @ts-expect-error
|
||||
toolCallId: value.toolCallId,
|
||||
// @ts-expect-error
|
||||
toolName: value.toolName,
|
||||
state: "result",
|
||||
// @ts-expect-error
|
||||
result: value.result as string,
|
||||
}
|
||||
Bus.publish(Message.Event.PartUpdated, {
|
||||
part: match,
|
||||
messageID: next.id,
|
||||
sessionID: next.metadata.sessionID,
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
l.info("unhandled", {
|
||||
type: value.type,
|
||||
})
|
||||
continue
|
||||
}
|
||||
await updateMessage(next)
|
||||
}
|
||||
await updateMessage(next)
|
||||
} catch (e: any) {
|
||||
log.error("stream error", {
|
||||
error: e,
|
||||
})
|
||||
switch (true) {
|
||||
case LoadAPIKeyError.isInstance(e):
|
||||
next.metadata.error = new Provider.AuthError(
|
||||
{
|
||||
providerID: input.providerID,
|
||||
message: e.message,
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
break
|
||||
case e instanceof Error:
|
||||
next.metadata.error = new NamedError.Unknown(
|
||||
{ message: e.toString() },
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
break
|
||||
default:
|
||||
next.metadata.error = new NamedError.Unknown(
|
||||
{ message: JSON.stringify(e) },
|
||||
{ cause: e },
|
||||
)
|
||||
}
|
||||
Bus.publish(Event.Error, {
|
||||
error: next.metadata.error,
|
||||
})
|
||||
}
|
||||
next.metadata!.time.completed = Date.now()
|
||||
for (const part of next.parts) {
|
||||
@@ -608,6 +706,7 @@ export namespace Session {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
time: {
|
||||
@@ -643,37 +742,56 @@ export namespace Session {
|
||||
text: result.text,
|
||||
})
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(result.usage, model.info)
|
||||
const usage = getUsage(model.info, result.usage, result.providerMetadata)
|
||||
assistant.cost = usage.cost
|
||||
assistant.tokens = usage.tokens
|
||||
await updateMessage(next)
|
||||
}
|
||||
|
||||
const pending = new Map<string, AbortController>()
|
||||
function lock(sessionID: string) {
|
||||
log.info("locking", { sessionID })
|
||||
if (pending.has(sessionID)) throw new BusyError(sessionID)
|
||||
if (state().pending.has(sessionID)) throw new BusyError(sessionID)
|
||||
const controller = new AbortController()
|
||||
pending.set(sessionID, controller)
|
||||
state().pending.set(sessionID, controller)
|
||||
return {
|
||||
signal: controller.signal,
|
||||
[Symbol.dispose]() {
|
||||
log.info("unlocking", { sessionID })
|
||||
pending.delete(sessionID)
|
||||
state().pending.delete(sessionID)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getUsage(usage: LanguageModelUsage, model: ModelsDev.Model) {
|
||||
function getUsage(
|
||||
model: ModelsDev.Model,
|
||||
usage: LanguageModelUsage,
|
||||
metadata?: ProviderMetadata,
|
||||
) {
|
||||
const tokens = {
|
||||
input: usage.promptTokens ?? 0,
|
||||
output: usage.completionTokens ?? 0,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||
0) as number,
|
||||
read: (metadata?.["anthropic"]?.["cacheReadInputTokens"] ??
|
||||
0) as number,
|
||||
},
|
||||
}
|
||||
return {
|
||||
cost: new Decimal(0)
|
||||
.add(new Decimal(tokens.input).mul(model.cost.input).div(1_000_000))
|
||||
.add(new Decimal(tokens.output).mul(model.cost.output).div(1_000_000))
|
||||
.add(
|
||||
new Decimal(tokens.cache.read)
|
||||
.mul(model.cost.cache_read ?? 0)
|
||||
.div(1_000_000),
|
||||
)
|
||||
.add(
|
||||
new Decimal(tokens.cache.write)
|
||||
.mul(model.cost.cache_write ?? 0)
|
||||
.div(1_000_000),
|
||||
)
|
||||
.toNumber(),
|
||||
tokens,
|
||||
}
|
||||
|
||||
@@ -174,6 +174,10 @@ export namespace Message {
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
reasoning: z.number(),
|
||||
cache: z.object({
|
||||
read: z.number(),
|
||||
write: z.number(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
you will generate a short title based on the first message a user begins a conversation with
|
||||
You will generate a short title based on the first message a user begins a conversation with
|
||||
- ensure it is not more than 50 characters long
|
||||
- the title should be a summary of the user's message
|
||||
- it should be one line long
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { App } from "../app/app"
|
||||
import { ListTool } from "../tool/ls"
|
||||
import { Ripgrep } from "../external/ripgrep"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
||||
@@ -22,8 +22,57 @@ export namespace SystemPrompt {
|
||||
return result
|
||||
}
|
||||
|
||||
export async function environment(sessionID: string) {
|
||||
export async function environment() {
|
||||
const app = App.info()
|
||||
|
||||
const tree = async () => {
|
||||
const files = await Ripgrep.files({
|
||||
cwd: app.path.cwd,
|
||||
})
|
||||
type Node = {
|
||||
children: Record<string, Node>
|
||||
}
|
||||
const root: Node = {
|
||||
children: {},
|
||||
}
|
||||
for (const file of files) {
|
||||
const parts = file.split("/")
|
||||
let node = root
|
||||
for (const part of parts) {
|
||||
const existing = node.children[part]
|
||||
if (existing) {
|
||||
node = existing
|
||||
continue
|
||||
}
|
||||
node.children[part] = {
|
||||
children: {},
|
||||
}
|
||||
node = node.children[part]
|
||||
}
|
||||
}
|
||||
|
||||
function render(path: string[], node: Node): string {
|
||||
// if (path.length === 3) return "\t".repeat(path.length) + "..."
|
||||
const lines: string[] = []
|
||||
const entries = Object.entries(node.children).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)
|
||||
|
||||
for (const [name, child] of entries) {
|
||||
const currentPath = [...path, name]
|
||||
const indent = "\t".repeat(path.length)
|
||||
const hasChildren = Object.keys(child.children).length > 0
|
||||
lines.push(`${indent}${name}` + (hasChildren ? "/" : ""))
|
||||
|
||||
if (hasChildren) lines.push(render(currentPath, child))
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
const result = render([], root)
|
||||
return result
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
`Here is some useful information about the environment you are running in:`,
|
||||
@@ -34,7 +83,7 @@ export namespace SystemPrompt {
|
||||
` Today's date: ${new Date().toDateString()}`,
|
||||
`</env>`,
|
||||
`<project>`,
|
||||
` ${app.git ? await ListTool.execute({ path: app.path.cwd, ignore: [] }, { sessionID: sessionID, messageID: "", abort: AbortSignal.any([]) }).then((x) => x.output) : ""}`,
|
||||
` ${app.git ? await tree() : ""}`,
|
||||
`</project>`,
|
||||
].join("\n"),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
import { Installation } from "../installation"
|
||||
import { Session } from "../session"
|
||||
import { Storage } from "../storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
@@ -19,10 +20,11 @@ export namespace Share {
|
||||
export async function sync(key: string, content: any) {
|
||||
const [root, ...splits] = key.split("/")
|
||||
if (root !== "session") return
|
||||
const [, sessionID] = splits
|
||||
const session = await Session.get(sessionID)
|
||||
if (!session.share) return
|
||||
const { secret } = session.share
|
||||
const [sub, sessionID] = splits
|
||||
if (sub === "share") return
|
||||
const share = await Session.getShare(sessionID).catch(() => {})
|
||||
if (!share) return
|
||||
const { secret } = share
|
||||
pending.set(key, content)
|
||||
queue = queue
|
||||
.then(async () => {
|
||||
@@ -55,7 +57,10 @@ export namespace Share {
|
||||
}
|
||||
|
||||
export const URL =
|
||||
process.env["OPENCODE_API"] ?? "https://api.dev.opencode.ai"
|
||||
process.env["OPENCODE_API"] ??
|
||||
(Installation.isSnapshot() || Installation.isDev()
|
||||
? "https://api.dev.opencode.ai"
|
||||
: "https://api.opencode.ai")
|
||||
|
||||
export async function create(sessionID: string) {
|
||||
return fetch(`${URL}/share_create`, {
|
||||
|
||||
@@ -24,8 +24,6 @@ export namespace Storage {
|
||||
}
|
||||
})
|
||||
|
||||
const locks = new Map<string, Promise<void>>()
|
||||
|
||||
export async function readJSON<T>(key: string) {
|
||||
return Bun.file(path.join(state().dir, key + ".json")).json() as Promise<T>
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const DEFAULT_TIMEOUT = 1 * 60 * 1000
|
||||
const MAX_TIMEOUT = 10 * 60 * 1000
|
||||
|
||||
export const BashTool = Tool.define({
|
||||
id: "opencode.bash",
|
||||
id: "bash",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
command: z.string().describe("The command to execute"),
|
||||
@@ -35,7 +35,7 @@ export const BashTool = Tool.define({
|
||||
.min(0)
|
||||
.max(MAX_TIMEOUT)
|
||||
.describe("Optional timeout in milliseconds")
|
||||
.nullable(),
|
||||
.optional(),
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
|
||||
@@ -9,7 +9,7 @@ import DESCRIPTION from "./edit.txt"
|
||||
import { App } from "../app/app"
|
||||
|
||||
export const EditTool = Tool.define({
|
||||
id: "opencode.edit",
|
||||
id: "edit",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
@@ -21,7 +21,7 @@ export const EditTool = Tool.define({
|
||||
),
|
||||
replaceAll: z
|
||||
.boolean()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Replace all occurences of old_string (default false)"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
@@ -35,7 +35,7 @@ export const EditTool = Tool.define({
|
||||
: path.join(app.path.cwd, params.filePath)
|
||||
|
||||
await Permission.ask({
|
||||
id: "opencode.edit",
|
||||
id: "edit",
|
||||
sessionID: ctx.sessionID,
|
||||
title: "Edit this file: " + filepath,
|
||||
metadata: {
|
||||
@@ -87,7 +87,9 @@ export const EditTool = Tool.define({
|
||||
await file.write(contentNew)
|
||||
})()
|
||||
|
||||
const diff = createTwoFilesPatch(filepath, filepath, contentOld, contentNew)
|
||||
const diff = trimDiff(
|
||||
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
|
||||
)
|
||||
|
||||
FileTimes.read(ctx.sessionID, filepath)
|
||||
|
||||
@@ -113,3 +115,39 @@ export const EditTool = Tool.define({
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function trimDiff(diff: string): string {
|
||||
const lines = diff.split("\n")
|
||||
const contentLines = lines.filter(
|
||||
(line) =>
|
||||
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
|
||||
!line.startsWith("---") &&
|
||||
!line.startsWith("+++"),
|
||||
)
|
||||
|
||||
if (contentLines.length === 0) return diff
|
||||
|
||||
let min = Infinity
|
||||
for (const line of contentLines) {
|
||||
const content = line.slice(1)
|
||||
if (content.trim().length > 0) {
|
||||
const match = content.match(/^(\s*)/)
|
||||
if (match) min = Math.min(min, match[1].length)
|
||||
}
|
||||
}
|
||||
if (min === Infinity || min === 0) return diff
|
||||
const trimmedLines = lines.map((line) => {
|
||||
if (
|
||||
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
|
||||
!line.startsWith("---") &&
|
||||
!line.startsWith("+++")
|
||||
) {
|
||||
const prefix = line[0]
|
||||
const content = line.slice(1)
|
||||
return prefix + content.slice(min)
|
||||
}
|
||||
return line
|
||||
})
|
||||
|
||||
return trimmedLines.join("\n")
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import { App } from "../app/app"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
|
||||
export const GlobTool = Tool.define({
|
||||
id: "opencode.glob",
|
||||
id: "glob",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z.string().describe("The glob pattern to match files against"),
|
||||
path: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
|
||||
),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Ripgrep } from "../external/ripgrep"
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
|
||||
export const GrepTool = Tool.define({
|
||||
id: "opencode.grep",
|
||||
id: "grep",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z
|
||||
@@ -14,13 +14,13 @@ export const GrepTool = Tool.define({
|
||||
.describe("The regex pattern to search for in file contents"),
|
||||
path: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"The directory to search in. Defaults to the current working directory.",
|
||||
),
|
||||
include: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
|
||||
),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { App } from "../app/app"
|
||||
import * as path from "path"
|
||||
import DESCRIPTION from "./ls.txt"
|
||||
|
||||
const IGNORE_PATTERNS = [
|
||||
export const IGNORE_PATTERNS = [
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
".git/",
|
||||
@@ -18,8 +18,10 @@ const IGNORE_PATTERNS = [
|
||||
".vscode/",
|
||||
]
|
||||
|
||||
const LIMIT = 100
|
||||
|
||||
export const ListTool = Tool.define({
|
||||
id: "opencode.list",
|
||||
id: "list",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
path: z
|
||||
@@ -27,11 +29,11 @@ export const ListTool = Tool.define({
|
||||
.describe(
|
||||
"The absolute path to the directory to list (must be absolute, not relative)",
|
||||
)
|
||||
.nullable(),
|
||||
.optional(),
|
||||
ignore: z
|
||||
.array(z.string())
|
||||
.describe("List of glob patterns to ignore")
|
||||
.nullable(),
|
||||
.optional(),
|
||||
}),
|
||||
async execute(params) {
|
||||
const app = App.info()
|
||||
@@ -41,12 +43,11 @@ export const ListTool = Tool.define({
|
||||
const files = []
|
||||
|
||||
for await (const file of glob.scan({ cwd: searchPath, dot: true })) {
|
||||
if (file.startsWith(".") || IGNORE_PATTERNS.some((p) => file.includes(p)))
|
||||
continue
|
||||
if (IGNORE_PATTERNS.some((p) => file.includes(p))) continue
|
||||
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
|
||||
continue
|
||||
files.push(file)
|
||||
if (files.length >= 1000) break
|
||||
if (files.length >= LIMIT) break
|
||||
}
|
||||
|
||||
// Build directory structure
|
||||
@@ -100,7 +101,7 @@ export const ListTool = Tool.define({
|
||||
return {
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated: files.length >= 1000,
|
||||
truncated: files.length >= LIMIT,
|
||||
title: path.relative(app.path.root, searchPath),
|
||||
},
|
||||
output,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { App } from "../app/app"
|
||||
import DESCRIPTION from "./lsp-diagnostics.txt"
|
||||
|
||||
export const LspDiagnosticTool = Tool.define({
|
||||
id: "opencode.lsp_diagnostics",
|
||||
id: "lsp_diagnostics",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
path: z.string().describe("The path to the file to get diagnostics."),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { App } from "../app/app"
|
||||
import DESCRIPTION from "./lsp-hover.txt"
|
||||
|
||||
export const LspHoverTool = Tool.define({
|
||||
id: "opencode.lsp_hover",
|
||||
id: "lsp_hover",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
file: z.string().describe("The path to the file to get diagnostics."),
|
||||
|
||||
@@ -6,7 +6,7 @@ import path from "path"
|
||||
import { App } from "../app/app"
|
||||
|
||||
export const MultiEditTool = Tool.define({
|
||||
id: "opencode.multiedit",
|
||||
id: "multiedit",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as fs from "fs/promises"
|
||||
import { Tool } from "./tool"
|
||||
import { FileTimes } from "./util/file-times"
|
||||
import DESCRIPTION from "./patch.txt"
|
||||
import { App } from "../app/app"
|
||||
|
||||
const PatchParams = z.object({
|
||||
patchText: z
|
||||
@@ -233,7 +232,7 @@ async function applyCommit(
|
||||
}
|
||||
|
||||
export const PatchTool = Tool.define({
|
||||
id: "opencode.patch",
|
||||
id: "patch",
|
||||
description: DESCRIPTION,
|
||||
parameters: PatchParams,
|
||||
execute: async (params, ctx) => {
|
||||
|
||||
@@ -12,18 +12,18 @@ const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
export const ReadTool = Tool.define({
|
||||
id: "opencode.read",
|
||||
id: "read",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The path to the file to read"),
|
||||
offset: z
|
||||
.number()
|
||||
.describe("The line number to start reading from (0-based)")
|
||||
.nullable(),
|
||||
.optional(),
|
||||
limit: z
|
||||
.number()
|
||||
.describe("The number of lines to read (defaults to 2000)")
|
||||
.nullable(),
|
||||
.optional(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
let filePath = params.filePath
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./task.txt"
|
||||
import { z } from "zod"
|
||||
import { Session } from "../session"
|
||||
import { Bus } from "../bus"
|
||||
import { Message } from "../session/message"
|
||||
|
||||
export const TaskTool = Tool.define({
|
||||
id: "opencode.task",
|
||||
id: "task",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
description: z
|
||||
@@ -17,6 +19,31 @@ export const TaskTool = Tool.define({
|
||||
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
|
||||
const metadata = msg.metadata.assistant!
|
||||
|
||||
function summary(input: Message.Info) {
|
||||
const result = []
|
||||
|
||||
for (const part of input.parts) {
|
||||
if (part.type === "tool-invocation") {
|
||||
result.push({
|
||||
toolInvocation: part.toolInvocation,
|
||||
metadata: input.metadata.tool[part.toolInvocation.toolCallId],
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const unsub = Bus.subscribe(Message.Event.Updated, async (evt) => {
|
||||
if (evt.properties.info.metadata.sessionID !== session.id) return
|
||||
ctx.metadata({
|
||||
title: params.description,
|
||||
summary: summary(evt.properties.info),
|
||||
})
|
||||
})
|
||||
|
||||
ctx.abort.addEventListener("abort", () => {
|
||||
Session.abort(session.id)
|
||||
})
|
||||
const result = await Session.chat({
|
||||
sessionID: session.id,
|
||||
modelID: metadata.modelID,
|
||||
@@ -28,10 +55,11 @@ export const TaskTool = Tool.define({
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
unsub()
|
||||
return {
|
||||
metadata: {
|
||||
title: params.description,
|
||||
summary: summary(result),
|
||||
},
|
||||
output: result.parts.findLast((x) => x.type === "text")!.text,
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const state = App.state("todo-tool", () => {
|
||||
})
|
||||
|
||||
export const TodoWriteTool = Tool.define({
|
||||
id: "opencode.todowrite",
|
||||
id: "todowrite",
|
||||
description: DESCRIPTION_WRITE,
|
||||
parameters: z.object({
|
||||
todos: z.array(TodoInfo).describe("The updated todo list"),
|
||||
@@ -42,7 +42,7 @@ export const TodoWriteTool = Tool.define({
|
||||
})
|
||||
|
||||
export const TodoReadTool = Tool.define({
|
||||
id: "opencode.todoread",
|
||||
id: "todoread",
|
||||
description: "Use this tool to read your todo list",
|
||||
parameters: z.object({}),
|
||||
async execute(_params, opts) {
|
||||
|
||||
@@ -5,10 +5,11 @@ export namespace Tool {
|
||||
title: string
|
||||
[key: string]: any
|
||||
}
|
||||
export type Context = {
|
||||
export type Context<M extends Metadata = Metadata> = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
abort: AbortSignal
|
||||
metadata(meta: M): void
|
||||
}
|
||||
export interface Info<
|
||||
Parameters extends StandardSchemaV1 = StandardSchemaV1,
|
||||
|
||||
@@ -8,7 +8,7 @@ const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
|
||||
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
|
||||
|
||||
export const WebFetchTool = Tool.define({
|
||||
id: "opencode.webfetch",
|
||||
id: "webfetch",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
url: z.string().describe("The URL to fetch content from"),
|
||||
@@ -22,7 +22,7 @@ export const WebFetchTool = Tool.define({
|
||||
.min(0)
|
||||
.max(MAX_TIMEOUT / 1000)
|
||||
.describe("Optional timeout in seconds (max 120)")
|
||||
.nullable(),
|
||||
.optional(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
// Validate URL
|
||||
@@ -76,7 +76,7 @@ export const WebFetchTool = Tool.define({
|
||||
switch (params.format) {
|
||||
case "text":
|
||||
if (contentType.includes("text/html")) {
|
||||
const text = extractTextFromHTML(content)
|
||||
const text = await extractTextFromHTML(content)
|
||||
return {
|
||||
output: text,
|
||||
metadata: {
|
||||
@@ -127,10 +127,45 @@ export const WebFetchTool = Tool.define({
|
||||
},
|
||||
})
|
||||
|
||||
function extractTextFromHTML(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html")
|
||||
const text = doc.body.textContent || doc.body.innerText || ""
|
||||
return text.replace(/\s+/g, " ").trim()
|
||||
async function extractTextFromHTML(html: string) {
|
||||
let text = ""
|
||||
let skipContent = false
|
||||
|
||||
const rewriter = new HTMLRewriter()
|
||||
.on("script, style, noscript, iframe, object, embed", {
|
||||
element() {
|
||||
skipContent = true
|
||||
},
|
||||
text() {
|
||||
// Skip text content inside these elements
|
||||
},
|
||||
})
|
||||
.on("*", {
|
||||
element(element) {
|
||||
// Reset skip flag when entering other elements
|
||||
if (
|
||||
![
|
||||
"script",
|
||||
"style",
|
||||
"noscript",
|
||||
"iframe",
|
||||
"object",
|
||||
"embed",
|
||||
].includes(element.tagName)
|
||||
) {
|
||||
skipContent = false
|
||||
}
|
||||
},
|
||||
text(input) {
|
||||
if (!skipContent) {
|
||||
text += input.text
|
||||
}
|
||||
},
|
||||
})
|
||||
.transform(new Response(html))
|
||||
|
||||
await rewriter.text()
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
function convertHTMLToMarkdown(html: string): string {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
- Allows OpenCode to search the web and use the results to inform responses
|
||||
- Allows opencode to search the web and use the results to inform responses
|
||||
- Provides up-to-date information for current events and recent data
|
||||
- Returns search result information formatted as search result blocks
|
||||
- Use this tool for accessing information beyond Claude's knowledge cutoff
|
||||
|
||||
@@ -8,7 +8,7 @@ import DESCRIPTION from "./write.txt"
|
||||
import { App } from "../app/app"
|
||||
|
||||
export const WriteTool = Tool.define({
|
||||
id: "opencode.write",
|
||||
id: "write",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z
|
||||
@@ -29,7 +29,7 @@ export const WriteTool = Tool.define({
|
||||
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
|
||||
|
||||
await Permission.ask({
|
||||
id: "opencode.write",
|
||||
id: "write",
|
||||
sessionID: ctx.sessionID,
|
||||
title: exists
|
||||
? "Overwrite this file: " + filepath
|
||||
|
||||
@@ -11,15 +11,16 @@ export abstract class NamedError extends Error {
|
||||
name: Name,
|
||||
data: Data,
|
||||
) {
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.literal(name),
|
||||
data,
|
||||
})
|
||||
.openapi({
|
||||
ref: name,
|
||||
})
|
||||
const result = class extends NamedError {
|
||||
public static readonly Schema = z
|
||||
.object({
|
||||
name: z.literal(name),
|
||||
data: data,
|
||||
})
|
||||
.openapi({
|
||||
ref: name,
|
||||
})
|
||||
public static readonly Schema = schema
|
||||
|
||||
public readonly name = name as Name
|
||||
|
||||
@@ -40,7 +41,7 @@ export abstract class NamedError extends Error {
|
||||
}
|
||||
|
||||
schema() {
|
||||
return result.Schema
|
||||
return schema
|
||||
}
|
||||
|
||||
toObject() {
|
||||
|
||||
@@ -83,6 +83,23 @@ export namespace Log {
|
||||
clone() {
|
||||
return Log.create({ ...tags })
|
||||
},
|
||||
time(message: string, extra?: Record<string, any>) {
|
||||
const now = Date.now()
|
||||
result.info(message, { status: "started", ...extra })
|
||||
function stop() {
|
||||
result.info(message, {
|
||||
status: "completed",
|
||||
duration: Date.now() - now,
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
return {
|
||||
stop,
|
||||
[Symbol.dispose]() {
|
||||
stop()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -3,21 +3,33 @@ import { App } from "../../src/app/app"
|
||||
import { GlobTool } from "../../src/tool/glob"
|
||||
import { ListTool } from "../../src/tool/ls"
|
||||
|
||||
const ctx = {
|
||||
sessionID: "test",
|
||||
messageID: "",
|
||||
abort: AbortSignal.any([]),
|
||||
metadata: () => {},
|
||||
}
|
||||
describe("tool.glob", () => {
|
||||
test("truncate", async () => {
|
||||
await App.provide({ cwd: process.cwd(), version: "test" }, async () => {
|
||||
await App.provide({ cwd: process.cwd() }, async () => {
|
||||
let result = await GlobTool.execute(
|
||||
{ pattern: "./node_modules/**/*" },
|
||||
{ sessionID: "test" },
|
||||
{
|
||||
pattern: "./node_modules/**/*",
|
||||
path: undefined,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.truncated).toBe(true)
|
||||
})
|
||||
})
|
||||
test("basic", async () => {
|
||||
await App.provide({ cwd: process.cwd(), version: "test" }, async () => {
|
||||
await App.provide({ cwd: process.cwd() }, async () => {
|
||||
let result = await GlobTool.execute(
|
||||
{ pattern: "*.json" },
|
||||
{ sessionID: "test" },
|
||||
{
|
||||
pattern: "*.json",
|
||||
path: undefined,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata).toMatchObject({
|
||||
truncated: false,
|
||||
@@ -29,15 +41,12 @@ describe("tool.glob", () => {
|
||||
|
||||
describe("tool.ls", () => {
|
||||
test("basic", async () => {
|
||||
const result = await App.provide(
|
||||
{ cwd: process.cwd(), version: "test" },
|
||||
async () => {
|
||||
return await ListTool.execute(
|
||||
{ path: "./example" },
|
||||
{ sessionID: "test" },
|
||||
)
|
||||
},
|
||||
)
|
||||
const result = await App.provide({ cwd: process.cwd() }, async () => {
|
||||
return await ListTool.execute(
|
||||
{ path: "./example", ignore: [".git"] },
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
expect(result.output).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
time=2025-05-30T22:01:45.386-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-30T22:01:45.391-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-30T22:01:50.683-04:00 level=INFO msg="TUI exited" result="{width:98 height:57 currentPage:chat previousPage: pages:map[chat:0xc00013b450] loadedPages:map[chat:true] status:{app:0xc0002e05b0 queue:[] width:98 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002e05b0 showPermissions:false permissions:0xc000159408 showHelp:false help:0xc0006822d0 showQuit:true quit:0xc00024b479 showSessionDialog:false sessionDialog:0xc0001f0240 showCommandDialog:false commandDialog:0xc0003cbba0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0001f45a0 showInitDialog:false initDialog:{width:98 height:57 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0001f0480 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0001f04c0}"
|
||||
time=2025-05-30T22:13:24.046-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-30T22:13:24.051-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-30T22:13:25.991-04:00 level=INFO msg="TUI exited" result="{width:199 height:57 currentPage:chat previousPage: pages:map[chat:0xc00025f950] loadedPages:map[chat:true] status:{app:0xc0000ca230 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0000ca230 showPermissions:false permissions:0xc00029f908 showHelp:false help:0xc00045d9b0 showQuit:true quit:0xc0005a0be9 showSessionDialog:false sessionDialog:0xc00012e3c0 showCommandDialog:false commandDialog:0xc0004379e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0002f2e60 showInitDialog:false initDialog:{width:199 height:57 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00012e600 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00012e640}"
|
||||
time=2025-05-31T16:00:29.137-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T16:00:29.141-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T16:00:36.530-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T16:00:36.531-04:00 level=INFO msg="TUI exited" result="{width:106 height:54 currentPage:chat previousPage: pages:map[chat:0xc000157450] loadedPages:map[chat:true] status:{app:0xc00020c5b0 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc00020c5b0 showPermissions:false permissions:0xc000175408 showHelp:false help:0xc00070c270 showQuit:true quit:0xc000299979 showSessionDialog:false sessionDialog:0xc0001f02c0 showCommandDialog:false commandDialog:0xc0003cbba0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc00021a5a0 showInitDialog:false initDialog:{width:106 height:54 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0001f0500 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0001f0540}"
|
||||
time=2025-05-31T16:06:20.089-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T16:06:20.094-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T16:06:20.095-04:00 level=ERROR msg="Failed to subscribe to events" error="Get \"http://localhost:16713/event\": dial tcp [::1]:16713: connect: connection refused"
|
||||
time=2025-05-31T17:54:04.009-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T17:54:04.014-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T17:54:06.337-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc0002332c0] loadedPages:map[chat:true] status:{app:0xc0002b1810 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002b1810 showPermissions:false permissions:0xc000267408 showHelp:false help:0xc00048dbc0 showQuit:true quit:0xc0004a2719 showSessionDialog:false sessionDialog:0xc000319ec0 showCommandDialog:false commandDialog:0xc000387980 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0000c6960 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc0000ac480 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac4c0}"
|
||||
time=2025-05-31T17:54:17.103-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T17:54:17.108-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T17:54:18.391-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T17:54:18.392-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc00042a960] loadedPages:map[chat:true] status:{app:0xc000163ce0 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000163ce0 showPermissions:false permissions:0xc0001df408 showHelp:false help:0xc0005198f0 showQuit:true quit:0xc0003a5ef9 showSessionDialog:false sessionDialog:0xc000323840 showCommandDialog:false commandDialog:0xc00043b0e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0004028c0 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc000323a80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000323ac0}"
|
||||
time=2025-05-31T17:59:54.360-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T17:59:54.364-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T17:59:55.814-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T17:59:55.815-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc0002787d0] loadedPages:map[chat:true] status:{app:0xc0003fed90 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0003fed90 showPermissions:false permissions:0xc0002b1908 showHelp:false help:0xc000126150 showQuit:true quit:0xc00011d439 showSessionDialog:false sessionDialog:0xc00025e380 showCommandDialog:false commandDialog:0xc00047fc00 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc0002f6d20 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0001b2c88 showThemeDialog:false themeDialog:0xc00025e5c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00025e600}"
|
||||
time=2025-05-31T17:59:56.746-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T17:59:56.750-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T18:00:10.757-04:00 level=INFO msg="TUI exited" result="{width:211 height:54 currentPage:chat previousPage: pages:map[chat:0xc00053b090] loadedPages:map[chat:true] status:{app:0xc000300cb0 queue:[] width:211 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000300cb0 showPermissions:false permissions:0xc0002c5408 showHelp:false help:0xc000682f90 showQuit:true quit:0xc0006134d9 showSessionDialog:false sessionDialog:0xc00031f980 showCommandDialog:false commandDialog:0xc0003d9520 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc000395220 showInitDialog:false initDialog:{width:211 height:54 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00031fbc0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00031fc00}"
|
||||
time=2025-05-31T18:35:42.289-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T18:35:42.294-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T18:36:54.773-04:00 level=INFO msg="TUI exited" result="{width:106 height:25 currentPage:chat previousPage: pages:map[chat:0xc00012f0e0] loadedPages:map[chat:true] status:{app:0xc0002aa070 queue:[] width:106 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002aa070 showPermissions:false permissions:0xc000267408 showHelp:false help:0xc00041b8f0 showQuit:true quit:0xc000345ee9 showSessionDialog:false sessionDialog:0xc00032ba40 showCommandDialog:false commandDialog:0xc00043b300 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc000426f00 showInitDialog:false initDialog:{width:106 height:25 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc00032bc80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00032bcc0}"
|
||||
time=2025-05-31T18:36:56.011-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T18:36:56.015-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T18:37:44.063-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T18:37:44.064-04:00 level=INFO msg="TUI exited" result="{width:211 height:54 currentPage:chat previousPage: pages:map[chat:0xc000420280] loadedPages:map[chat:true] status:{app:0xc0002d8000 queue:[] width:211 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002d8000 showPermissions:false permissions:0xc000271408 showHelp:false help:0xc00048da70 showQuit:true quit:0xc000390809 showSessionDialog:false sessionDialog:0xc000323b80 showCommandDialog:false commandDialog:0xc0003e5920 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a7a0} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a620}] showModelDialog:false modelDialog:0xc00025f9a0 showInitDialog:false initDialog:{width:211 height:54 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d6c88 showThemeDialog:false themeDialog:0xc000323dc0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000323e00}"
|
||||
time=2025-05-31T20:32:32.443-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-05-31T20:32:32.448-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-05-31T20:33:09.783-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc00032c960] loadedPages:map[chat:true] status:{app:0xc000279420 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000279420 showPermissions:false permissions:0xc0001fb408 showHelp:false help:0xc000154150 showQuit:true quit:0xc000528849 showSessionDialog:false sessionDialog:0xc000309e40 showCommandDialog:false commandDialog:0xc0003a3800 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc0002e7cc0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac400 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac440}"
|
||||
time=2025-06-01T14:37:36.423-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T14:37:36.427-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T14:38:19.951-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc00035b9f0] loadedPages:map[chat:true] status:{app:0xc000226d90 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000226d90 showPermissions:false permissions:0xc00027f908 showHelp:false help:0xc0005139e0 showQuit:true quit:0xc000510d49 showSessionDialog:false sessionDialog:0xc0001e84c0 showCommandDialog:false commandDialog:0xc00051a160 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc0002675e0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc0001e8700 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0001e8740}"
|
||||
time=2025-06-01T14:38:50.886-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T14:38:50.891-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T14:38:53.495-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0005ac8c0] loadedPages:map[chat:true] status:{app:0xc0002796c0 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002796c0 showPermissions:false permissions:0xc00028b408 showHelp:false help:0xc000490d80 showQuit:true quit:0xc000582589 showSessionDialog:false sessionDialog:0xc0003359c0 showCommandDialog:false commandDialog:0xc00042d480 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc000389360 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000335c00 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000335c80}"
|
||||
time=2025-06-01T14:39:49.852-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T14:39:49.856-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T14:39:57.071-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc000616f00] loadedPages:map[chat:true] status:{app:0xc000333490 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000333490 showPermissions:false permissions:0xc0004faa08 showHelp:false help:0xc000471140 showQuit:true quit:0xc000459299 showSessionDialog:false sessionDialog:0xc000352500 showCommandDialog:false commandDialog:0xc00041ed80 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc000515a40 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc000352740 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000352780}"
|
||||
time=2025-06-01T14:40:21.954-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T14:40:21.958-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T14:41:29.195-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0002cc280] loadedPages:map[chat:true] status:{app:0xc0002e64d0 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002e64d0 showPermissions:false permissions:0xc00026f408 showHelp:false help:0xc00051c1b0 showQuit:true quit:0xc00051a819 showSessionDialog:false sessionDialog:0xc00030fec0 showCommandDialog:false commandDialog:0xc00042d760 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc0002ce1e0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac480 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac4c0}"
|
||||
time=2025-06-01T14:58:27.272-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T14:58:27.276-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T14:58:59.711-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc000316280] loadedPages:map[chat:true] status:{app:0xc0002b5810 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002b5810 showPermissions:false permissions:0xc000269408 showHelp:false help:0xc000490e10 showQuit:true quit:0xc00047a929 showSessionDialog:false sessionDialog:0xc0000adb40 showCommandDialog:false commandDialog:0xc0003e59c0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6a800} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6a680}] showModelDialog:false modelDialog:0xc00024fd60 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000add80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000addc0}"
|
||||
time=2025-06-01T15:02:54.453-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T15:02:54.458-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T15:02:56.136-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc000392ff0] loadedPages:map[chat:true] status:{app:0xc0001ecc40 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0001ecc40 showPermissions:false permissions:0xc000205408 showHelp:false help:0xc00051c0c0 showQuit:true quit:0xc0003b3f49 showSessionDialog:false sessionDialog:0xc000319980 showCommandDialog:false commandDialog:0xc00042d220 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0005c52c0 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000319bc0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000319c00}"
|
||||
time=2025-06-01T15:02:57.053-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T15:02:57.057-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T15:02:58.135-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0004411d0] loadedPages:map[chat:true] status:{app:0xc00023ee70 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc00023ee70 showPermissions:false permissions:0xc000177408 showHelp:false help:0xc000520030 showQuit:true quit:0xc000314929 showSessionDialog:false sessionDialog:0xc000319d00 showCommandDialog:false commandDialog:0xc0003e5860 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0002c9a40 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000319f40 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac000}"
|
||||
time=2025-06-01T15:15:13.582-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T15:15:13.587-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T15:15:19.009-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T15:15:19.010-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0001490e0] loadedPages:map[chat:true] status:{app:0xc0001efb90 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0001efb90 showPermissions:false permissions:0xc000167408 showHelp:false help:0xc00052c1b0 showQuit:true quit:0xc000254629 showSessionDialog:false sessionDialog:0xc00030fe80 showCommandDialog:false commandDialog:0xc0003a3420 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0000c6640 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac440 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac480}"
|
||||
time=2025-06-01T15:15:20.678-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-01T15:15:20.683-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-01T15:15:23.252-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-01T15:15:23.253-04:00 level=INFO msg="TUI exited" result="{width:199 height:56 currentPage:chat previousPage: pages:map[chat:0xc0002c47d0] loadedPages:map[chat:true] status:{app:0xc0003363f0 queue:[] width:199 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0003363f0 showPermissions:false permissions:0xc0002f3408 showHelp:false help:0xc0007055f0 showQuit:true quit:0xc00041c9b9 showSessionDialog:false sessionDialog:0xc00033bd00 showCommandDialog:false commandDialog:0xc000437700 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0003d9c20 showInitDialog:false initDialog:{width:199 height:56 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00033bf40 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00034a140}"
|
||||
time=2025-06-02T11:40:21.643-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T11:40:21.648-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T11:40:24.684-04:00 level=INFO msg="TUI exited" result="{width:347 height:89 currentPage:chat previousPage: pages:map[chat:0xc00020d180] loadedPages:map[chat:true] status:{app:0xc0002a8230 queue:[] width:347 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002a8230 showPermissions:false permissions:0xc000239408 showHelp:false help:0xc00051c1e0 showQuit:true quit:0xc000598909 showSessionDialog:false sessionDialog:0xc000309f00 showCommandDialog:false commandDialog:0xc0003a3660 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0000c4a00 showInitDialog:false initDialog:{width:347 height:89 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000aa4c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000aa500}"
|
||||
time=2025-06-02T11:40:55.224-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T11:40:55.228-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T11:41:03.512-04:00 level=INFO msg="TUI exited" result="{width:347 height:89 currentPage:chat previousPage: pages:map[chat:0xc0001467d0] loadedPages:map[chat:true] status:{app:0xc0004feee0 queue:[] width:347 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0004feee0 showPermissions:false permissions:0xc000167408 showHelp:false help:0xc00059cd50 showQuit:true quit:0xc00038aaa9 showSessionDialog:false sessionDialog:0xc00030ff00 showCommandDialog:false commandDialog:0xc0003e5aa0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc00029be00 showInitDialog:false initDialog:{width:347 height:89 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000aa4c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000aa500}"
|
||||
time=2025-06-02T11:41:05.131-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T11:41:05.136-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T11:41:06.417-04:00 level=INFO msg="TUI exited" result="{width:347 height:89 currentPage:chat previousPage: pages:map[chat:0xc0002527d0] loadedPages:map[chat:true] status:{app:0xc0002e0d90 queue:[] width:347 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0002e0d90 showPermissions:false permissions:0xc00027b408 showHelp:false help:0xc0004900c0 showQuit:true quit:0xc00047ae69 showSessionDialog:false sessionDialog:0xc000319f40 showCommandDialog:false commandDialog:0xc00042d880 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc000357e00 showInitDialog:false initDialog:{width:347 height:89 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000aa500 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000aa540}"
|
||||
time=2025-06-02T19:36:04.879-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T19:36:04.883-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T19:36:07.068-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc000544b40] loadedPages:map[chat:true] status:{app:0xc000249b90 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000249b90 showPermissions:false permissions:0xc000207408 showHelp:false help:0xc00011a1e0 showQuit:true quit:0xc0003890b9 showSessionDialog:false sessionDialog:0xc000319f40 showCommandDialog:false commandDialog:0xc0003e5520 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc000547220 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac500 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac540}"
|
||||
time=2025-06-02T19:44:20.524-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T19:44:20.529-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T19:45:43.720-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc0001f87d0] loadedPages:map[chat:true] status:{app:0xc000270cb0 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000270cb0 showPermissions:false permissions:0xc00022f408 showHelp:false help:0xc000490e70 showQuit:true quit:0xc000388ab9 showSessionDialog:false sessionDialog:0xc000319f00 showCommandDialog:false commandDialog:0xc0003e55e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc00030bd60 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc0000ac4c0 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc0000ac500}"
|
||||
time=2025-06-02T19:45:47.456-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T19:45:47.462-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T19:46:50.039-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc00035b9f0] loadedPages:map[chat:true] status:{app:0xc0000ec230 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc0000ec230 showPermissions:false permissions:0xc0005e5408 showHelp:false help:0xc0005ad950 showQuit:true quit:0xc0005a0c09 showSessionDialog:false sessionDialog:0xc00012e440 showCommandDialog:false commandDialog:0xc0003c2160 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc0002c74a0 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc00013ac88 showThemeDialog:false themeDialog:0xc00012e680 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00012e6c0}"
|
||||
time=2025-06-02T19:47:11.433-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T19:47:11.438-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T19:48:43.841-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T19:48:43.841-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T19:48:43.842-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc0001f9040] loadedPages:map[chat:true] status:{app:0xc000270070 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000270070 showPermissions:false permissions:0xc00022f408 showHelp:false help:0xc000122090 showQuit:true quit:0xc000447c19 showSessionDialog:false sessionDialog:0xc000323b40 showCommandDialog:false commandDialog:0xc0003cb540 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc00044d5e0 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc000323d80 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc000323dc0}"
|
||||
time=2025-06-02T19:48:57.679-04:00 level=DEBUG msg="Set theme from config" theme=opencode
|
||||
time=2025-06-02T19:48:57.685-04:00 level=INFO msg="Reading directory: /home/thdxr"
|
||||
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="Cancelling all subscriptions"
|
||||
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="subscription cancelled" name=status
|
||||
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="All subscription goroutines completed successfully"
|
||||
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="TUI message channel closed"
|
||||
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="All goroutines cleaned up"
|
||||
time=2025-06-02T19:50:56.190-04:00 level=INFO msg="TUI exited" result="{width:145 height:36 currentPage:chat previousPage: pages:map[chat:0xc000564be0] loadedPages:map[chat:true] status:{app:0xc000250d20 queue:[] width:145 messageTTL:4000000000 activeUntil:{wall:0 ext:0 loc:<nil>}} app:0xc000250d20 showPermissions:false permissions:0xc0004d6a08 showHelp:false help:0xc00061d5c0 showQuit:true quit:0xc0005578a9 showSessionDialog:false sessionDialog:0xc00032a640 showCommandDialog:false commandDialog:0xc0003e51e0 commands:[{ID:init Title:Initialize Project Description:Create/Update the CONTEXT.md memory file Handler:0xb6b340} {ID:compact_conversation Title:Compact Conversation Description:Summarize the current session to save tokens Handler:0xb6b1c0}] showModelDialog:false modelDialog:0xc000620aa0 showInitDialog:false initDialog:{width:145 height:36 selected:0 keys:{Tab:{keys:[] help:{Key: Desc:} disabled:false} Left:{keys:[] help:{Key: Desc:} disabled:false} Right:{keys:[] help:{Key: Desc:} disabled:false} Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false} Y:{keys:[] help:{Key: Desc:} disabled:false} N:{keys:[] help:{Key: Desc:} disabled:false}}} showFilepicker:false filepicker:0xc0000d4c88 showThemeDialog:false themeDialog:0xc00032a880 showMultiArgumentsDialog:false multiArgumentsDialog:{width:0 height:0 inputs:[] focusIndex:0 keys:{Enter:{keys:[] help:{Key: Desc:} disabled:false} Escape:{keys:[] help:{Key: Desc:} disabled:false}} commandID: content: argNames:[]} showToolsDialog:false toolsDialog:0xc00032a8c0}"
|
||||
@@ -2,17 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
zone "github.com/lrstanley/bubblezone"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/pubsub"
|
||||
"github.com/sst/opencode/internal/tui"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
@@ -20,18 +17,18 @@ import (
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
url := os.Getenv("OPENCODE_SERVER")
|
||||
httpClient, err := client.NewClientWithResponses(url)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create client", "error", err)
|
||||
os.Exit(1)
|
||||
version := Version
|
||||
if version != "dev" && !strings.HasPrefix(Version, "v") {
|
||||
version = "v" + Version
|
||||
}
|
||||
paths, err := httpClient.PostPathGetWithResponse(context.Background())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
logfile := filepath.Join(paths.JSON200.Data, "log", "tui.log")
|
||||
|
||||
url := os.Getenv("OPENCODE_SERVER")
|
||||
|
||||
appInfoStr := os.Getenv("OPENCODE_APP_INFO")
|
||||
var appInfo client.AppInfo
|
||||
json.Unmarshal([]byte(appInfoStr), &appInfo)
|
||||
|
||||
logfile := filepath.Join(appInfo.Path.Data, "log", "tui.log")
|
||||
if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(filepath.Dir(logfile), 0755)
|
||||
if err != nil {
|
||||
@@ -48,26 +45,26 @@ func main() {
|
||||
logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
httpClient, err := client.NewClientWithResponses(url)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create main context for the application
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
version := Version
|
||||
if version != "dev" && !strings.HasPrefix(Version, "v") {
|
||||
version = "v" + Version
|
||||
}
|
||||
app_, err := app.New(ctx, version, httpClient)
|
||||
app_, err := app.New(ctx, version, appInfo, httpClient)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Set up the TUI
|
||||
zone.NewGlobal()
|
||||
program := tea.NewProgram(
|
||||
tui.NewModel(app_),
|
||||
// tea.WithMouseCellMotion(),
|
||||
tea.WithKeyboardEnhancements(),
|
||||
tea.WithAltScreen(),
|
||||
tea.WithKeyboardEnhancements(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
eventClient, err := client.NewClient(url)
|
||||
@@ -88,133 +85,11 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Setup the subscriptions, this will send services events to the TUI
|
||||
ch, cancelSubs := setupSubscriptions(app_, ctx)
|
||||
|
||||
// Create a context for the TUI message handler
|
||||
tuiCtx, tuiCancel := context.WithCancel(ctx)
|
||||
var tuiWg sync.WaitGroup
|
||||
tuiWg.Add(1)
|
||||
|
||||
// Set up message handling for the TUI
|
||||
go func() {
|
||||
defer tuiWg.Done()
|
||||
// defer logging.RecoverPanic("TUI-message-handler", func() {
|
||||
// attemptTUIRecovery(program)
|
||||
// })
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-tuiCtx.Done():
|
||||
slog.Info("TUI message handler shutting down")
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
slog.Info("TUI message channel closed")
|
||||
return
|
||||
}
|
||||
program.Send(msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Cleanup function for when the program exits
|
||||
cleanup := func() {
|
||||
// Cancel subscriptions first
|
||||
cancelSubs()
|
||||
|
||||
// Then cancel TUI message handler
|
||||
tuiCancel()
|
||||
|
||||
// Wait for TUI message handler to finish
|
||||
tuiWg.Wait()
|
||||
|
||||
slog.Info("All goroutines cleaned up")
|
||||
}
|
||||
|
||||
// Run the TUI
|
||||
result, err := program.Run()
|
||||
cleanup()
|
||||
|
||||
if err != nil {
|
||||
slog.Error("TUI error", "error", err)
|
||||
// return fmt.Errorf("TUI error: %v", err)
|
||||
}
|
||||
|
||||
slog.Info("TUI exited", "result", result)
|
||||
}
|
||||
|
||||
func setupSubscriber[T any](
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
name string,
|
||||
subscriber func(context.Context) <-chan pubsub.Event[T],
|
||||
outputCh chan<- tea.Msg,
|
||||
) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
|
||||
|
||||
subCh := subscriber(ctx)
|
||||
if subCh == nil {
|
||||
slog.Warn("subscription channel is nil", "name", name)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-subCh:
|
||||
if !ok {
|
||||
slog.Info("subscription channel closed", "name", name)
|
||||
return
|
||||
}
|
||||
|
||||
var msg tea.Msg = event
|
||||
|
||||
select {
|
||||
case outputCh <- msg:
|
||||
case <-time.After(2 * time.Second):
|
||||
slog.Warn("message dropped due to slow consumer", "name", name)
|
||||
case <-ctx.Done():
|
||||
slog.Info("subscription cancelled", "name", name)
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
slog.Info("subscription cancelled", "name", name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) {
|
||||
ch := make(chan tea.Msg, 100)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
|
||||
|
||||
setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch)
|
||||
|
||||
cleanupFunc := func() {
|
||||
slog.Info("Cancelling all subscriptions")
|
||||
cancel() // Signal all goroutines to stop
|
||||
|
||||
waitCh := make(chan struct{})
|
||||
go func() {
|
||||
// defer logging.RecoverPanic("subscription-cleanup", nil)
|
||||
wg.Wait()
|
||||
close(waitCh)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-waitCh:
|
||||
slog.Info("All subscription goroutines completed successfully")
|
||||
close(ch) // Only close after all writers are confirmed done
|
||||
case <-time.After(5 * time.Second):
|
||||
slog.Warn("Timed out waiting for some subscription goroutines to complete")
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
return ch, cleanupFunc
|
||||
}
|
||||
|
||||
@@ -5,14 +5,12 @@ go 1.24.0
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/alecthomas/chroma/v2 v2.18.0
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/x/ansi v0.8.0
|
||||
github.com/lithammer/fuzzysearch v1.1.8
|
||||
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
@@ -27,7 +25,6 @@ require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.4 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 // indirect
|
||||
@@ -66,13 +63,11 @@ require (
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
|
||||
@@ -24,12 +24,8 @@ github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
|
||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3/go.mod h1:ZFDg5oPjyRYrPAa3iFrtP1DO8xy+LUQxd9JFHEcuwJY=
|
||||
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
|
||||
@@ -38,8 +34,8 @@ github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 h1:D9AJJuYTN5pvz6mpIGO1ijLKpfTYSHOtKGgwoTQ4Gog=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 h1:iGrflaL5jQW6crML+pZx/ulWAVZQR3CQoRGvFsr2Tyg=
|
||||
@@ -69,8 +65,6 @@ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn
|
||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
@@ -126,16 +120,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
|
||||
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
@@ -267,7 +257,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
packages/tui/internal/app/.DS_Store
vendored
BIN
packages/tui/internal/app/.DS_Store
vendored
Binary file not shown.
@@ -5,122 +5,168 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"log/slog"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
var RootPath string
|
||||
|
||||
type App struct {
|
||||
ConfigPath string
|
||||
Config *config.Config
|
||||
Client *client.ClientWithResponses
|
||||
Provider *client.ProviderInfo
|
||||
Model *client.ModelInfo
|
||||
Session *client.SessionInfo
|
||||
Messages []client.MessageInfo
|
||||
Status status.Service
|
||||
Commands commands.Registry
|
||||
Info client.AppInfo
|
||||
Version string
|
||||
StatePath string
|
||||
Config *client.ConfigInfo
|
||||
Client *client.ClientWithResponses
|
||||
State *config.State
|
||||
Provider *client.ProviderInfo
|
||||
Model *client.ModelInfo
|
||||
Session *client.SessionInfo
|
||||
Messages []client.MessageInfo
|
||||
Commands commands.CommandRegistry
|
||||
}
|
||||
|
||||
type AppInfo struct {
|
||||
client.AppInfo
|
||||
Version string
|
||||
type SessionSelectedMsg = *client.SessionInfo
|
||||
type ModelSelectedMsg struct {
|
||||
Provider client.ProviderInfo
|
||||
Model client.ModelInfo
|
||||
}
|
||||
type SessionClearedMsg struct{}
|
||||
type CompactSessionMsg struct{}
|
||||
type SendMsg struct {
|
||||
Text string
|
||||
Attachments []Attachment
|
||||
}
|
||||
type CompletionDialogTriggerdMsg struct {
|
||||
InitialValue string
|
||||
}
|
||||
|
||||
var Info AppInfo
|
||||
func New(
|
||||
ctx context.Context,
|
||||
version string,
|
||||
appInfo client.AppInfo,
|
||||
httpClient *client.ClientWithResponses,
|
||||
) (*App, error) {
|
||||
RootPath = appInfo.Path.Root
|
||||
|
||||
func New(ctx context.Context, version string, httpClient *client.ClientWithResponses) (*App, error) {
|
||||
err := status.InitService()
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize status service", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
appInfoResponse, _ := httpClient.PostAppInfoWithResponse(ctx)
|
||||
appInfo := appInfoResponse.JSON200
|
||||
Info = AppInfo{Version: version}
|
||||
Info.Git = appInfo.Git
|
||||
Info.Path = appInfo.Path
|
||||
Info.Time = appInfo.Time
|
||||
Info.User = appInfo.User
|
||||
|
||||
providersResponse, err := httpClient.PostProviderListWithResponse(ctx)
|
||||
configResponse, err := httpClient.PostConfigGetWithResponse(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
providers := []client.ProviderInfo{}
|
||||
var defaultProvider *client.ProviderInfo
|
||||
var defaultModel *client.ModelInfo
|
||||
|
||||
var anthropic *client.ProviderInfo
|
||||
for _, provider := range providersResponse.JSON200.Providers {
|
||||
if provider.Id == "anthropic" {
|
||||
anthropic = &provider
|
||||
configInfo := configResponse.JSON200
|
||||
if configInfo.Keybinds == nil {
|
||||
leader := "ctrl+x"
|
||||
keybinds := client.ConfigKeybinds{
|
||||
Leader: &leader,
|
||||
}
|
||||
configInfo.Keybinds = &keybinds
|
||||
}
|
||||
|
||||
// default to anthropic if available
|
||||
if anthropic != nil {
|
||||
defaultProvider = anthropic
|
||||
defaultModel = getDefaultModel(providersResponse, *anthropic)
|
||||
}
|
||||
|
||||
for _, provider := range providersResponse.JSON200.Providers {
|
||||
if defaultProvider == nil || defaultModel == nil {
|
||||
defaultProvider = &provider
|
||||
defaultModel = getDefaultModel(providersResponse, provider)
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return nil, fmt.Errorf("no providers found")
|
||||
}
|
||||
|
||||
appConfigPath := filepath.Join(Info.Path.Config, "config")
|
||||
appConfig, err := config.LoadConfig(appConfigPath)
|
||||
appStatePath := filepath.Join(appInfo.Path.State, "tui")
|
||||
appState, err := config.LoadState(appStatePath)
|
||||
if err != nil {
|
||||
slog.Info("No TUI config found, using default values", "error", err)
|
||||
appConfig = config.NewConfig("opencode", defaultProvider.Id, defaultModel.Id)
|
||||
config.SaveConfig(appConfigPath, appConfig)
|
||||
appState = config.NewState()
|
||||
config.SaveState(appStatePath, appState)
|
||||
}
|
||||
|
||||
var currentProvider *client.ProviderInfo
|
||||
var currentModel *client.ModelInfo
|
||||
for _, provider := range providers {
|
||||
if provider.Id == appConfig.Provider {
|
||||
currentProvider = &provider
|
||||
if configInfo.Theme != nil {
|
||||
appState.Theme = *configInfo.Theme
|
||||
}
|
||||
if configInfo.Model != nil {
|
||||
splits := strings.Split(*configInfo.Model, "/")
|
||||
appState.Provider = splits[0]
|
||||
appState.Model = strings.Join(splits[1:], "/")
|
||||
}
|
||||
|
||||
for _, model := range provider.Models {
|
||||
if model.Id == appConfig.Model {
|
||||
currentModel = &model
|
||||
if appState.Theme != "" {
|
||||
theme.SetTheme(appState.Theme)
|
||||
}
|
||||
|
||||
slog.Debug("Loaded config", "config", configInfo)
|
||||
|
||||
app := &App{
|
||||
Info: appInfo,
|
||||
Version: version,
|
||||
StatePath: appStatePath,
|
||||
Config: configInfo,
|
||||
State: appState,
|
||||
Client: httpClient,
|
||||
Session: &client.SessionInfo{},
|
||||
Messages: []client.MessageInfo{},
|
||||
Commands: commands.LoadFromConfig(configInfo),
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (a *App) InitializeProvider() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
providersResponse, err := a.Client.PostProviderListWithResponse(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("Failed to list providers", "error", err)
|
||||
// TODO: notify user
|
||||
return nil
|
||||
}
|
||||
providers := []client.ProviderInfo{}
|
||||
var defaultProvider *client.ProviderInfo
|
||||
var defaultModel *client.ModelInfo
|
||||
|
||||
var anthropic *client.ProviderInfo
|
||||
for _, provider := range providersResponse.JSON200.Providers {
|
||||
if provider.Id == "anthropic" {
|
||||
anthropic = &provider
|
||||
}
|
||||
}
|
||||
|
||||
// default to anthropic if available
|
||||
if anthropic != nil {
|
||||
defaultProvider = anthropic
|
||||
defaultModel = getDefaultModel(providersResponse, *anthropic)
|
||||
}
|
||||
|
||||
for _, provider := range providersResponse.JSON200.Providers {
|
||||
if defaultProvider == nil || defaultModel == nil {
|
||||
defaultProvider = &provider
|
||||
defaultModel = getDefaultModel(providersResponse, provider)
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
slog.Error("No providers configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
var currentProvider *client.ProviderInfo
|
||||
var currentModel *client.ModelInfo
|
||||
for _, provider := range providers {
|
||||
if provider.Id == a.State.Provider {
|
||||
currentProvider = &provider
|
||||
|
||||
for _, model := range provider.Models {
|
||||
if model.Id == a.State.Model {
|
||||
currentModel = &model
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentProvider == nil || currentModel == nil {
|
||||
currentProvider = defaultProvider
|
||||
currentModel = defaultModel
|
||||
}
|
||||
|
||||
// TODO: handle no provider or model setup, yet
|
||||
return ModelSelectedMsg{
|
||||
Provider: *currentProvider,
|
||||
Model: *currentModel,
|
||||
}
|
||||
}
|
||||
|
||||
app := &App{
|
||||
ConfigPath: appConfigPath,
|
||||
Config: appConfig,
|
||||
Client: httpClient,
|
||||
Provider: currentProvider,
|
||||
Model: currentModel,
|
||||
Session: &client.SessionInfo{},
|
||||
Messages: []client.MessageInfo{},
|
||||
Status: status.GetService(),
|
||||
Commands: commands.NewCommandRegistry(),
|
||||
}
|
||||
|
||||
theme.SetTheme(appConfig.Theme)
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func getDefaultModel(response *client.PostProviderListResponse, provider client.ProviderInfo) *client.ModelInfo {
|
||||
@@ -151,8 +197,11 @@ func (a *App) IsBusy() bool {
|
||||
return lastMessage.Metadata.Time.Completed == nil
|
||||
}
|
||||
|
||||
func (a *App) SaveConfig() {
|
||||
config.SaveConfig(a.ConfigPath, a.Config)
|
||||
func (a *App) SaveState() {
|
||||
err := config.SaveState(a.StatePath, a.State)
|
||||
if err != nil {
|
||||
slog.Error("Failed to save state", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
||||
@@ -160,31 +209,47 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
||||
|
||||
session, err := a.CreateSession(ctx)
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
// status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
a.Session = session
|
||||
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
|
||||
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||
|
||||
go func() {
|
||||
// TODO: Handle no provider or model setup, yet
|
||||
response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
|
||||
SessionID: a.Session.Id,
|
||||
ProviderID: a.Provider.Id,
|
||||
ModelID: a.Model.Id,
|
||||
})
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
slog.Error("Failed to initialize project", "error", err)
|
||||
// status.Error(err.Error())
|
||||
}
|
||||
if response != nil && response.StatusCode != 200 {
|
||||
status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
|
||||
slog.Error("Failed to initialize project", "error", response.StatusCode)
|
||||
// status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
|
||||
}
|
||||
}()
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) CompactSession(ctx context.Context) tea.Cmd {
|
||||
response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
|
||||
SessionID: a.Session.Id,
|
||||
ProviderID: a.Provider.Id,
|
||||
ModelID: a.Model.Id,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Failed to compact session", "error", err)
|
||||
}
|
||||
if response != nil && response.StatusCode() != 200 {
|
||||
slog.Error("Failed to compact session", "error", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) MarkProjectInitialized(ctx context.Context) error {
|
||||
response, err := a.Client.PostAppInitialize(ctx)
|
||||
if err != nil {
|
||||
@@ -214,11 +279,11 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
||||
if a.Session.Id == "" {
|
||||
session, err := a.CreateSession(ctx)
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
// status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
a.Session = session
|
||||
cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
|
||||
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||
}
|
||||
|
||||
// TODO: Handle attachments when API supports them
|
||||
@@ -243,11 +308,11 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Failed to send message", "error", err)
|
||||
status.Error(err.Error())
|
||||
// status.Error(err.Error())
|
||||
}
|
||||
if response != nil && response.StatusCode != 200 {
|
||||
slog.Error("Failed to send message", "error", fmt.Sprintf("failed to send message: %d", response.StatusCode))
|
||||
status.Error(fmt.Sprintf("failed to send message: %d", response.StatusCode))
|
||||
// status.Error(fmt.Sprintf("failed to send message: %d", response.StatusCode))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -262,12 +327,12 @@ func (a *App) Cancel(ctx context.Context, sessionID string) error {
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Failed to cancel session", "error", err)
|
||||
status.Error(err.Error())
|
||||
// status.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
if response != nil && response.StatusCode != 200 {
|
||||
slog.Error("Failed to cancel session", "error", fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
|
||||
status.Error(fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
|
||||
// status.Error(fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
|
||||
return fmt.Errorf("failed to cancel session: %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
@@ -323,3 +388,7 @@ func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error)
|
||||
providers := *resp.JSON200
|
||||
return providers.Providers, nil
|
||||
}
|
||||
|
||||
// func (a *App) loadCustomKeybinds() {
|
||||
//
|
||||
// }
|
||||
|
||||
@@ -1,71 +1,281 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
// Command represents a user-triggerable action.
|
||||
type ExecuteCommandMsg Command
|
||||
type ExecuteCommandsMsg []Command
|
||||
type CommandExecutedMsg Command
|
||||
|
||||
type Keybinding struct {
|
||||
RequiresLeader bool
|
||||
Key string
|
||||
}
|
||||
|
||||
func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool {
|
||||
key := k.Key
|
||||
key = strings.TrimSpace(key)
|
||||
return key == msg.String() && (k.RequiresLeader == leader)
|
||||
}
|
||||
|
||||
type CommandName string
|
||||
type Command struct {
|
||||
// Name is the identifier used for slash commands (e.g., "new").
|
||||
Name string
|
||||
// Description is a short explanation of what the command does.
|
||||
Name CommandName
|
||||
Description string
|
||||
// KeyBinding is the keyboard shortcut to trigger this command.
|
||||
KeyBinding key.Binding
|
||||
Keybindings []Keybinding
|
||||
Trigger string
|
||||
}
|
||||
|
||||
// Registry holds all the available commands.
|
||||
type Registry map[string]Command
|
||||
|
||||
// ExecuteCommandMsg is a message sent when a command should be executed.
|
||||
type ExecuteCommandMsg struct {
|
||||
Name string
|
||||
func (c Command) Keys() []string {
|
||||
var keys []string
|
||||
for _, k := range c.Keybindings {
|
||||
keys = append(keys, k.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func NewCommandRegistry() Registry {
|
||||
return Registry{
|
||||
"help": {
|
||||
Name: "help",
|
||||
type CommandRegistry map[CommandName]Command
|
||||
|
||||
func (r CommandRegistry) Sorted() []Command {
|
||||
var commands []Command
|
||||
for _, command := range r {
|
||||
commands = append(commands, command)
|
||||
}
|
||||
slices.SortFunc(commands, func(a, b Command) int {
|
||||
if a.Name == AppExitCommand {
|
||||
return 1
|
||||
}
|
||||
if b.Name == AppExitCommand {
|
||||
return -1
|
||||
}
|
||||
return strings.Compare(string(a.Name), string(b.Name))
|
||||
})
|
||||
return commands
|
||||
}
|
||||
|
||||
func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
|
||||
var matched []Command
|
||||
for _, command := range r.Sorted() {
|
||||
if command.Matches(msg, leader) {
|
||||
matched = append(matched, command)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
const (
|
||||
AppHelpCommand CommandName = "app_help"
|
||||
EditorOpenCommand CommandName = "editor_open"
|
||||
SessionNewCommand CommandName = "session_new"
|
||||
SessionListCommand CommandName = "session_list"
|
||||
SessionShareCommand CommandName = "session_share"
|
||||
SessionInterruptCommand CommandName = "session_interrupt"
|
||||
SessionCompactCommand CommandName = "session_compact"
|
||||
ToolDetailsCommand CommandName = "tool_details"
|
||||
ModelListCommand CommandName = "model_list"
|
||||
ThemeListCommand CommandName = "theme_list"
|
||||
ProjectInitCommand CommandName = "project_init"
|
||||
InputClearCommand CommandName = "input_clear"
|
||||
InputPasteCommand CommandName = "input_paste"
|
||||
InputSubmitCommand CommandName = "input_submit"
|
||||
InputNewlineCommand CommandName = "input_newline"
|
||||
HistoryPreviousCommand CommandName = "history_previous"
|
||||
HistoryNextCommand CommandName = "history_next"
|
||||
MessagesPageUpCommand CommandName = "messages_page_up"
|
||||
MessagesPageDownCommand CommandName = "messages_page_down"
|
||||
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
|
||||
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
|
||||
MessagesPreviousCommand CommandName = "messages_previous"
|
||||
MessagesNextCommand CommandName = "messages_next"
|
||||
MessagesFirstCommand CommandName = "messages_first"
|
||||
MessagesLastCommand CommandName = "messages_last"
|
||||
AppExitCommand CommandName = "app_exit"
|
||||
)
|
||||
|
||||
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
|
||||
for _, binding := range k.Keybindings {
|
||||
if binding.Matches(msg, leader) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseBindings(bindings ...string) []Keybinding {
|
||||
var parsedBindings []Keybinding
|
||||
for _, binding := range bindings {
|
||||
for p := range strings.SplitSeq(binding, ",") {
|
||||
requireLeader := strings.HasPrefix(p, "<leader>")
|
||||
keybinding := strings.ReplaceAll(p, "<leader>", "")
|
||||
keybinding = strings.TrimSpace(keybinding)
|
||||
parsedBindings = append(parsedBindings, Keybinding{
|
||||
RequiresLeader: requireLeader,
|
||||
Key: keybinding,
|
||||
})
|
||||
}
|
||||
}
|
||||
return parsedBindings
|
||||
}
|
||||
|
||||
func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
|
||||
defaults := []Command{
|
||||
{
|
||||
Name: AppHelpCommand,
|
||||
Description: "show help",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f1", "super+/", "super+h"),
|
||||
),
|
||||
Keybindings: parseBindings("<leader>h"),
|
||||
Trigger: "help",
|
||||
},
|
||||
"new": {
|
||||
Name: "new",
|
||||
{
|
||||
Name: EditorOpenCommand,
|
||||
Description: "open editor",
|
||||
Keybindings: parseBindings("<leader>e"),
|
||||
Trigger: "editor",
|
||||
},
|
||||
{
|
||||
Name: SessionNewCommand,
|
||||
Description: "new session",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f2", "super+n"),
|
||||
),
|
||||
Keybindings: parseBindings("<leader>n"),
|
||||
Trigger: "new",
|
||||
},
|
||||
"sessions": {
|
||||
Name: "sessions",
|
||||
Description: "switch session",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f3", "super+s"),
|
||||
),
|
||||
{
|
||||
Name: SessionListCommand,
|
||||
Description: "list sessions",
|
||||
Keybindings: parseBindings("<leader>l"),
|
||||
Trigger: "sessions",
|
||||
},
|
||||
"model": {
|
||||
Name: "model",
|
||||
Description: "switch model",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f4", "super+m"),
|
||||
),
|
||||
{
|
||||
Name: SessionShareCommand,
|
||||
Description: "share session",
|
||||
Keybindings: parseBindings("<leader>s"),
|
||||
Trigger: "share",
|
||||
},
|
||||
"theme": {
|
||||
Name: "theme",
|
||||
Description: "switch theme",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f5", "super+t"),
|
||||
),
|
||||
{
|
||||
Name: SessionInterruptCommand,
|
||||
Description: "interrupt session",
|
||||
Keybindings: parseBindings("esc"),
|
||||
},
|
||||
"quit": {
|
||||
Name: "quit",
|
||||
Description: "quit",
|
||||
KeyBinding: key.NewBinding(
|
||||
key.WithKeys("f10", "ctrl+c", "super+q"),
|
||||
),
|
||||
{
|
||||
Name: SessionCompactCommand,
|
||||
Description: "compact the session",
|
||||
Keybindings: parseBindings("<leader>c"),
|
||||
Trigger: "compact",
|
||||
},
|
||||
{
|
||||
Name: ToolDetailsCommand,
|
||||
Description: "toggle tool details",
|
||||
Keybindings: parseBindings("<leader>d"),
|
||||
Trigger: "details",
|
||||
},
|
||||
{
|
||||
Name: ModelListCommand,
|
||||
Description: "list models",
|
||||
Keybindings: parseBindings("<leader>m"),
|
||||
Trigger: "models",
|
||||
},
|
||||
{
|
||||
Name: ThemeListCommand,
|
||||
Description: "list themes",
|
||||
Keybindings: parseBindings("<leader>t"),
|
||||
Trigger: "themes",
|
||||
},
|
||||
{
|
||||
Name: ProjectInitCommand,
|
||||
Description: "create/update AGENTS.md",
|
||||
Keybindings: parseBindings("<leader>i"),
|
||||
Trigger: "init",
|
||||
},
|
||||
{
|
||||
Name: InputClearCommand,
|
||||
Description: "clear input",
|
||||
Keybindings: parseBindings("ctrl+c"),
|
||||
},
|
||||
{
|
||||
Name: InputPasteCommand,
|
||||
Description: "paste content",
|
||||
Keybindings: parseBindings("ctrl+v"),
|
||||
},
|
||||
{
|
||||
Name: InputSubmitCommand,
|
||||
Description: "submit message",
|
||||
Keybindings: parseBindings("enter"),
|
||||
},
|
||||
{
|
||||
Name: InputNewlineCommand,
|
||||
Description: "insert newline",
|
||||
Keybindings: parseBindings("shift+enter", "ctrl+j"),
|
||||
},
|
||||
// {
|
||||
// Name: HistoryPreviousCommand,
|
||||
// Description: "previous prompt",
|
||||
// Keybindings: parseBindings("up"),
|
||||
// },
|
||||
// {
|
||||
// Name: HistoryNextCommand,
|
||||
// Description: "next prompt",
|
||||
// Keybindings: parseBindings("down"),
|
||||
// },
|
||||
{
|
||||
Name: MessagesPageUpCommand,
|
||||
Description: "page up",
|
||||
Keybindings: parseBindings("pgup"),
|
||||
},
|
||||
{
|
||||
Name: MessagesPageDownCommand,
|
||||
Description: "page down",
|
||||
Keybindings: parseBindings("pgdown"),
|
||||
},
|
||||
{
|
||||
Name: MessagesHalfPageUpCommand,
|
||||
Description: "half page up",
|
||||
Keybindings: parseBindings("ctrl+alt+u"),
|
||||
},
|
||||
{
|
||||
Name: MessagesHalfPageDownCommand,
|
||||
Description: "half page down",
|
||||
Keybindings: parseBindings("ctrl+alt+d"),
|
||||
},
|
||||
{
|
||||
Name: MessagesPreviousCommand,
|
||||
Description: "previous message",
|
||||
Keybindings: parseBindings("ctrl+alt+k"),
|
||||
},
|
||||
{
|
||||
Name: MessagesNextCommand,
|
||||
Description: "next message",
|
||||
Keybindings: parseBindings("ctrl+alt+j"),
|
||||
},
|
||||
{
|
||||
Name: MessagesFirstCommand,
|
||||
Description: "first message",
|
||||
Keybindings: parseBindings("ctrl+g"),
|
||||
},
|
||||
{
|
||||
Name: MessagesLastCommand,
|
||||
Description: "last message",
|
||||
Keybindings: parseBindings("ctrl+alt+g"),
|
||||
},
|
||||
{
|
||||
Name: AppExitCommand,
|
||||
Description: "exit the app",
|
||||
Keybindings: parseBindings("ctrl+c", "<leader>q"),
|
||||
Trigger: "exit",
|
||||
},
|
||||
}
|
||||
registry := make(CommandRegistry)
|
||||
keybinds := map[string]string{}
|
||||
marshalled, _ := json.Marshal(*config.Keybinds)
|
||||
json.Unmarshal(marshalled, &keybinds)
|
||||
for _, command := range defaults {
|
||||
if keybind, ok := keybinds[string(command.Name)]; ok {
|
||||
command.Keybindings = parseBindings(keybind)
|
||||
}
|
||||
registry[command.Name] = command
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@ package completions
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type CommandCompletionProvider struct {
|
||||
@@ -27,15 +31,39 @@ func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetEmptyMessage() string {
|
||||
return "no matching commands"
|
||||
}
|
||||
|
||||
func getCommandCompletionItem(cmd commands.Command, space int) dialog.CompletionItemI {
|
||||
t := theme.CurrentTheme()
|
||||
spacer := strings.Repeat(" ", space)
|
||||
title := " /" + cmd.Trigger + lipgloss.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
|
||||
value := string(cmd.Name)
|
||||
return dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: title,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
|
||||
space := 1
|
||||
for _, cmd := range c.app.Commands {
|
||||
if lipgloss.Width(cmd.Trigger) > space {
|
||||
space = lipgloss.Width(cmd.Trigger)
|
||||
}
|
||||
}
|
||||
space += 2
|
||||
|
||||
if query == "" {
|
||||
// If no query, return all commands
|
||||
items := []dialog.CompletionItemI{}
|
||||
for _, cmd := range c.app.Commands {
|
||||
items = append(items, dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: " /" + cmd.Name,
|
||||
Value: "/" + cmd.Name,
|
||||
}))
|
||||
if cmd.Trigger == "" {
|
||||
continue
|
||||
}
|
||||
space := space - lipgloss.Width(cmd.Trigger)
|
||||
items = append(items, getCommandCompletionItem(cmd, space))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -45,11 +73,12 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
|
||||
commandMap := make(map[string]dialog.CompletionItemI)
|
||||
|
||||
for _, cmd := range c.app.Commands {
|
||||
commandNames = append(commandNames, cmd.Name)
|
||||
commandMap[cmd.Name] = dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: " /" + cmd.Name,
|
||||
Value: "/" + cmd.Name,
|
||||
})
|
||||
if cmd.Trigger == "" {
|
||||
continue
|
||||
}
|
||||
space := space - lipgloss.Width(cmd.Trigger)
|
||||
commandNames = append(commandNames, cmd.Trigger)
|
||||
commandMap[cmd.Trigger] = getCommandCompletionItem(cmd, space)
|
||||
}
|
||||
|
||||
// Find fuzzy matches
|
||||
@@ -65,7 +94,5 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
|
||||
})
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
|
||||
return "no matching files"
|
||||
}
|
||||
|
||||
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
|
||||
response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{
|
||||
Query: query,
|
||||
|
||||
@@ -20,10 +20,13 @@ func NewCompletionManager(app *app.App) *CompletionManager {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CompletionManager) DefaultProvider() dialog.CompletionProvider {
|
||||
return m.providers["commands"]
|
||||
}
|
||||
|
||||
func (m *CompletionManager) GetProvider(input string) dialog.CompletionProvider {
|
||||
if strings.HasPrefix(input, "/") {
|
||||
return m.providers["commands"]
|
||||
}
|
||||
return m.providers["files"]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type SendMsg struct {
|
||||
Text string
|
||||
Attachments []app.Attachment
|
||||
}
|
||||
|
||||
func repo(width int) string {
|
||||
repo := "github.com/sst/opencode"
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
return styles.BaseStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Width(width).
|
||||
Render(repo)
|
||||
}
|
||||
@@ -3,95 +3,48 @@ package chat
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
||||
"github.com/charmbracelet/bubbles/v2/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/components/textarea"
|
||||
"github.com/sst/opencode/internal/image"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type EditorComponent interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
layout.Sizeable
|
||||
Content() string
|
||||
Lines() int
|
||||
Value() string
|
||||
Submit() (tea.Model, tea.Cmd)
|
||||
Clear() (tea.Model, tea.Cmd)
|
||||
Paste() (tea.Model, tea.Cmd)
|
||||
Newline() (tea.Model, tea.Cmd)
|
||||
Previous() (tea.Model, tea.Cmd)
|
||||
Next() (tea.Model, tea.Cmd)
|
||||
}
|
||||
|
||||
type editorComponent struct {
|
||||
width int
|
||||
height int
|
||||
app *app.App
|
||||
width, height int
|
||||
textarea textarea.Model
|
||||
attachments []app.Attachment
|
||||
deleteMode bool
|
||||
history []string
|
||||
historyIndex int
|
||||
currentMessage string
|
||||
spinner spinner.Model
|
||||
}
|
||||
|
||||
type EditorKeyMaps struct {
|
||||
Send key.Binding
|
||||
OpenEditor key.Binding
|
||||
Paste key.Binding
|
||||
HistoryUp key.Binding
|
||||
HistoryDown key.Binding
|
||||
}
|
||||
|
||||
type DeleteAttachmentKeyMaps struct {
|
||||
AttachmentDeleteMode key.Binding
|
||||
Escape key.Binding
|
||||
DeleteAllAttachments key.Binding
|
||||
}
|
||||
|
||||
var editorMaps = EditorKeyMaps{
|
||||
Send: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "send message"),
|
||||
),
|
||||
OpenEditor: key.NewBinding(
|
||||
key.WithKeys("f12"),
|
||||
key.WithHelp("f12", "open editor"),
|
||||
),
|
||||
Paste: key.NewBinding(
|
||||
key.WithKeys("ctrl+v"),
|
||||
key.WithHelp("ctrl+v", "paste content"),
|
||||
),
|
||||
HistoryUp: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithHelp("up", "previous message"),
|
||||
),
|
||||
HistoryDown: key.NewBinding(
|
||||
key.WithKeys("down"),
|
||||
key.WithHelp("down", "next message"),
|
||||
),
|
||||
}
|
||||
|
||||
var DeleteKeyMaps = DeleteAttachmentKeyMaps{
|
||||
AttachmentDeleteMode: key.NewBinding(
|
||||
key.WithKeys("ctrl+r"),
|
||||
key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
|
||||
),
|
||||
Escape: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "cancel delete mode"),
|
||||
),
|
||||
DeleteAllAttachments: key.NewBinding(
|
||||
key.WithKeys("r"),
|
||||
key.WithHelp("ctrl+r+r", "delete all attachments"),
|
||||
),
|
||||
}
|
||||
|
||||
const (
|
||||
maxAttachments = 5
|
||||
)
|
||||
|
||||
func (m *editorComponent) Init() tea.Cmd {
|
||||
return tea.Batch(textarea.Blink, m.spinner.Tick, tea.EnableReportFocus)
|
||||
}
|
||||
@@ -99,157 +52,33 @@ func (m *editorComponent) Init() tea.Cmd {
|
||||
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case dialog.ThemeChangedMsg:
|
||||
case tea.KeyPressMsg:
|
||||
// Maximize editor responsiveness for printable characters
|
||||
if msg.Text != "" {
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
case dialog.ThemeSelectedMsg:
|
||||
m.textarea = createTextArea(&m.textarea)
|
||||
m.spinner = createSpinner()
|
||||
return m, tea.Batch(m.spinner.Tick, textarea.Blink)
|
||||
case dialog.CompletionSelectedMsg:
|
||||
if msg.IsCommand {
|
||||
// Execute the command directly
|
||||
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
|
||||
m.textarea.Reset()
|
||||
return m, util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
|
||||
return m, tea.Batch(cmds...)
|
||||
} else {
|
||||
// For files, replace the text in the editor
|
||||
existingValue := m.textarea.Value()
|
||||
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
|
||||
m.textarea.SetValue(modifiedValue)
|
||||
m.textarea.SetValue(modifiedValue + " ")
|
||||
return m, nil
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
if m.textarea.Value() != "" {
|
||||
m.textarea.Reset()
|
||||
return m, func() tea.Msg {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
case "ctrl+d":
|
||||
if m.textarea.Value() != "" {
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
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)
|
||||
@@ -261,10 +90,10 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorComponent) View() string {
|
||||
func (m *editorComponent) Content() string {
|
||||
t := theme.CurrentTheme()
|
||||
base := styles.BaseStyle().Render
|
||||
muted := styles.Muted().Render
|
||||
base := styles.BaseStyle().Background(t.Background()).Render
|
||||
muted := styles.Muted().Background(t.Background()).Render
|
||||
promptStyle := lipgloss.NewStyle().
|
||||
Padding(0, 0, 0, 1).
|
||||
Bold(true).
|
||||
@@ -281,94 +110,71 @@ func (m *editorComponent) View() string {
|
||||
PaddingTop(1).
|
||||
PaddingBottom(1).
|
||||
Background(t.BackgroundElement()).
|
||||
Border(lipgloss.ThickBorder(), false, true).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderBackground(t.Background()).
|
||||
Render(textarea)
|
||||
|
||||
hint := base("enter") + muted(" send ") + base("shift") + muted("+") + base("enter") + muted(" newline")
|
||||
hint := base("enter") + muted(" send ")
|
||||
if m.app.IsBusy() {
|
||||
hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
|
||||
}
|
||||
|
||||
model := ""
|
||||
if m.app.Model != nil {
|
||||
model = base(m.app.Model.Name) + muted(" • /model")
|
||||
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
|
||||
}
|
||||
|
||||
space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
|
||||
spacer := lipgloss.NewStyle().Width(space).Render("")
|
||||
spacer := lipgloss.NewStyle().Background(t.Background()).Width(space).Render("")
|
||||
|
||||
info := lipgloss.JoinHorizontal(lipgloss.Left, hint, spacer, model)
|
||||
info = styles.Padded().Render(info)
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
// m.attachmentsContent(),
|
||||
"",
|
||||
textarea,
|
||||
info,
|
||||
)
|
||||
info := hint + spacer + model
|
||||
info = styles.Padded().Background(t.Background()).Render(info)
|
||||
|
||||
content := strings.Join([]string{"", textarea, info}, "\n")
|
||||
return content
|
||||
}
|
||||
|
||||
func (m *editorComponent) View() string {
|
||||
if m.Lines() > 1 {
|
||||
return ""
|
||||
}
|
||||
return m.Content()
|
||||
}
|
||||
|
||||
func (m *editorComponent) GetSize() (width, height int) {
|
||||
return m.width, m.height
|
||||
}
|
||||
|
||||
func (m *editorComponent) SetSize(width, height int) tea.Cmd {
|
||||
m.width = width
|
||||
m.height = height
|
||||
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
|
||||
m.textarea.SetHeight(height - 4) // account for info underneath
|
||||
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
|
||||
// m.textarea.SetHeight(height - 4)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *editorComponent) GetSize() (int, int) {
|
||||
return m.width, m.height
|
||||
func (m *editorComponent) Lines() int {
|
||||
return m.textarea.LineCount()
|
||||
}
|
||||
|
||||
func (m *editorComponent) openEditor(value string) tea.Cmd {
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
editor = "nvim"
|
||||
}
|
||||
|
||||
tmpfile, err := os.CreateTemp("", "msg_*.md")
|
||||
tmpfile.WriteString(value)
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
tmpfile.Close()
|
||||
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
|
||||
c.Stdin = os.Stdin
|
||||
c.Stdout = os.Stdout
|
||||
c.Stderr = os.Stderr
|
||||
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
content, err := os.ReadFile(tmpfile.Name())
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
return nil
|
||||
}
|
||||
if len(content) == 0 {
|
||||
status.Warn("Message is empty")
|
||||
return nil
|
||||
}
|
||||
os.Remove(tmpfile.Name())
|
||||
attachments := m.attachments
|
||||
m.attachments = nil
|
||||
return SendMsg{
|
||||
Text: string(content),
|
||||
Attachments: attachments,
|
||||
}
|
||||
})
|
||||
func (m *editorComponent) Value() string {
|
||||
return m.textarea.Value()
|
||||
}
|
||||
|
||||
func (m *editorComponent) send() tea.Cmd {
|
||||
value := strings.TrimSpace(m.textarea.Value())
|
||||
m.textarea.Reset()
|
||||
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
||||
value := strings.TrimSpace(m.Value())
|
||||
if value == "" {
|
||||
return m, nil
|
||||
}
|
||||
if len(value) > 0 && value[len(value)-1] == '\\' {
|
||||
// If the last character is a backslash, remove it and add a newline
|
||||
m.textarea.SetValue(value[:len(value)-1] + "\n")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
attachments := m.attachments
|
||||
|
||||
// Save to history if not empty and not a duplicate of the last entry
|
||||
@@ -381,52 +187,78 @@ func (m *editorComponent) send() tea.Cmd {
|
||||
}
|
||||
|
||||
m.attachments = nil
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for slash command
|
||||
// if strings.HasPrefix(value, "/") {
|
||||
// commandName := strings.TrimPrefix(value, "/")
|
||||
// if _, ok := m.app.Commands[commandName]; ok {
|
||||
// return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
|
||||
// }
|
||||
// }
|
||||
slog.Info("Send message", "value", value)
|
||||
|
||||
return tea.Batch(
|
||||
util.CmdHandler(SendMsg{
|
||||
Text: value,
|
||||
Attachments: attachments,
|
||||
}),
|
||||
)
|
||||
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorComponent) attachmentsContent() string {
|
||||
if len(m.attachments) == 0 {
|
||||
return ""
|
||||
}
|
||||
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
|
||||
m.textarea.Reset()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
var styledAttachments []string
|
||||
attachmentStyles := styles.BaseStyle().
|
||||
MarginLeft(1).
|
||||
Background(t.TextMuted()).
|
||||
Foreground(t.Text())
|
||||
for i, attachment := range m.attachments {
|
||||
var filename string
|
||||
if len(attachment.FileName) > 10 {
|
||||
filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
|
||||
} else {
|
||||
filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
|
||||
}
|
||||
if m.deleteMode {
|
||||
filename = fmt.Sprintf("%d%s", i, filename)
|
||||
}
|
||||
styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
|
||||
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
|
||||
imageBytes, text, err := image.GetImageFromClipboard()
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
return m, nil
|
||||
}
|
||||
content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
|
||||
return content
|
||||
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
|
||||
}
|
||||
|
||||
func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
|
||||
value := m.textarea.Value()
|
||||
m.textarea.SetValue(value + "\n")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
|
||||
currentLine := m.textarea.Line()
|
||||
|
||||
// Only navigate history if we're at the first line
|
||||
if currentLine == 0 && len(m.history) > 0 {
|
||||
// Save current message if we're just starting to navigate
|
||||
if m.historyIndex == len(m.history) {
|
||||
m.currentMessage = m.textarea.Value()
|
||||
}
|
||||
|
||||
// Go to previous message in history
|
||||
if m.historyIndex > 0 {
|
||||
m.historyIndex--
|
||||
m.textarea.SetValue(m.history[m.historyIndex])
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
|
||||
currentLine := m.textarea.Line()
|
||||
value := m.textarea.Value()
|
||||
lines := strings.Split(value, "\n")
|
||||
totalLines := len(lines)
|
||||
|
||||
// Only navigate history if we're at the last line
|
||||
if currentLine == totalLines-1 {
|
||||
if m.historyIndex < len(m.history)-1 {
|
||||
// Go to next message in history
|
||||
m.historyIndex++
|
||||
m.textarea.SetValue(m.history[m.historyIndex])
|
||||
} else if m.historyIndex == len(m.history)-1 {
|
||||
// Return to the current message being composed
|
||||
m.historyIndex = len(m.history)
|
||||
m.textarea.SetValue(m.currentMessage)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func createTextArea(existing *textarea.Model) textarea.Model {
|
||||
@@ -461,12 +293,19 @@ func createTextArea(existing *textarea.Model) textarea.Model {
|
||||
return ta
|
||||
}
|
||||
|
||||
func (m *editorComponent) GetValue() string {
|
||||
return m.textarea.Value()
|
||||
func createSpinner() spinner.Model {
|
||||
return spinner.New(
|
||||
spinner.WithSpinner(spinner.Ellipsis),
|
||||
spinner.WithStyle(
|
||||
styles.
|
||||
Muted().
|
||||
Background(theme.CurrentTheme().Background()).
|
||||
Width(3)),
|
||||
)
|
||||
}
|
||||
|
||||
func NewEditorComponent(app *app.App) layout.ModelWithView {
|
||||
s := spinner.New(spinner.WithSpinner(spinner.Ellipsis), spinner.WithStyle(styles.Muted().Width(3)))
|
||||
func NewEditorComponent(app *app.App) EditorComponent {
|
||||
s := createSpinner()
|
||||
ta := createTextArea(nil)
|
||||
|
||||
return &editorComponent{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
|
||||
func toMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
|
||||
r := styles.GetMarkdownRenderer(width, backgroundColor)
|
||||
content = strings.ReplaceAll(content, app.Info.Path.Root+"/", "")
|
||||
content = strings.ReplaceAll(content, app.RootPath+"/", "")
|
||||
rendered, _ := r.Render(content)
|
||||
lines := strings.Split(rendered, "\n")
|
||||
|
||||
@@ -131,8 +131,8 @@ func renderContentBlock(content string, options ...renderingOption) string {
|
||||
}
|
||||
|
||||
style := styles.BaseStyle().
|
||||
MarginTop(renderer.marginTop).
|
||||
MarginBottom(renderer.marginBottom).
|
||||
// MarginTop(renderer.marginTop).
|
||||
// MarginBottom(renderer.marginBottom).
|
||||
PaddingTop(renderer.paddingTop).
|
||||
PaddingBottom(renderer.paddingBottom).
|
||||
PaddingLeft(renderer.paddingLeft).
|
||||
@@ -180,26 +180,42 @@ func renderContentBlock(content string, options ...renderingOption) string {
|
||||
layout.Current.Container.Width,
|
||||
align,
|
||||
content,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
layout.Current.Viewport.Width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
if renderer.marginTop > 0 {
|
||||
for range renderer.marginTop {
|
||||
content = "\n" + content
|
||||
}
|
||||
}
|
||||
if renderer.marginBottom > 0 {
|
||||
for range renderer.marginBottom {
|
||||
content = content + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
func calculatePadding() int {
|
||||
if layout.Current.Viewport.Width < 80 {
|
||||
return 5
|
||||
} else if layout.Current.Viewport.Width < 120 {
|
||||
return 15
|
||||
} else {
|
||||
return 20
|
||||
}
|
||||
}
|
||||
|
||||
func renderText(message client.MessageInfo, text string, author string) string {
|
||||
t := theme.CurrentTheme()
|
||||
width := layout.Current.Container.Width
|
||||
padding := 0
|
||||
if layout.Current.Viewport.Width < 80 {
|
||||
padding = 5
|
||||
} else if layout.Current.Viewport.Width < 120 {
|
||||
padding = 15
|
||||
} else {
|
||||
padding = 20
|
||||
}
|
||||
padding := calculatePadding()
|
||||
|
||||
timestamp := time.UnixMilli(int64(message.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
|
||||
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
|
||||
@@ -208,18 +224,13 @@ func renderText(message client.MessageInfo, text string, author string) string {
|
||||
}
|
||||
info := fmt.Sprintf("%s (%s)", author, timestamp)
|
||||
|
||||
align := lipgloss.Left
|
||||
switch message.Role {
|
||||
case client.User:
|
||||
align = lipgloss.Right
|
||||
case client.Assistant:
|
||||
align = lipgloss.Left
|
||||
}
|
||||
|
||||
textWidth := max(lipgloss.Width(text), lipgloss.Width(info))
|
||||
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
|
||||
if message.Role == client.Assistant {
|
||||
markdownWidth = width - padding - 4
|
||||
}
|
||||
content := toMarkdown(text, markdownWidth, t.BackgroundSubtle())
|
||||
content = lipgloss.JoinVertical(align, content, info)
|
||||
content = strings.Join([]string{content, info}, "\n")
|
||||
|
||||
switch message.Role {
|
||||
case client.User:
|
||||
@@ -240,9 +251,11 @@ func renderToolInvocation(
|
||||
toolCall client.MessageToolInvocationToolCall,
|
||||
result *string,
|
||||
metadata client.MessageInfo_Metadata_Tool_AdditionalProperties,
|
||||
showResult bool,
|
||||
showDetails bool,
|
||||
isLast bool,
|
||||
contentOnly bool,
|
||||
) string {
|
||||
ignoredTools := []string{"opencode_todoread"}
|
||||
ignoredTools := []string{"todoread"}
|
||||
if slices.Contains(ignoredTools, toolCall.ToolName) {
|
||||
return ""
|
||||
}
|
||||
@@ -251,7 +264,7 @@ func renderToolInvocation(
|
||||
innerWidth := outerWidth - 6
|
||||
paddingTop := 0
|
||||
paddingBottom := 0
|
||||
if showResult {
|
||||
if showDetails {
|
||||
paddingTop = 1
|
||||
if result == nil || *result == "" {
|
||||
paddingBottom = 1
|
||||
@@ -268,34 +281,49 @@ func renderToolInvocation(
|
||||
PaddingRight(2).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
if toolCall.State == "partial-call" {
|
||||
title := renderToolAction(toolCall.ToolName)
|
||||
if !showDetails {
|
||||
title = "∟ " + title
|
||||
padding := calculatePadding()
|
||||
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundSubtle())
|
||||
return renderContentBlock(style.Render(title),
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(t.Accent()),
|
||||
WithPaddingTop(0),
|
||||
WithPaddingBottom(1),
|
||||
)
|
||||
}
|
||||
|
||||
style = style.Foreground(t.TextMuted())
|
||||
return style.Render(renderToolAction(toolCall.ToolName))
|
||||
return style.Render(title)
|
||||
}
|
||||
|
||||
toolArgs := ""
|
||||
toolArgsMap := make(map[string]any)
|
||||
if toolCall.Args != nil {
|
||||
value := *toolCall.Args
|
||||
m, ok := value.(map[string]any)
|
||||
if ok {
|
||||
if m, ok := value.(map[string]any); ok {
|
||||
toolArgsMap = m
|
||||
firstKey := ""
|
||||
|
||||
keys := make([]string, 0, len(toolArgsMap))
|
||||
for key := range toolArgsMap {
|
||||
firstKey = key
|
||||
break
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
firstKey := ""
|
||||
if len(keys) > 0 {
|
||||
firstKey = keys[0]
|
||||
}
|
||||
|
||||
toolArgs = renderArgs(&toolArgsMap, firstKey)
|
||||
}
|
||||
}
|
||||
|
||||
if len(toolArgsMap) == 0 {
|
||||
slog.Debug("no args")
|
||||
}
|
||||
|
||||
body := ""
|
||||
error := ""
|
||||
finished := result != nil && *result != ""
|
||||
@@ -316,30 +344,19 @@ func renderToolInvocation(
|
||||
}
|
||||
}
|
||||
|
||||
elapsed := ""
|
||||
start := metadata.Time.Start
|
||||
end := metadata.Time.End
|
||||
durationMs := end - start
|
||||
duration := time.Duration(durationMs * float32(time.Millisecond))
|
||||
roundedDuration := time.Duration(duration.Round(time.Millisecond))
|
||||
if durationMs > 1000 {
|
||||
roundedDuration = time.Duration(duration.Round(time.Second))
|
||||
}
|
||||
elapsed = styles.Muted().Render(roundedDuration.String())
|
||||
|
||||
title := ""
|
||||
switch toolCall.ToolName {
|
||||
case "opencode_read":
|
||||
case "read":
|
||||
toolArgs = renderArgs(&toolArgsMap, "filePath")
|
||||
title = fmt.Sprintf("Read: %s %s", toolArgs, elapsed)
|
||||
title = fmt.Sprintf("READ %s", toolArgs)
|
||||
if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
|
||||
filename := toolArgsMap["filePath"].(string)
|
||||
body = preview.(string)
|
||||
body = renderFile(filename, body, WithTruncate(6))
|
||||
}
|
||||
case "opencode_edit":
|
||||
case "edit":
|
||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
||||
title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed)
|
||||
title = fmt.Sprintf("EDIT %s", relative(filename))
|
||||
if d, ok := metadata.Get("diff"); ok {
|
||||
patch := d.(string)
|
||||
var formattedDiff string
|
||||
@@ -356,12 +373,13 @@ func renderToolInvocation(
|
||||
formattedDiff = strings.TrimSpace(formattedDiff)
|
||||
formattedDiff = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
Render(formattedDiff)
|
||||
|
||||
if showResult {
|
||||
if showDetails {
|
||||
style = style.Width(lipgloss.Width(formattedDiff))
|
||||
title += "\n"
|
||||
}
|
||||
@@ -373,19 +391,20 @@ func renderToolInvocation(
|
||||
lipgloss.Center,
|
||||
lipgloss.Top,
|
||||
body,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
}
|
||||
}
|
||||
case "opencode_write":
|
||||
case "write":
|
||||
if filename, ok := toolArgsMap["filePath"].(string); ok {
|
||||
title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed)
|
||||
title = fmt.Sprintf("WRITE %s", relative(filename))
|
||||
if content, ok := toolArgsMap["content"].(string); ok {
|
||||
body = renderFile(filename, content)
|
||||
}
|
||||
}
|
||||
case "opencode_bash":
|
||||
case "bash":
|
||||
if description, ok := toolArgsMap["description"].(string); ok {
|
||||
title = fmt.Sprintf("Shell: %s %s", description, elapsed)
|
||||
title = fmt.Sprintf("SHELL %s", description)
|
||||
}
|
||||
if stdout, ok := metadata.Get("stdout"); ok {
|
||||
command := toolArgsMap["command"].(string)
|
||||
@@ -394,9 +413,9 @@ func renderToolInvocation(
|
||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
case "opencode_webfetch":
|
||||
case "webfetch":
|
||||
toolArgs = renderArgs(&toolArgsMap, "url")
|
||||
title = fmt.Sprintf("Fetching: %s %s", toolArgs, elapsed)
|
||||
title = fmt.Sprintf("FETCH %s", toolArgs)
|
||||
if format, ok := toolArgsMap["format"].(string); ok {
|
||||
body = *result
|
||||
body = truncateHeight(body, 10)
|
||||
@@ -405,8 +424,8 @@ func renderToolInvocation(
|
||||
}
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
case "opencode_todowrite":
|
||||
title = fmt.Sprintf("Planning %s", elapsed)
|
||||
case "todowrite":
|
||||
title = fmt.Sprintf("PLAN")
|
||||
|
||||
if to, ok := metadata.Get("todos"); ok && finished {
|
||||
todos := to.([]any)
|
||||
@@ -425,14 +444,76 @@ func renderToolInvocation(
|
||||
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
case "task":
|
||||
if description, ok := toolArgsMap["description"].(string); ok {
|
||||
title = fmt.Sprintf("TASK %s", description)
|
||||
if summary, ok := metadata.Get("summary"); ok {
|
||||
toolcalls := summary.([]any)
|
||||
// toolcalls :=
|
||||
|
||||
steps := []string{}
|
||||
for _, toolcall := range toolcalls {
|
||||
call := toolcall.(map[string]any)
|
||||
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
|
||||
data, _ := json.Marshal(toolInvocation)
|
||||
var toolCall client.MessageToolInvocationToolCall
|
||||
_ = json.Unmarshal(data, &toolCall)
|
||||
|
||||
if metadata, ok := call["metadata"].(map[string]any); ok {
|
||||
data, _ = json.Marshal(metadata)
|
||||
var toolMetadata client.MessageInfo_Metadata_Tool_AdditionalProperties
|
||||
_ = json.Unmarshal(data, &toolMetadata)
|
||||
|
||||
step := renderToolInvocation(
|
||||
toolCall,
|
||||
nil,
|
||||
toolMetadata,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
steps = append(steps, step)
|
||||
}
|
||||
}
|
||||
}
|
||||
body = strings.Join(steps, "\n")
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
toolName := renderToolName(toolCall.ToolName)
|
||||
title = fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed)
|
||||
title = fmt.Sprintf("%s %s", toolName, toolArgs)
|
||||
if result == nil {
|
||||
empty := ""
|
||||
result = &empty
|
||||
}
|
||||
body = *result
|
||||
body = truncateHeight(body, 10)
|
||||
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
|
||||
}
|
||||
|
||||
if contentOnly {
|
||||
title = "∟ " + title
|
||||
return title
|
||||
}
|
||||
|
||||
if !showDetails {
|
||||
title = "∟ " + title
|
||||
padding := calculatePadding()
|
||||
style := lipgloss.NewStyle().Width(outerWidth - padding - 4).Background(t.BackgroundSubtle())
|
||||
paddingBottom := 0
|
||||
if isLast {
|
||||
paddingBottom = 1
|
||||
}
|
||||
return renderContentBlock(style.Render(title),
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(t.Accent()),
|
||||
WithPaddingTop(0),
|
||||
WithPaddingBottom(paddingBottom),
|
||||
)
|
||||
}
|
||||
|
||||
if body == "" && error == "" {
|
||||
body = *result
|
||||
body = truncateHeight(body, 10)
|
||||
@@ -440,11 +521,16 @@ func renderToolInvocation(
|
||||
}
|
||||
|
||||
content := style.Render(title)
|
||||
content = lipgloss.PlaceHorizontal(layout.Current.Viewport.Width, lipgloss.Center, content)
|
||||
if showResult && body != "" && error == "" {
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
layout.Current.Viewport.Width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
if showDetails && body != "" && error == "" {
|
||||
content += "\n" + body
|
||||
}
|
||||
if showResult && error != "" {
|
||||
if showDetails && error != "" {
|
||||
content += "\n" + error
|
||||
}
|
||||
return content
|
||||
@@ -452,22 +538,18 @@ func renderToolInvocation(
|
||||
|
||||
func renderToolName(name string) string {
|
||||
switch name {
|
||||
// case agent.AgentToolName:
|
||||
// return "Task"
|
||||
case "opencode_ls":
|
||||
return "List"
|
||||
case "opencode_webfetch":
|
||||
return "Fetch"
|
||||
case "opencode_todoread":
|
||||
return "Planning"
|
||||
case "opencode_todowrite":
|
||||
return "Planning"
|
||||
case "list":
|
||||
return "LIST"
|
||||
case "webfetch":
|
||||
return "FETCH"
|
||||
case "todowrite":
|
||||
return "PLAN"
|
||||
default:
|
||||
normalizedName := name
|
||||
if strings.HasPrefix(name, "opencode_") {
|
||||
normalizedName = strings.TrimPrefix(name, "opencode_")
|
||||
}
|
||||
return cases.Title(language.Und).String(normalizedName)
|
||||
return cases.Upper(language.Und).String(normalizedName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,27 +597,29 @@ func renderFile(filename string, content string, options ...fileRenderingOption)
|
||||
|
||||
func renderToolAction(name string) string {
|
||||
switch name {
|
||||
// case agent.AgentToolName:
|
||||
// return "Preparing prompt..."
|
||||
case "opencode_bash":
|
||||
case "task":
|
||||
return "Searching..."
|
||||
case "bash":
|
||||
return "Building command..."
|
||||
case "opencode_edit":
|
||||
case "edit":
|
||||
return "Preparing edit..."
|
||||
case "opencode_fetch":
|
||||
case "webfetch":
|
||||
return "Writing fetch..."
|
||||
case "opencode_glob":
|
||||
case "glob":
|
||||
return "Finding files..."
|
||||
case "opencode_grep":
|
||||
case "grep":
|
||||
return "Searching content..."
|
||||
case "opencode_ls":
|
||||
case "list":
|
||||
return "Listing directory..."
|
||||
case "opencode_read":
|
||||
case "read":
|
||||
return "Reading file..."
|
||||
case "opencode_write":
|
||||
case "write":
|
||||
return "Preparing write..."
|
||||
case "opencode_patch":
|
||||
case "todowrite", "todoread":
|
||||
return "Planning..."
|
||||
case "patch":
|
||||
return "Preparing patch..."
|
||||
case "opencode_batch":
|
||||
case "batch":
|
||||
return "Running batch operations..."
|
||||
}
|
||||
return "Working..."
|
||||
@@ -545,9 +629,17 @@ func renderArgs(args *map[string]any, titleKey string) string {
|
||||
if args == nil || len(*args) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(*args))
|
||||
for key := range *args {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
|
||||
title := ""
|
||||
parts := []string{}
|
||||
for key, value := range *args {
|
||||
for _, key := range keys {
|
||||
value := (*args)[key]
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
@@ -575,7 +667,7 @@ func truncateHeight(content string, height int) string {
|
||||
}
|
||||
|
||||
func relative(path string) string {
|
||||
return strings.TrimPrefix(path, app.Info.Path.Root+"/")
|
||||
return strings.TrimPrefix(path, app.RootPath+"/")
|
||||
}
|
||||
|
||||
func extension(path string) string {
|
||||
|
||||
@@ -1,116 +1,105 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
type MessagesComponent interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
PageUp() (tea.Model, tea.Cmd)
|
||||
PageDown() (tea.Model, tea.Cmd)
|
||||
HalfPageUp() (tea.Model, tea.Cmd)
|
||||
HalfPageDown() (tea.Model, tea.Cmd)
|
||||
First() (tea.Model, tea.Cmd)
|
||||
Last() (tea.Model, tea.Cmd)
|
||||
// Previous() (tea.Model, tea.Cmd)
|
||||
// Next() (tea.Model, tea.Cmd)
|
||||
ToolDetailsVisible() bool
|
||||
}
|
||||
|
||||
type messagesComponent struct {
|
||||
app *app.App
|
||||
width, height int
|
||||
app *app.App
|
||||
viewport viewport.Model
|
||||
spinner spinner.Model
|
||||
rendering bool
|
||||
attachments viewport.Model
|
||||
showToolResults bool
|
||||
commands commands.CommandsComponent
|
||||
cache *MessageCache
|
||||
rendering bool
|
||||
showToolDetails bool
|
||||
tail bool
|
||||
}
|
||||
type renderFinishedMsg struct{}
|
||||
type ToggleToolMessagesMsg struct{}
|
||||
|
||||
type MessageKeys struct {
|
||||
PageDown key.Binding
|
||||
PageUp key.Binding
|
||||
HalfPageUp key.Binding
|
||||
HalfPageDown key.Binding
|
||||
}
|
||||
|
||||
var messageKeys = MessageKeys{
|
||||
PageDown: key.NewBinding(
|
||||
key.WithKeys("pgdown"),
|
||||
key.WithHelp("f/pgdn", "page down"),
|
||||
),
|
||||
PageUp: key.NewBinding(
|
||||
key.WithKeys("pgup"),
|
||||
key.WithHelp("b/pgup", "page up"),
|
||||
),
|
||||
HalfPageUp: key.NewBinding(
|
||||
key.WithKeys("ctrl+u"),
|
||||
key.WithHelp("ctrl+u", "½ page up"),
|
||||
),
|
||||
HalfPageDown: key.NewBinding(
|
||||
key.WithKeys("ctrl+d", "ctrl+d"),
|
||||
key.WithHelp("ctrl+d", "½ page down"),
|
||||
),
|
||||
}
|
||||
type ToggleToolDetailsMsg struct{}
|
||||
|
||||
func (m *messagesComponent) Init() tea.Cmd {
|
||||
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
|
||||
return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.commands.Init())
|
||||
}
|
||||
|
||||
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case SendMsg:
|
||||
switch msg.(type) {
|
||||
case app.SendMsg:
|
||||
m.viewport.GotoBottom()
|
||||
m.tail = true
|
||||
return m, nil
|
||||
case dialog.ThemeChangedMsg:
|
||||
case dialog.ThemeSelectedMsg:
|
||||
m.cache.Clear()
|
||||
m.renderView()
|
||||
return m, nil
|
||||
case ToggleToolMessagesMsg:
|
||||
m.showToolResults = !m.showToolResults
|
||||
case ToggleToolDetailsMsg:
|
||||
m.showToolDetails = !m.showToolDetails
|
||||
m.renderView()
|
||||
return m, nil
|
||||
case state.SessionSelectedMsg:
|
||||
case app.SessionSelectedMsg:
|
||||
m.cache.Clear()
|
||||
cmd := m.Reload()
|
||||
m.viewport.GotoBottom()
|
||||
return m, cmd
|
||||
case state.SessionClearedMsg:
|
||||
case app.SessionClearedMsg:
|
||||
m.cache.Clear()
|
||||
cmd := m.Reload()
|
||||
return m, cmd
|
||||
case tea.KeyMsg:
|
||||
if key.Matches(msg, messageKeys.PageUp) ||
|
||||
key.Matches(msg, messageKeys.PageDown) ||
|
||||
key.Matches(msg, messageKeys.HalfPageUp) ||
|
||||
key.Matches(msg, messageKeys.HalfPageDown) {
|
||||
u, cmd := m.viewport.Update(msg)
|
||||
m.viewport = u
|
||||
m.tail = m.viewport.AtBottom()
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
case renderFinishedMsg:
|
||||
m.rendering = false
|
||||
if m.tail {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
case state.StateUpdatedMsg:
|
||||
case client.EventSessionUpdated, client.EventMessageUpdated:
|
||||
m.renderView()
|
||||
if m.tail {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
}
|
||||
|
||||
viewport, cmd := m.viewport.Update(msg)
|
||||
m.viewport = viewport
|
||||
m.tail = m.viewport.AtBottom()
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
spinner, cmd := m.spinner.Update(msg)
|
||||
m.spinner = spinner
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
updated, cmd := m.commands.Update(msg)
|
||||
m.commands = updated.(commands.CommandsComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
@@ -135,16 +124,27 @@ func (m *messagesComponent) renderView() {
|
||||
for _, message := range m.app.Messages {
|
||||
var content string
|
||||
var cached bool
|
||||
lastToolIndex := 0
|
||||
lastToolIndices := []int{}
|
||||
for i, p := range message.Parts {
|
||||
part, _ := p.ValueByDiscriminator()
|
||||
switch part.(type) {
|
||||
case client.MessagePartText:
|
||||
lastToolIndices = append(lastToolIndices, lastToolIndex)
|
||||
case client.MessagePartToolInvocation:
|
||||
lastToolIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
author := ""
|
||||
switch message.Role {
|
||||
case client.User:
|
||||
author = app.Info.User
|
||||
author = m.app.Info.User
|
||||
case client.Assistant:
|
||||
author = message.Metadata.Assistant.ModelID
|
||||
}
|
||||
|
||||
for _, p := range message.Parts {
|
||||
for i, p := range message.Parts {
|
||||
part, err := p.ValueByDiscriminator()
|
||||
if err != nil {
|
||||
continue //TODO: handle error?
|
||||
@@ -171,6 +171,7 @@ func (m *messagesComponent) renderView() {
|
||||
previousBlockType = assistantTextBlock
|
||||
}
|
||||
case client.MessagePartToolInvocation:
|
||||
isLastToolInvocation := slices.Contains(lastToolIndices, i)
|
||||
toolInvocationPart := part.(client.MessagePartToolInvocation)
|
||||
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
|
||||
metadata := client.MessageInfo_Metadata_Tool_AdditionalProperties{}
|
||||
@@ -186,20 +187,34 @@ func (m *messagesComponent) renderView() {
|
||||
if toolCall.State == "result" {
|
||||
key := m.cache.GenerateKey(message.Id,
|
||||
toolCall.ToolCallId,
|
||||
m.showToolResults,
|
||||
m.showToolDetails,
|
||||
layout.Current.Viewport.Width,
|
||||
)
|
||||
content, cached = m.cache.Get(key)
|
||||
if !cached {
|
||||
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
|
||||
content = renderToolInvocation(
|
||||
toolCall,
|
||||
result,
|
||||
metadata,
|
||||
m.showToolDetails,
|
||||
isLastToolInvocation,
|
||||
false,
|
||||
)
|
||||
m.cache.Set(key, content)
|
||||
}
|
||||
} else {
|
||||
// if the tool call isn't finished, never cache
|
||||
content = renderToolInvocation(toolCall, result, metadata, m.showToolResults)
|
||||
// if the tool call isn't finished, don't cache
|
||||
content = renderToolInvocation(
|
||||
toolCall,
|
||||
result,
|
||||
metadata,
|
||||
m.showToolDetails,
|
||||
isLastToolInvocation,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
if previousBlockType != toolInvocationBlock {
|
||||
if previousBlockType != toolInvocationBlock && m.showToolDetails {
|
||||
blocks = append(blocks, "")
|
||||
}
|
||||
blocks = append(blocks, content)
|
||||
@@ -242,8 +257,8 @@ func (m *messagesComponent) header() string {
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
width := layout.Current.Container.Width
|
||||
base := styles.BaseStyle().Render
|
||||
muted := styles.Muted().Render
|
||||
base := styles.BaseStyle().Background(t.Background()).Render
|
||||
muted := styles.Muted().Background(t.Background()).Render
|
||||
headerLines := []string{}
|
||||
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
|
||||
if m.app.Session.Share != nil && m.app.Session.Share.Url != "" {
|
||||
@@ -257,11 +272,11 @@ func (m *messagesComponent) header() string {
|
||||
Width(width).
|
||||
PaddingLeft(2).
|
||||
PaddingRight(2).
|
||||
// Background(t.BackgroundElement()).
|
||||
Background(t.Background()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
BorderForeground(t.BackgroundElement()).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
Render(header)
|
||||
|
||||
@@ -273,25 +288,33 @@ func (m *messagesComponent) View() string {
|
||||
return m.home()
|
||||
}
|
||||
if m.rendering {
|
||||
return m.viewport.View()
|
||||
return lipgloss.Place(
|
||||
m.width,
|
||||
m.height,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
"Loading session...",
|
||||
)
|
||||
}
|
||||
t := theme.CurrentTheme()
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
lipgloss.PlaceHorizontal(m.width, lipgloss.Center, m.header()),
|
||||
lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
m.header(),
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
),
|
||||
m.viewport.View(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesComponent) home() string {
|
||||
// t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle().Background(t.Background())
|
||||
base := baseStyle.Render
|
||||
muted := styles.Muted().Render
|
||||
muted := styles.Muted().Background(t.Background()).Render
|
||||
|
||||
// mark := `
|
||||
// ███▀▀█
|
||||
// ███ █
|
||||
// ▀▀▀▀▀▀ `
|
||||
open := `
|
||||
█▀▀█ █▀▀█ █▀▀ █▀▀▄
|
||||
█░░█ █░░█ █▀▀ █░░█
|
||||
@@ -303,56 +326,50 @@ func (m *messagesComponent) home() string {
|
||||
|
||||
logo := lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
// styles.BaseStyle().Foreground(t.Primary()).Render(mark),
|
||||
styles.Muted().Render(open),
|
||||
styles.BaseStyle().Render(code),
|
||||
muted(open),
|
||||
base(code),
|
||||
)
|
||||
// cwd := app.Info.Path.Cwd
|
||||
// config := app.Info.Path.Config
|
||||
|
||||
commands := [][]string{
|
||||
{"/help", "show help"},
|
||||
{"/sessions", "list sessions"},
|
||||
{"/new", "start a new session"},
|
||||
{"/model", "switch model"},
|
||||
{"/theme", "switch theme"},
|
||||
{"/quit", "exit the app"},
|
||||
}
|
||||
versionStyle := lipgloss.NewStyle().
|
||||
Background(t.Background()).
|
||||
Foreground(t.TextMuted()).
|
||||
Width(lipgloss.Width(logo)).
|
||||
Align(lipgloss.Right)
|
||||
version := versionStyle.Render(m.app.Version)
|
||||
|
||||
commandLines := []string{}
|
||||
for _, command := range commands {
|
||||
commandLines = append(commandLines, (base(command[0]) + " " + muted(command[1])))
|
||||
}
|
||||
|
||||
logoAndVersion := lipgloss.JoinVertical(
|
||||
lipgloss.Right,
|
||||
logo,
|
||||
muted(app.Info.Version),
|
||||
logoAndVersion := strings.Join([]string{logo, version}, "\n")
|
||||
logoAndVersion = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
logoAndVersion,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
commands := lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
m.commands.View(),
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
|
||||
lines := []string{}
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, logoAndVersion)
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, "")
|
||||
// lines = append(lines, base("cwd ")+muted(cwd))
|
||||
// lines = append(lines, base("config ")+muted(config))
|
||||
// lines = append(lines, "")
|
||||
lines = append(lines, commandLines...)
|
||||
lines = append(lines, "")
|
||||
if m.rendering {
|
||||
lines = append(lines, styles.Muted().Render("Loading session..."))
|
||||
} else {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, commands)
|
||||
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
||||
baseStyle.Width(lipgloss.Width(logoAndVersion)).Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
lines...,
|
||||
),
|
||||
))
|
||||
return lipgloss.Place(
|
||||
m.width,
|
||||
m.height,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
baseStyle.Render(strings.Join(lines, "\n")),
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
|
||||
@@ -369,6 +386,7 @@ func (m *messagesComponent) SetSize(width, height int) tea.Cmd {
|
||||
m.viewport.SetHeight(height - lipgloss.Height(m.header()))
|
||||
m.attachments.SetWidth(width + 40)
|
||||
m.attachments.SetHeight(3)
|
||||
m.commands.SetSize(width, height)
|
||||
m.renderView()
|
||||
return nil
|
||||
}
|
||||
@@ -385,26 +403,67 @@ func (m *messagesComponent) Reload() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func NewMessagesComponent(app *app.App) layout.ModelWithView {
|
||||
func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) {
|
||||
m.viewport.ViewUp()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) {
|
||||
m.viewport.ViewDown()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) {
|
||||
m.viewport.HalfViewUp()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
|
||||
m.viewport.HalfViewDown()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *messagesComponent) First() (tea.Model, tea.Cmd) {
|
||||
m.viewport.GotoTop()
|
||||
m.tail = false
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *messagesComponent) Last() (tea.Model, tea.Cmd) {
|
||||
m.viewport.GotoBottom()
|
||||
m.tail = true
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *messagesComponent) ToolDetailsVisible() bool {
|
||||
return m.showToolDetails
|
||||
}
|
||||
|
||||
func NewMessagesComponent(app *app.App) MessagesComponent {
|
||||
customSpinner := spinner.Spinner{
|
||||
Frames: []string{" ", "┃", "┃"},
|
||||
FPS: time.Second / 3,
|
||||
}
|
||||
s := spinner.New(spinner.WithSpinner(customSpinner))
|
||||
|
||||
vp := viewport.New() //(0, 0)
|
||||
attachments := viewport.New() //(0, 0)
|
||||
vp.KeyMap.PageUp = messageKeys.PageUp
|
||||
vp.KeyMap.PageDown = messageKeys.PageDown
|
||||
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
|
||||
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
|
||||
vp := viewport.New()
|
||||
attachments := viewport.New()
|
||||
vp.KeyMap = viewport.KeyMap{}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
commandsView := commands.New(
|
||||
app,
|
||||
commands.WithBackground(t.Background()),
|
||||
commands.WithLimit(6),
|
||||
)
|
||||
|
||||
return &messagesComponent{
|
||||
app: app,
|
||||
viewport: vp,
|
||||
spinner: s,
|
||||
attachments: attachments,
|
||||
showToolResults: true,
|
||||
commands: commandsView,
|
||||
showToolDetails: true,
|
||||
cache: NewMessageCache(),
|
||||
tail: true,
|
||||
}
|
||||
|
||||
196
packages/tui/internal/components/commands/commands.go
Normal file
196
packages/tui/internal/components/commands/commands.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type CommandsComponent interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
layout.Sizeable
|
||||
}
|
||||
|
||||
type commandsComponent struct {
|
||||
app *app.App
|
||||
width, height int
|
||||
showKeybinds bool
|
||||
background *compat.AdaptiveColor
|
||||
limit *int
|
||||
}
|
||||
|
||||
func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
|
||||
c.width = width
|
||||
c.height = height
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *commandsComponent) GetSize() (int, int) {
|
||||
return c.width, c.height
|
||||
}
|
||||
|
||||
func (c *commandsComponent) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *commandsComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
c.width = msg.Width
|
||||
c.height = msg.Height
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *commandsComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
triggerStyle := lipgloss.NewStyle().
|
||||
Foreground(t.Primary()).
|
||||
Bold(true)
|
||||
|
||||
descriptionStyle := lipgloss.NewStyle().
|
||||
Foreground(t.Text())
|
||||
|
||||
keybindStyle := lipgloss.NewStyle().
|
||||
Foreground(t.TextMuted())
|
||||
|
||||
if c.background != nil {
|
||||
triggerStyle = triggerStyle.Background(*c.background)
|
||||
descriptionStyle = descriptionStyle.Background(*c.background)
|
||||
keybindStyle = keybindStyle.Background(*c.background)
|
||||
}
|
||||
|
||||
var commandsWithTriggers []commands.Command
|
||||
for _, cmd := range c.app.Commands.Sorted() {
|
||||
if cmd.Trigger != "" {
|
||||
commandsWithTriggers = append(commandsWithTriggers, cmd)
|
||||
}
|
||||
}
|
||||
if c.limit != nil && len(commandsWithTriggers) > *c.limit {
|
||||
commandsWithTriggers = commandsWithTriggers[:*c.limit]
|
||||
}
|
||||
|
||||
if len(commandsWithTriggers) == 0 {
|
||||
return styles.Muted().Render("No commands with triggers available")
|
||||
}
|
||||
|
||||
// Calculate column widths
|
||||
maxTriggerWidth := 0
|
||||
maxDescriptionWidth := 0
|
||||
maxKeybindWidth := 0
|
||||
|
||||
// Prepare command data
|
||||
type commandRow struct {
|
||||
trigger string
|
||||
description string
|
||||
keybinds string
|
||||
}
|
||||
|
||||
rows := make([]commandRow, 0, len(commandsWithTriggers))
|
||||
|
||||
for _, cmd := range commandsWithTriggers {
|
||||
trigger := "/" + cmd.Trigger
|
||||
description := cmd.Description
|
||||
|
||||
// Format keybindings
|
||||
var keybindStrs []string
|
||||
if c.showKeybinds {
|
||||
for _, kb := range cmd.Keybindings {
|
||||
if kb.RequiresLeader {
|
||||
keybindStrs = append(keybindStrs, *c.app.Config.Keybinds.Leader+" "+kb.Key)
|
||||
} else {
|
||||
keybindStrs = append(keybindStrs, kb.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
keybinds := strings.Join(keybindStrs, ", ")
|
||||
|
||||
rows = append(rows, commandRow{
|
||||
trigger: trigger,
|
||||
description: description,
|
||||
keybinds: keybinds,
|
||||
})
|
||||
|
||||
// Update max widths
|
||||
if len(trigger) > maxTriggerWidth {
|
||||
maxTriggerWidth = len(trigger)
|
||||
}
|
||||
if len(description) > maxDescriptionWidth {
|
||||
maxDescriptionWidth = len(description)
|
||||
}
|
||||
if len(keybinds) > maxKeybindWidth {
|
||||
maxKeybindWidth = len(keybinds)
|
||||
}
|
||||
}
|
||||
|
||||
// Add padding between columns
|
||||
columnPadding := 3
|
||||
|
||||
// Build the output
|
||||
var output strings.Builder
|
||||
|
||||
for _, row := range rows {
|
||||
// Pad each column to align properly
|
||||
trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger)
|
||||
description := fmt.Sprintf("%-*s", maxDescriptionWidth, row.description)
|
||||
|
||||
// Apply styles and combine
|
||||
line := triggerStyle.Render(trigger) +
|
||||
triggerStyle.Render(strings.Repeat(" ", columnPadding)) +
|
||||
descriptionStyle.Render(description)
|
||||
|
||||
if c.showKeybinds && row.keybinds != "" {
|
||||
line += keybindStyle.Render(strings.Repeat(" ", columnPadding)) +
|
||||
keybindStyle.Render(row.keybinds)
|
||||
}
|
||||
|
||||
output.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
// Remove trailing newline
|
||||
result := strings.TrimSuffix(output.String(), "\n")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type Option func(*commandsComponent)
|
||||
|
||||
func WithKeybinds(show bool) Option {
|
||||
return func(c *commandsComponent) {
|
||||
c.showKeybinds = show
|
||||
}
|
||||
}
|
||||
|
||||
func WithBackground(background compat.AdaptiveColor) Option {
|
||||
return func(c *commandsComponent) {
|
||||
c.background = &background
|
||||
}
|
||||
}
|
||||
|
||||
func WithLimit(limit int) Option {
|
||||
return func(c *commandsComponent) {
|
||||
c.limit = &limit
|
||||
}
|
||||
}
|
||||
|
||||
func New(app *app.App, opts ...Option) CommandsComponent {
|
||||
c := &commandsComponent{
|
||||
app: app,
|
||||
background: nil,
|
||||
showKeybinds: true,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/pubsub"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type StatusComponent interface {
|
||||
layout.ModelWithView
|
||||
}
|
||||
|
||||
type statusComponent struct {
|
||||
app *app.App
|
||||
queue []status.StatusMessage
|
||||
width int
|
||||
messageTTL time.Duration
|
||||
activeUntil time.Time
|
||||
}
|
||||
|
||||
// clearMessageCmd is a command that clears status messages after a timeout
|
||||
func (m statusComponent) clearMessageCmd() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return statusCleanupMsg{time: t}
|
||||
})
|
||||
}
|
||||
|
||||
// statusCleanupMsg is a message that triggers cleanup of expired status messages
|
||||
type statusCleanupMsg struct {
|
||||
time time.Time
|
||||
}
|
||||
|
||||
func (m statusComponent) Init() tea.Cmd {
|
||||
return m.clearMessageCmd()
|
||||
}
|
||||
|
||||
func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
return m, nil
|
||||
case pubsub.Event[status.StatusMessage]:
|
||||
if msg.Type == status.EventStatusPublished {
|
||||
// If this is a critical message, move it to the front of the queue
|
||||
if msg.Payload.Critical {
|
||||
// Insert at the front of the queue
|
||||
m.queue = append([]status.StatusMessage{msg.Payload}, m.queue...)
|
||||
|
||||
// Reset active time to show critical message immediately
|
||||
m.activeUntil = time.Time{}
|
||||
} else {
|
||||
// Otherwise, just add it to the queue
|
||||
m.queue = append(m.queue, msg.Payload)
|
||||
|
||||
// If this is the first message and nothing is active, activate it immediately
|
||||
if len(m.queue) == 1 && m.activeUntil.IsZero() {
|
||||
now := time.Now()
|
||||
duration := m.messageTTL
|
||||
if msg.Payload.Duration > 0 {
|
||||
duration = msg.Payload.Duration
|
||||
}
|
||||
m.activeUntil = now.Add(duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
case statusCleanupMsg:
|
||||
now := msg.time
|
||||
|
||||
// If the active message has expired, remove it and activate the next one
|
||||
if !m.activeUntil.IsZero() && m.activeUntil.Before(now) {
|
||||
// Current message expired, remove it if we have one
|
||||
if len(m.queue) > 0 {
|
||||
m.queue = m.queue[1:]
|
||||
}
|
||||
m.activeUntil = time.Time{}
|
||||
}
|
||||
|
||||
// If we have messages in queue but none are active, activate the first one
|
||||
if len(m.queue) > 0 && m.activeUntil.IsZero() {
|
||||
// Use custom duration if specified, otherwise use default
|
||||
duration := m.messageTTL
|
||||
if m.queue[0].Duration > 0 {
|
||||
duration = m.queue[0].Duration
|
||||
}
|
||||
m.activeUntil = now.Add(duration)
|
||||
}
|
||||
|
||||
return m, m.clearMessageCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func logo() string {
|
||||
t := theme.CurrentTheme()
|
||||
base := lipgloss.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render
|
||||
emphasis := lipgloss.NewStyle().Bold(true).Background(t.BackgroundElement()).Foreground(t.Text()).Render
|
||||
|
||||
open := base("open")
|
||||
code := emphasis("code ")
|
||||
version := base(app.Info.Version)
|
||||
return styles.Padded().
|
||||
Background(t.BackgroundElement()).
|
||||
Render(open + code + version)
|
||||
}
|
||||
|
||||
func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
|
||||
// Format tokens in human-readable format (e.g., 110K, 1.2M)
|
||||
var formattedTokens string
|
||||
switch {
|
||||
case tokens >= 1_000_000:
|
||||
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
|
||||
case tokens >= 1_000:
|
||||
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
|
||||
default:
|
||||
formattedTokens = fmt.Sprintf("%d", int(tokens))
|
||||
}
|
||||
|
||||
// Remove .0 suffix if present
|
||||
if strings.HasSuffix(formattedTokens, ".0K") {
|
||||
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
|
||||
}
|
||||
if strings.HasSuffix(formattedTokens, ".0M") {
|
||||
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
|
||||
}
|
||||
|
||||
// Format cost with $ symbol and 2 decimal places
|
||||
formattedCost := fmt.Sprintf("$%.2f", cost)
|
||||
percentage := (float64(tokens) / float64(contextWindow)) * 100
|
||||
|
||||
return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
|
||||
}
|
||||
|
||||
func (m statusComponent) View() string {
|
||||
if m.app.Session.Id == "" {
|
||||
return styles.BaseStyle().
|
||||
Width(m.width).
|
||||
Height(2).
|
||||
Render("")
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
logo := logo()
|
||||
|
||||
cwd := styles.Padded().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundSubtle()).
|
||||
Render(app.Info.Path.Cwd)
|
||||
|
||||
sessionInfo := ""
|
||||
if m.app.Session.Id != "" {
|
||||
tokens := float32(0)
|
||||
cost := float32(0)
|
||||
contextWindow := m.app.Model.Limit.Context
|
||||
|
||||
for _, message := range m.app.Messages {
|
||||
if message.Metadata.Assistant != nil {
|
||||
cost += message.Metadata.Assistant.Cost
|
||||
usage := message.Metadata.Assistant.Tokens
|
||||
if usage.Output > 0 {
|
||||
tokens = (usage.Input + usage.Output + usage.Reasoning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessionInfo = styles.Padded().
|
||||
Background(t.BackgroundElement()).
|
||||
Foreground(t.TextMuted()).
|
||||
Render(formatTokensAndCost(tokens, contextWindow, cost))
|
||||
}
|
||||
|
||||
// diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
|
||||
|
||||
space := max(
|
||||
0,
|
||||
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
|
||||
)
|
||||
spacer := lipgloss.NewStyle().Background(t.BackgroundSubtle()).Width(space).Render("")
|
||||
|
||||
status := logo + cwd + spacer + sessionInfo
|
||||
|
||||
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
|
||||
return blank + "\n" + status
|
||||
|
||||
// Display the first status message if available
|
||||
// var statusMessage string
|
||||
// if len(m.queue) > 0 {
|
||||
// sm := m.queue[0]
|
||||
// infoStyle := styles.Padded().
|
||||
// Foreground(t.Background())
|
||||
//
|
||||
// switch sm.Level {
|
||||
// case "info":
|
||||
// infoStyle = infoStyle.Background(t.Info())
|
||||
// case "warn":
|
||||
// infoStyle = infoStyle.Background(t.Warning())
|
||||
// case "error":
|
||||
// infoStyle = infoStyle.Background(t.Error())
|
||||
// case "debug":
|
||||
// infoStyle = infoStyle.Background(t.TextMuted())
|
||||
// }
|
||||
//
|
||||
// // Truncate message if it's longer than available width
|
||||
// msg := sm.Message
|
||||
// availWidth := statusWidth - 10
|
||||
//
|
||||
// // If we have enough space, show inline
|
||||
// if availWidth >= minInlineWidth {
|
||||
// if len(msg) > availWidth && availWidth > 0 {
|
||||
// msg = msg[:availWidth] + "..."
|
||||
// }
|
||||
// status += infoStyle.Width(statusWidth).Render(msg)
|
||||
// } else {
|
||||
// // Otherwise, prepare a full-width message to show above
|
||||
// if len(msg) > m.width-10 && m.width > 10 {
|
||||
// msg = msg[:m.width-10] + "..."
|
||||
// }
|
||||
// statusMessage = infoStyle.Width(m.width).Render(msg)
|
||||
//
|
||||
// // Add empty space in the status bar
|
||||
// status += styles.Padded().
|
||||
// Foreground(t.Text()).
|
||||
// Background(t.BackgroundSubtle()).
|
||||
// Width(statusWidth).
|
||||
// Render("")
|
||||
// }
|
||||
// } else {
|
||||
// status += styles.Padded().
|
||||
// Foreground(t.Text()).
|
||||
// Background(t.BackgroundSubtle()).
|
||||
// Width(statusWidth).
|
||||
// Render("")
|
||||
// }
|
||||
|
||||
// status += diagnostics
|
||||
// status += modelName
|
||||
|
||||
// If we have a separate status message, prepend it
|
||||
// if statusMessage != "" {
|
||||
// return statusMessage + "\n" + status
|
||||
// } else {
|
||||
// blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
|
||||
// return blank + "\n" + status
|
||||
// }
|
||||
}
|
||||
|
||||
func NewStatusCmp(app *app.App) StatusComponent {
|
||||
statusComponent := &statusComponent{
|
||||
app: app,
|
||||
queue: []status.StatusMessage{},
|
||||
messageTTL: 4 * time.Second,
|
||||
activeUntil: time.Time{},
|
||||
}
|
||||
|
||||
return statusComponent
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
"github.com/charmbracelet/bubbles/v2/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type CompletionItem struct {
|
||||
title string
|
||||
Title string
|
||||
Value string
|
||||
}
|
||||
@@ -36,8 +35,7 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
|
||||
|
||||
if selected {
|
||||
itemStyle = itemStyle.
|
||||
Foreground(t.Primary()).
|
||||
Bold(true)
|
||||
Foreground(t.Primary())
|
||||
}
|
||||
|
||||
title := itemStyle.Render(
|
||||
@@ -63,6 +61,7 @@ type CompletionProvider interface {
|
||||
GetId() string
|
||||
GetEntry() CompletionItemI
|
||||
GetChildEntries(query string) ([]CompletionItemI, error)
|
||||
GetEmptyMessage() string
|
||||
}
|
||||
|
||||
type CompletionSelectedMsg struct {
|
||||
@@ -78,7 +77,8 @@ type CompletionDialogCompleteItemMsg struct {
|
||||
type CompletionDialogCloseMsg struct{}
|
||||
|
||||
type CompletionDialog interface {
|
||||
layout.ModelWithView
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
SetWidth(width int)
|
||||
IsEmpty() bool
|
||||
SetProvider(provider CompletionProvider)
|
||||
@@ -100,10 +100,10 @@ type completionDialogKeyMap struct {
|
||||
|
||||
var completionDialogKeys = completionDialogKeyMap{
|
||||
Complete: key.NewBinding(
|
||||
key.WithKeys("tab", "enter"),
|
||||
key.WithKeys("tab", "enter", "right"),
|
||||
),
|
||||
Cancel: key.NewBinding(
|
||||
key.WithKeys(" ", "esc", "backspace"),
|
||||
key.WithKeys(" ", "esc", "backspace", "ctrl+c"),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -111,6 +111,116 @@ func (c *completionDialogComponent) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case []CompletionItemI:
|
||||
c.list.SetItems(msg)
|
||||
case app.CompletionDialogTriggerdMsg:
|
||||
c.pseudoSearchTextArea.SetValue(msg.InitialValue)
|
||||
case tea.KeyMsg:
|
||||
if c.pseudoSearchTextArea.Focused() {
|
||||
if !key.Matches(msg, completionDialogKeys.Complete) {
|
||||
var cmd tea.Cmd
|
||||
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
var query string
|
||||
query = c.pseudoSearchTextArea.Value()
|
||||
if query != "" {
|
||||
query = query[1:]
|
||||
}
|
||||
|
||||
if query != c.query {
|
||||
c.query = query
|
||||
cmd = func() tea.Msg {
|
||||
items, err := c.completionProvider.GetChildEntries(query)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
u, cmd := c.list.Update(msg)
|
||||
c.list = u.(list.List[CompletionItemI])
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, completionDialogKeys.Complete):
|
||||
item, i := c.list.GetSelectedItem()
|
||||
if i == -1 {
|
||||
return c, nil
|
||||
}
|
||||
return c, c.complete(item)
|
||||
case key.Matches(msg, completionDialogKeys.Cancel):
|
||||
// Only close on backspace when there are no characters left
|
||||
if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
|
||||
return c, c.close()
|
||||
}
|
||||
}
|
||||
|
||||
return c, tea.Batch(cmds...)
|
||||
} else {
|
||||
cmd := func() tea.Msg {
|
||||
items, err := c.completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
cmds = append(cmds, cmd)
|
||||
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
|
||||
// c.pseudoSearchTextArea.SetValue(msg.String())
|
||||
return c, tea.Batch(cmds...)
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
c.width = msg.Width
|
||||
c.height = msg.Height
|
||||
}
|
||||
|
||||
return c, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
maxWidth := 40
|
||||
completions := c.list.GetItems()
|
||||
|
||||
for _, cmd := range completions {
|
||||
title := cmd.DisplayValue()
|
||||
if len(title) > maxWidth-4 {
|
||||
maxWidth = len(title) + 4
|
||||
}
|
||||
}
|
||||
|
||||
c.list.SetMaxWidth(maxWidth)
|
||||
|
||||
return baseStyle.Padding(0, 0).
|
||||
Background(t.BackgroundElement()).
|
||||
Width(c.width).
|
||||
Render(c.list.View())
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) SetWidth(width int) {
|
||||
c.width = width
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) IsEmpty() bool {
|
||||
return c.list.IsEmpty()
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
|
||||
if c.completionProvider.GetId() != provider.GetId() {
|
||||
c.completionProvider = provider
|
||||
c.list.SetEmptyMessage(" " + provider.GetEmptyMessage())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
|
||||
value := c.pseudoSearchTextArea.Value()
|
||||
|
||||
@@ -139,133 +249,24 @@ func (c *completionDialogComponent) close() tea.Cmd {
|
||||
return util.CmdHandler(CompletionDialogCloseMsg{})
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if c.pseudoSearchTextArea.Focused() {
|
||||
if !key.Matches(msg, completionDialogKeys.Complete) {
|
||||
var cmd tea.Cmd
|
||||
c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
var query string
|
||||
query = c.pseudoSearchTextArea.Value()
|
||||
if query != "" {
|
||||
query = query[1:]
|
||||
}
|
||||
|
||||
if query != c.query {
|
||||
items, err := c.completionProvider.GetChildEntries(query)
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
}
|
||||
|
||||
c.list.SetItems(items)
|
||||
c.query = query
|
||||
}
|
||||
|
||||
u, cmd := c.list.Update(msg)
|
||||
c.list = u.(list.List[CompletionItemI])
|
||||
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, completionDialogKeys.Complete):
|
||||
item, i := c.list.GetSelectedItem()
|
||||
if i == -1 {
|
||||
return c, nil
|
||||
}
|
||||
return c, c.complete(item)
|
||||
case key.Matches(msg, completionDialogKeys.Cancel):
|
||||
// Only close on backspace when there are no characters left
|
||||
if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
|
||||
return c, c.close()
|
||||
}
|
||||
}
|
||||
|
||||
return c, tea.Batch(cmds...)
|
||||
} else {
|
||||
items, err := c.completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
}
|
||||
|
||||
c.list.SetItems(items)
|
||||
c.pseudoSearchTextArea.SetValue(msg.String())
|
||||
return c, c.pseudoSearchTextArea.Focus()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
c.width = msg.Width
|
||||
c.height = msg.Height
|
||||
}
|
||||
|
||||
return c, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
maxWidth := 40
|
||||
completions := c.list.GetItems()
|
||||
|
||||
for _, cmd := range completions {
|
||||
title := cmd.DisplayValue()
|
||||
if len(title) > maxWidth-4 {
|
||||
maxWidth = len(title) + 4
|
||||
}
|
||||
}
|
||||
|
||||
c.list.SetMaxWidth(maxWidth)
|
||||
|
||||
return baseStyle.Padding(0, 0).
|
||||
Background(t.BackgroundElement()).
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderTop(false).
|
||||
BorderBottom(false).
|
||||
BorderRight(true).
|
||||
BorderLeft(true).
|
||||
BorderForeground(t.BackgroundSubtle()).
|
||||
Width(c.width).
|
||||
Render(c.list.View())
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) SetWidth(width int) {
|
||||
c.width = width
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) IsEmpty() bool {
|
||||
return c.list.IsEmpty()
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
|
||||
if c.completionProvider.GetId() != provider.GetId() {
|
||||
c.completionProvider = provider
|
||||
items, err := provider.GetChildEntries("")
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
}
|
||||
c.list.SetItems(items)
|
||||
}
|
||||
}
|
||||
|
||||
func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
|
||||
ti := textarea.New()
|
||||
|
||||
items, err := completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
status.Error(err.Error())
|
||||
}
|
||||
|
||||
li := list.NewListComponent(
|
||||
items,
|
||||
[]CompletionItemI{},
|
||||
7,
|
||||
"No matches",
|
||||
completionProvider.GetEmptyMessage(),
|
||||
false,
|
||||
)
|
||||
|
||||
go func() {
|
||||
items, err := completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
}
|
||||
li.SetItems(items)
|
||||
}()
|
||||
|
||||
return &completionDialogComponent{
|
||||
query: "",
|
||||
completionProvider: completionProvider,
|
||||
|
||||
@@ -3,9 +3,9 @@ package dialog
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
@@ -15,28 +15,9 @@ type helpDialog struct {
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
bindings []key.Binding
|
||||
commands []commands.Command
|
||||
}
|
||||
|
||||
// func (i bindingItem) Render(selected bool, width int) string {
|
||||
// t := theme.CurrentTheme()
|
||||
// baseStyle := styles.BaseStyle().
|
||||
// Width(width - 2).
|
||||
// Background(t.BackgroundElement())
|
||||
//
|
||||
// if selected {
|
||||
// baseStyle = baseStyle.
|
||||
// Background(t.Primary()).
|
||||
// Foreground(t.BackgroundElement()).
|
||||
// Bold(true)
|
||||
// } else {
|
||||
// baseStyle = baseStyle.
|
||||
// Foreground(t.Text())
|
||||
// }
|
||||
//
|
||||
// return baseStyle.Padding(0, 1).Render(i.binding.Help().Desc)
|
||||
// }
|
||||
|
||||
func (h *helpDialog) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
@@ -63,19 +44,24 @@ func (h *helpDialog) View() string {
|
||||
PaddingLeft(1).Background(t.BackgroundElement())
|
||||
|
||||
lines := []string{}
|
||||
for _, b := range h.bindings {
|
||||
content := keyStyle.Render(b.Help().Key)
|
||||
content += descStyle.Render(" " + b.Help().Desc)
|
||||
for i, key := range b.Keys() {
|
||||
if i == 0 {
|
||||
keyString := " (" + strings.ToUpper(key) + ")"
|
||||
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
|
||||
// spacer := strings.Repeat(" ", space)
|
||||
// content += descStyle.Render(spacer)
|
||||
content += descStyle.Render(keyString)
|
||||
}
|
||||
for _, b := range h.commands {
|
||||
// Only interested in slash commands
|
||||
if b.Trigger == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
content := keyStyle.Render("/" + b.Trigger)
|
||||
content += descStyle.Render(" " + b.Description)
|
||||
// for i, key := range b.Keybindings {
|
||||
// if i == 0 {
|
||||
// keyString := " (" + key.Key + ")"
|
||||
// space := max(h.width-lipgloss.Width(content)-lipgloss.Width(keyString), 0)
|
||||
// spacer := strings.Repeat(" ", space)
|
||||
// content += descStyle.Render(spacer)
|
||||
// content += descStyle.Render(keyString)
|
||||
// }
|
||||
// }
|
||||
|
||||
lines = append(lines, contentStyle.Render(content))
|
||||
}
|
||||
|
||||
@@ -94,9 +80,9 @@ type HelpDialog interface {
|
||||
layout.Modal
|
||||
}
|
||||
|
||||
func NewHelpDialog(bindings ...key.Binding) HelpDialog {
|
||||
func NewHelpDialog(commands []commands.Command) HelpDialog {
|
||||
return &helpDialog{
|
||||
bindings: bindings,
|
||||
modal: modal.New(),
|
||||
commands: commands,
|
||||
modal: modal.New(modal.WithTitle("Help")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
@@ -115,7 +114,7 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(
|
||||
state.ModelSelectedMsg{
|
||||
app.ModelSelectedMsg{
|
||||
Provider: m.provider,
|
||||
Model: models[m.selectedIdx],
|
||||
}),
|
||||
@@ -181,6 +180,7 @@ func (m *modelDialog) switchProvider(offset int) {
|
||||
|
||||
m.hScrollOffset = newOffset
|
||||
m.provider = m.availableProviders[m.hScrollOffset]
|
||||
m.modal.SetTitle(fmt.Sprintf("Select %s Model", m.provider.Name))
|
||||
m.setupModelsForProvider(m.provider.Id)
|
||||
}
|
||||
|
||||
@@ -190,14 +190,6 @@ func (m *modelDialog) View() string {
|
||||
Background(t.BackgroundElement()).
|
||||
Foreground(t.Text())
|
||||
|
||||
// Capitalize first letter of provider name
|
||||
title := baseStyle.
|
||||
Foreground(t.Primary()).
|
||||
Bold(true).
|
||||
Width(maxDialogWidth).
|
||||
Padding(0, 0, 1).
|
||||
Render(fmt.Sprintf("Select %s Model", m.provider.Name))
|
||||
|
||||
// Render visible models
|
||||
endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models))
|
||||
modelItems := make([]string, 0, endIdx-m.scrollOffset)
|
||||
@@ -218,8 +210,9 @@ func (m *modelDialog) View() string {
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
|
||||
baseStyle.
|
||||
Width(maxDialogWidth).
|
||||
Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
|
||||
scrollIndicator,
|
||||
)
|
||||
|
||||
@@ -239,12 +232,7 @@ func (m *modelDialog) getScrollIndicators(maxWidth int) string {
|
||||
}
|
||||
|
||||
if m.hScrollPossible {
|
||||
if m.hScrollOffset > 0 {
|
||||
indicator = "← " + indicator
|
||||
}
|
||||
if m.hScrollOffset < len(m.availableProviders)-1 {
|
||||
indicator += "→"
|
||||
}
|
||||
indicator = "← " + indicator + "→"
|
||||
}
|
||||
|
||||
if indicator == "" {
|
||||
@@ -314,6 +302,6 @@ func NewModelDialog(app *app.App) ModelDialog {
|
||||
hScrollOffset: 0,
|
||||
hScrollPossible: len(availableProviders) > 1,
|
||||
provider: availableProviders[0],
|
||||
modal: modal.New(),
|
||||
modal: modal.New(modal.WithTitle(fmt.Sprintf("Select %s Model", availableProviders[0].Name))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
@@ -30,7 +29,8 @@ type PermissionResponseMsg struct {
|
||||
|
||||
// PermissionDialogComponent interface for permission dialog component
|
||||
type PermissionDialogComponent interface {
|
||||
layout.ModelWithView
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
// SetPermissions(permission permission.PermissionRequest) tea.Cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
const question = "Are you sure you want to quit?"
|
||||
|
||||
// QuitDialog interface for the quit confirmation dialog
|
||||
type QuitDialog interface {
|
||||
layout.Modal
|
||||
IsQuitDialog() bool
|
||||
}
|
||||
|
||||
type quitDialog struct {
|
||||
width int
|
||||
height int
|
||||
|
||||
modal *modal.Modal
|
||||
selectedNo bool
|
||||
}
|
||||
|
||||
type helpMapping struct {
|
||||
LeftRight key.Binding
|
||||
EnterSpace key.Binding
|
||||
Yes key.Binding
|
||||
No key.Binding
|
||||
}
|
||||
|
||||
var helpKeys = helpMapping{
|
||||
LeftRight: key.NewBinding(
|
||||
key.WithKeys("left", "right", "h", "l", "tab"),
|
||||
key.WithHelp("←/→", "switch options"),
|
||||
),
|
||||
EnterSpace: key.NewBinding(
|
||||
key.WithKeys("enter", " "),
|
||||
key.WithHelp("enter/space", "confirm"),
|
||||
),
|
||||
Yes: key.NewBinding(
|
||||
key.WithKeys("y", "Y", "ctrl+c"),
|
||||
key.WithHelp("y/Y", "yes"),
|
||||
),
|
||||
No: key.NewBinding(
|
||||
key.WithKeys("n", "N"),
|
||||
key.WithHelp("n/N", "no"),
|
||||
),
|
||||
}
|
||||
|
||||
func (q *quitDialog) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *quitDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
q.width = msg.Width
|
||||
q.height = msg.Height
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, helpKeys.LeftRight):
|
||||
q.selectedNo = !q.selectedNo
|
||||
return q, nil
|
||||
case key.Matches(msg, helpKeys.EnterSpace):
|
||||
if !q.selectedNo {
|
||||
return q, tea.Quit
|
||||
}
|
||||
return q, util.CmdHandler(modal.CloseModalMsg{})
|
||||
case key.Matches(msg, helpKeys.Yes):
|
||||
return q, tea.Quit
|
||||
case key.Matches(msg, helpKeys.No):
|
||||
return q, util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (q *quitDialog) Render(background string) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle()
|
||||
|
||||
yesStyle := baseStyle
|
||||
noStyle := baseStyle
|
||||
spacerStyle := baseStyle.Background(t.BackgroundElement())
|
||||
|
||||
if q.selectedNo {
|
||||
noStyle = noStyle.Background(t.Primary()).Foreground(t.BackgroundElement())
|
||||
yesStyle = yesStyle.Background(t.BackgroundElement()).Foreground(t.Primary())
|
||||
} else {
|
||||
yesStyle = yesStyle.Background(t.Primary()).Foreground(t.BackgroundElement())
|
||||
noStyle = noStyle.Background(t.BackgroundElement()).Foreground(t.Primary())
|
||||
}
|
||||
|
||||
yesButton := yesStyle.Padding(0, 1).Render("Yes")
|
||||
noButton := noStyle.Padding(0, 1).Render("No")
|
||||
|
||||
buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
|
||||
|
||||
width := lipgloss.Width(question)
|
||||
remainingWidth := width - lipgloss.Width(buttons)
|
||||
if remainingWidth > 0 {
|
||||
buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
|
||||
}
|
||||
|
||||
content := baseStyle.Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Center,
|
||||
question,
|
||||
"",
|
||||
buttons,
|
||||
),
|
||||
)
|
||||
|
||||
return q.modal.Render(content, background)
|
||||
}
|
||||
|
||||
func (q *quitDialog) Close() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *quitDialog) IsQuitDialog() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// NewQuitDialog creates a new quit confirmation dialog
|
||||
func NewQuitDialog() QuitDialog {
|
||||
return &quitDialog{
|
||||
selectedNo: true,
|
||||
modal: modal.New(),
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/state"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
@@ -20,14 +19,12 @@ type SessionDialog interface {
|
||||
layout.Modal
|
||||
}
|
||||
|
||||
type sessionItem struct {
|
||||
session client.SessionInfo
|
||||
}
|
||||
type sessionItem client.SessionInfo
|
||||
|
||||
func (s sessionItem) Render(selected bool, width int) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.BaseStyle().
|
||||
Width(width - 2).
|
||||
Width(width - 4).
|
||||
Background(t.BackgroundElement())
|
||||
|
||||
if selected {
|
||||
@@ -40,7 +37,7 @@ func (s sessionItem) Render(selected bool, width int) string {
|
||||
Foreground(t.Text())
|
||||
}
|
||||
|
||||
return baseStyle.Padding(0, 1).Render(s.session.Title)
|
||||
return baseStyle.Padding(0, 1).Render(s.Title)
|
||||
}
|
||||
|
||||
type sessionDialog struct {
|
||||
@@ -61,15 +58,14 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.width = msg.Width
|
||||
s.height = msg.Height
|
||||
s.list.SetMaxWidth(layout.Current.Container.Width - 12)
|
||||
case tea.KeyMsg:
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if item, idx := s.list.GetSelectedItem(); idx >= 0 {
|
||||
selectedSession := item.session
|
||||
s.selectedSessionID = selectedSession.Id
|
||||
s.selectedSessionID = item.Id
|
||||
return s, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(state.SessionSelectedMsg(&selectedSession)),
|
||||
util.CmdHandler(app.SessionSelectedMsg(&item)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -95,7 +91,10 @@ func NewSessionDialog(app *app.App) SessionDialog {
|
||||
|
||||
var sessionItems []sessionItem
|
||||
for _, sess := range sessions {
|
||||
sessionItems = append(sessionItems, sessionItem{session: sess})
|
||||
if sess.ParentID != nil {
|
||||
continue
|
||||
}
|
||||
sessionItems = append(sessionItems, sessionItem(sess))
|
||||
}
|
||||
|
||||
list := list.NewListComponent(
|
||||
|
||||
@@ -5,14 +5,13 @@ import (
|
||||
list "github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
// ThemeChangedMsg is sent when the theme is changed
|
||||
type ThemeChangedMsg struct {
|
||||
// ThemeSelectedMsg is sent when the theme is changed
|
||||
type ThemeSelectedMsg struct {
|
||||
ThemeName string
|
||||
}
|
||||
|
||||
@@ -71,12 +70,12 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return t, util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
if err := theme.SetTheme(selectedTheme); err != nil {
|
||||
status.Error(err.Error())
|
||||
// status.Error(err.Error())
|
||||
return t, nil
|
||||
}
|
||||
return t, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(ThemeChangedMsg{ThemeName: selectedTheme}),
|
||||
util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
)
|
||||
|
||||
type ListItem interface {
|
||||
@@ -12,12 +12,14 @@ type ListItem interface {
|
||||
}
|
||||
|
||||
type List[T ListItem] interface {
|
||||
layout.ModelWithView
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
SetMaxWidth(maxWidth int)
|
||||
GetSelectedItem() (item T, idx int)
|
||||
SetItems(items []T)
|
||||
GetItems() []T
|
||||
SetSelectedIndex(idx int)
|
||||
SetEmptyMessage(msg string)
|
||||
IsEmpty() bool
|
||||
}
|
||||
|
||||
@@ -100,6 +102,10 @@ func (c *listComponent[T]) GetItems() []T {
|
||||
return c.items
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) SetEmptyMessage(msg string) {
|
||||
c.fallbackMsg = msg
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) IsEmpty() bool {
|
||||
return len(c.items) == 0
|
||||
}
|
||||
@@ -143,7 +149,7 @@ func (c *listComponent[T]) View() string {
|
||||
listItems = append(listItems, title)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, listItems...)
|
||||
return strings.Join(listItems, "\n")
|
||||
}
|
||||
|
||||
func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package modal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
@@ -66,6 +68,10 @@ func New(opts ...ModalOption) *Modal {
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Modal) SetTitle(title string) {
|
||||
m.title = title
|
||||
}
|
||||
|
||||
// Render renders the modal centered on the screen
|
||||
func (m *Modal) Render(contentView string, background string) string {
|
||||
t := theme.CurrentTheme()
|
||||
@@ -95,15 +101,20 @@ func (m *Modal) Render(contentView string, background string) string {
|
||||
titleStyle := baseStyle.
|
||||
Foreground(t.Primary()).
|
||||
Bold(true).
|
||||
Width(innerWidth).
|
||||
Padding(0, 1)
|
||||
|
||||
titleView := titleStyle.Render(m.title)
|
||||
finalContent = lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
titleView,
|
||||
contentView,
|
||||
)
|
||||
escStyle := baseStyle.Foreground(t.TextMuted()).Bold(false)
|
||||
escText := escStyle.Render("esc")
|
||||
|
||||
// Calculate position for esc text
|
||||
titleWidth := lipgloss.Width(m.title)
|
||||
escWidth := lipgloss.Width(escText)
|
||||
spacesNeeded := max(0, innerWidth-titleWidth-escWidth-3)
|
||||
spacer := strings.Repeat(" ", spacesNeeded)
|
||||
titleLine := m.title + spacer + escText
|
||||
titleLine = titleStyle.Render(titleLine)
|
||||
|
||||
finalContent = strings.Join([]string{titleLine, contentView}, "\n") + "\n"
|
||||
} else {
|
||||
finalContent = contentView
|
||||
}
|
||||
@@ -112,14 +123,7 @@ func (m *Modal) Render(contentView string, background string) string {
|
||||
PaddingTop(1).
|
||||
PaddingBottom(1).
|
||||
PaddingLeft(2).
|
||||
PaddingRight(2).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
BorderLeftForeground(t.BackgroundSubtle()).
|
||||
BorderLeftBackground(t.Background()).
|
||||
BorderRightForeground(t.BackgroundSubtle()).
|
||||
BorderRightBackground(t.Background())
|
||||
PaddingRight(2)
|
||||
|
||||
modalView := modalStyle.
|
||||
Width(outerWidth).
|
||||
@@ -139,5 +143,7 @@ func (m *Modal) Render(contentView string, background string) string {
|
||||
row,
|
||||
modalView,
|
||||
background,
|
||||
layout.WithOverlayBorder(),
|
||||
layout.WithOverlayBorderColor(t.Primary()),
|
||||
)
|
||||
}
|
||||
|
||||
140
packages/tui/internal/components/status/status.go
Normal file
140
packages/tui/internal/components/status/status.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type StatusComponent interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
}
|
||||
|
||||
type statusComponent struct {
|
||||
app *app.App
|
||||
width int
|
||||
}
|
||||
|
||||
func (m statusComponent) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m statusComponent) logo() string {
|
||||
t := theme.CurrentTheme()
|
||||
base := lipgloss.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render
|
||||
emphasis := lipgloss.NewStyle().Bold(true).Background(t.BackgroundElement()).Foreground(t.Text()).Render
|
||||
|
||||
open := base("open")
|
||||
code := emphasis("code ")
|
||||
version := base(m.app.Version)
|
||||
return styles.Padded().
|
||||
Background(t.BackgroundElement()).
|
||||
Render(open + code + version)
|
||||
}
|
||||
|
||||
func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
|
||||
// Format tokens in human-readable format (e.g., 110K, 1.2M)
|
||||
var formattedTokens string
|
||||
switch {
|
||||
case tokens >= 1_000_000:
|
||||
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
|
||||
case tokens >= 1_000:
|
||||
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
|
||||
default:
|
||||
formattedTokens = fmt.Sprintf("%d", int(tokens))
|
||||
}
|
||||
|
||||
// Remove .0 suffix if present
|
||||
if strings.HasSuffix(formattedTokens, ".0K") {
|
||||
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
|
||||
}
|
||||
if strings.HasSuffix(formattedTokens, ".0M") {
|
||||
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
|
||||
}
|
||||
|
||||
// Format cost with $ symbol and 2 decimal places
|
||||
formattedCost := fmt.Sprintf("$%.2f", cost)
|
||||
percentage := (float64(tokens) / float64(contextWindow)) * 100
|
||||
|
||||
return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
|
||||
}
|
||||
|
||||
func (m statusComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
if m.app.Session.Id == "" {
|
||||
return styles.BaseStyle().
|
||||
Background(t.Background()).
|
||||
Width(m.width).
|
||||
Height(2).
|
||||
Render("")
|
||||
}
|
||||
|
||||
logo := m.logo()
|
||||
|
||||
cwd := styles.Padded().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundSubtle()).
|
||||
Render(m.app.Info.Path.Cwd)
|
||||
|
||||
sessionInfo := ""
|
||||
if m.app.Session.Id != "" {
|
||||
tokens := float32(0)
|
||||
cost := float32(0)
|
||||
contextWindow := m.app.Model.Limit.Context
|
||||
|
||||
for _, message := range m.app.Messages {
|
||||
if message.Metadata.Assistant != nil {
|
||||
cost += message.Metadata.Assistant.Cost
|
||||
usage := message.Metadata.Assistant.Tokens
|
||||
if usage.Output > 0 {
|
||||
tokens = (usage.Input +
|
||||
usage.Cache.Write +
|
||||
usage.Cache.Read +
|
||||
usage.Output +
|
||||
usage.Reasoning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessionInfo = styles.Padded().
|
||||
Background(t.BackgroundElement()).
|
||||
Foreground(t.TextMuted()).
|
||||
Render(formatTokensAndCost(tokens, contextWindow, cost))
|
||||
}
|
||||
|
||||
// diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
|
||||
|
||||
space := max(
|
||||
0,
|
||||
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
|
||||
)
|
||||
spacer := lipgloss.NewStyle().Background(t.BackgroundSubtle()).Width(space).Render("")
|
||||
|
||||
status := logo + cwd + spacer + sessionInfo
|
||||
|
||||
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
|
||||
return blank + "\n" + status
|
||||
}
|
||||
|
||||
func NewStatusCmp(app *app.App) StatusComponent {
|
||||
statusComponent := &statusComponent{
|
||||
app: app,
|
||||
}
|
||||
|
||||
return statusComponent
|
||||
}
|
||||
125
packages/tui/internal/components/textarea/memoization.go
Normal file
125
packages/tui/internal/components/textarea/memoization.go
Normal file
@@ -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))))
|
||||
}
|
||||
102
packages/tui/internal/components/textarea/runeutil.go
Normal file
102
packages/tui/internal/components/textarea/runeutil.go
Normal file
@@ -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
|
||||
}
|
||||
1632
packages/tui/internal/components/textarea/textarea.go
Normal file
1632
packages/tui/internal/components/textarea/textarea.go
Normal file
File diff suppressed because it is too large
Load Diff
267
packages/tui/internal/components/toast/toast.go
Normal file
267
packages/tui/internal/components/toast/toast.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package toast
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
// ShowToastMsg is a message to display a toast notification
|
||||
type ShowToastMsg struct {
|
||||
Message string
|
||||
Title *string
|
||||
Color compat.AdaptiveColor
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// DismissToastMsg is a message to dismiss a specific toast
|
||||
type DismissToastMsg struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
// Toast represents a single toast notification
|
||||
type Toast struct {
|
||||
ID string
|
||||
Message string
|
||||
Title *string
|
||||
Color compat.AdaptiveColor
|
||||
CreatedAt time.Time
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// ToastManager manages multiple toast notifications
|
||||
type ToastManager struct {
|
||||
toasts []Toast
|
||||
}
|
||||
|
||||
// NewToastManager creates a new toast manager
|
||||
func NewToastManager() *ToastManager {
|
||||
return &ToastManager{
|
||||
toasts: []Toast{},
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the toast manager
|
||||
func (tm *ToastManager) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the toast manager
|
||||
func (tm *ToastManager) Update(msg tea.Msg) (*ToastManager, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case ShowToastMsg:
|
||||
toast := Toast{
|
||||
ID: fmt.Sprintf("toast-%d", time.Now().UnixNano()),
|
||||
Title: msg.Title,
|
||||
Message: msg.Message,
|
||||
Color: msg.Color,
|
||||
CreatedAt: time.Now(),
|
||||
Duration: msg.Duration,
|
||||
}
|
||||
|
||||
tm.toasts = append(tm.toasts, toast)
|
||||
|
||||
// Return command to dismiss after duration
|
||||
return tm, tea.Tick(toast.Duration, func(t time.Time) tea.Msg {
|
||||
return DismissToastMsg{ID: toast.ID}
|
||||
})
|
||||
|
||||
case DismissToastMsg:
|
||||
var newToasts []Toast
|
||||
for _, t := range tm.toasts {
|
||||
if t.ID != msg.ID {
|
||||
newToasts = append(newToasts, t)
|
||||
}
|
||||
}
|
||||
tm.toasts = newToasts
|
||||
}
|
||||
|
||||
return tm, nil
|
||||
}
|
||||
|
||||
// renderSingleToast renders a single toast notification
|
||||
func (tm *ToastManager) renderSingleToast(toast Toast) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
baseStyle := styles.BaseStyle().
|
||||
Background(t.BackgroundElement()).
|
||||
Foreground(t.Text()).
|
||||
Padding(1, 2)
|
||||
|
||||
maxWidth := max(40, layout.Current.Viewport.Width/3)
|
||||
contentMaxWidth := max(maxWidth-6, 20)
|
||||
|
||||
// Build content with wrapping
|
||||
var content strings.Builder
|
||||
if toast.Title != nil {
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(toast.Color).
|
||||
Bold(true)
|
||||
content.WriteString(titleStyle.Render(*toast.Title))
|
||||
content.WriteString("\n")
|
||||
}
|
||||
|
||||
// Wrap message text
|
||||
messageStyle := lipgloss.NewStyle()
|
||||
contentWidth := lipgloss.Width(toast.Message)
|
||||
if contentWidth > contentMaxWidth {
|
||||
messageStyle = messageStyle.Width(contentMaxWidth)
|
||||
}
|
||||
content.WriteString(messageStyle.Render(toast.Message))
|
||||
|
||||
// Render toast with max width
|
||||
return baseStyle.MaxWidth(maxWidth).Render(content.String())
|
||||
}
|
||||
|
||||
// View renders all active toasts
|
||||
func (tm *ToastManager) View() string {
|
||||
if len(tm.toasts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var toastViews []string
|
||||
for _, toast := range tm.toasts {
|
||||
toastView := tm.renderSingleToast(toast)
|
||||
toastViews = append(toastViews, toastView+"\n")
|
||||
}
|
||||
|
||||
return strings.Join(toastViews, "\n")
|
||||
}
|
||||
|
||||
// RenderOverlay renders the toasts as an overlay on the given background
|
||||
func (tm *ToastManager) RenderOverlay(background string) string {
|
||||
if len(tm.toasts) == 0 {
|
||||
return background
|
||||
}
|
||||
|
||||
bgWidth := lipgloss.Width(background)
|
||||
bgHeight := lipgloss.Height(background)
|
||||
result := background
|
||||
|
||||
// Start from top with 2 character padding
|
||||
currentY := 2
|
||||
|
||||
// Render each toast individually
|
||||
for _, toast := range tm.toasts {
|
||||
// Render individual toast
|
||||
toastView := tm.renderSingleToast(toast)
|
||||
toastWidth := lipgloss.Width(toastView)
|
||||
toastHeight := lipgloss.Height(toastView)
|
||||
|
||||
// Position at top-right with 2 character padding from right edge
|
||||
x := max(bgWidth-toastWidth-4, 0)
|
||||
|
||||
// Check if toast fits vertically
|
||||
if currentY+toastHeight > bgHeight-2 {
|
||||
// No more room for toasts
|
||||
break
|
||||
}
|
||||
|
||||
// Place this toast
|
||||
result = layout.PlaceOverlay(
|
||||
x,
|
||||
currentY,
|
||||
toastView,
|
||||
result,
|
||||
layout.WithOverlayBorder(),
|
||||
layout.WithOverlayBorderColor(toast.Color),
|
||||
)
|
||||
|
||||
// Move down for next toast (add 1 for spacing between toasts)
|
||||
currentY += toastHeight + 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type ToastOptions struct {
|
||||
Title string
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
type toastOptions struct {
|
||||
title *string
|
||||
duration *time.Duration
|
||||
color *compat.AdaptiveColor
|
||||
}
|
||||
|
||||
type ToastOption func(*toastOptions)
|
||||
|
||||
func WithTitle(title string) ToastOption {
|
||||
return func(t *toastOptions) {
|
||||
t.title = &title
|
||||
}
|
||||
}
|
||||
func WithDuration(duration time.Duration) ToastOption {
|
||||
return func(t *toastOptions) {
|
||||
t.duration = &duration
|
||||
}
|
||||
}
|
||||
|
||||
func WithColor(color compat.AdaptiveColor) ToastOption {
|
||||
return func(t *toastOptions) {
|
||||
t.color = &color
|
||||
}
|
||||
}
|
||||
|
||||
func NewToast(message string, options ...ToastOption) tea.Cmd {
|
||||
t := theme.CurrentTheme()
|
||||
duration := 5 * time.Second
|
||||
color := t.Primary()
|
||||
|
||||
opts := toastOptions{
|
||||
duration: &duration,
|
||||
color: &color,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(&opts)
|
||||
}
|
||||
|
||||
return func() tea.Msg {
|
||||
return ShowToastMsg{
|
||||
Message: message,
|
||||
Title: opts.title,
|
||||
Duration: *opts.duration,
|
||||
Color: *opts.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewInfoToast(message string, options ...ToastOption) tea.Cmd {
|
||||
options = append(options, WithColor(theme.CurrentTheme().Info()))
|
||||
return NewToast(
|
||||
message,
|
||||
options...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewSuccessToast(message string, options ...ToastOption) tea.Cmd {
|
||||
options = append(options, WithColor(theme.CurrentTheme().Success()))
|
||||
return NewToast(
|
||||
message,
|
||||
options...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewWarningToast(message string, options ...ToastOption) tea.Cmd {
|
||||
options = append(options, WithColor(theme.CurrentTheme().Warning()))
|
||||
return NewToast(
|
||||
message,
|
||||
options...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewErrorToast(message string, options ...ToastOption) tea.Cmd {
|
||||
options = append(options, WithColor(theme.CurrentTheme().Error()))
|
||||
return NewToast(
|
||||
message,
|
||||
options...,
|
||||
)
|
||||
}
|
||||
@@ -7,27 +7,31 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/sst/opencode/pkg/client"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
type State struct {
|
||||
Theme string `toml:"theme"`
|
||||
Provider string `toml:"provider"`
|
||||
Model string `toml:"model"`
|
||||
}
|
||||
|
||||
// NewConfig creates a new Config instance with default values.
|
||||
// This can be useful for initializing a new configuration file.
|
||||
func NewConfig(theme, provider, model string) *Config {
|
||||
return &Config{
|
||||
Theme: theme,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
func NewState() *State {
|
||||
return &State{
|
||||
Theme: "opencode",
|
||||
}
|
||||
}
|
||||
|
||||
// SaveConfig writes the provided Config struct to the specified TOML file.
|
||||
func MergeState(state *State, config *client.ConfigInfo) *client.ConfigInfo {
|
||||
if config.Theme == nil {
|
||||
config.Theme = &state.Theme
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// SaveState writes the provided Config struct to the specified TOML file.
|
||||
// It will create the file if it doesn't exist, or overwrite it if it does.
|
||||
func SaveConfig(filePath string, config *Config) error {
|
||||
func SaveState(filePath string, state *State) error {
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create/open config file %s: %w", filePath, err)
|
||||
@@ -35,31 +39,27 @@ func SaveConfig(filePath string, config *Config) error {
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
|
||||
encoder := toml.NewEncoder(writer)
|
||||
if err := encoder.Encode(config); err != nil {
|
||||
return fmt.Errorf("failed to encode config to TOML file %s: %w", filePath, err)
|
||||
if err := encoder.Encode(state); err != nil {
|
||||
return fmt.Errorf("failed to encode state to TOML file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
if err := writer.Flush(); err != nil {
|
||||
return fmt.Errorf("failed to flush writer for config file %s: %w", filePath, err)
|
||||
return fmt.Errorf("failed to flush writer for state file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
slog.Debug("Configuration saved to file", "file", filePath)
|
||||
slog.Debug("State saved to file", "file", filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfig reads a Config struct from the specified TOML file.
|
||||
// It returns a pointer to the Config struct and an error if any issues occur.
|
||||
func LoadConfig(filePath string) (*Config, error) {
|
||||
var config Config
|
||||
|
||||
if _, err := toml.DecodeFile(filePath, &config); err != nil {
|
||||
// LoadState loads the state from the specified TOML file.
|
||||
// It returns a pointer to the State struct and an error if any issues occur.
|
||||
func LoadState(filePath string) (*State, error) {
|
||||
var state State
|
||||
if _, err := toml.DecodeFile(filePath, &state); err != nil {
|
||||
if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
|
||||
return nil, fmt.Errorf("config file not found at %s: %w", filePath, statErr)
|
||||
return nil, fmt.Errorf("state file not found at %s: %w", filePath, statErr)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
@@ -6,20 +6,12 @@ import (
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type ModelWithView interface {
|
||||
type Container interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
}
|
||||
|
||||
type Container interface {
|
||||
ModelWithView
|
||||
Sizeable
|
||||
Focus()
|
||||
Blur()
|
||||
MaxWidth() int
|
||||
Alignment() lipgloss.Position
|
||||
GetPosition() (x, y int)
|
||||
GetContent() ModelWithView
|
||||
Focusable
|
||||
Alignable
|
||||
}
|
||||
|
||||
type container struct {
|
||||
@@ -28,7 +20,7 @@ type container struct {
|
||||
x int
|
||||
y int
|
||||
|
||||
content ModelWithView
|
||||
content tea.ViewModel
|
||||
|
||||
paddingTop int
|
||||
paddingRight int
|
||||
@@ -48,13 +40,19 @@ type container struct {
|
||||
}
|
||||
|
||||
func (c *container) Init() tea.Cmd {
|
||||
return c.content.Init()
|
||||
if model, ok := c.content.(tea.Model); ok {
|
||||
return model.Init()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
u, cmd := c.content.Update(msg)
|
||||
c.content = u.(ModelWithView)
|
||||
return c, cmd
|
||||
if model, ok := c.content.(tea.Model); ok {
|
||||
u, cmd := model.Update(msg)
|
||||
c.content = u.(tea.ViewModel)
|
||||
return c, cmd
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *container) View() string {
|
||||
@@ -156,21 +154,28 @@ func (c *container) Alignment() lipgloss.Position {
|
||||
}
|
||||
|
||||
// Focus sets the container as focused
|
||||
func (c *container) Focus() {
|
||||
func (c *container) Focus() tea.Cmd {
|
||||
c.focused = true
|
||||
// Pass focus to content if it supports it
|
||||
if focusable, ok := c.content.(interface{ Focus() }); ok {
|
||||
focusable.Focus()
|
||||
if focusable, ok := c.content.(Focusable); ok {
|
||||
return focusable.Focus()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Blur removes focus from the container
|
||||
func (c *container) Blur() {
|
||||
func (c *container) Blur() tea.Cmd {
|
||||
c.focused = false
|
||||
// Remove focus from content if it supports it
|
||||
if blurable, ok := c.content.(interface{ Blur() }); ok {
|
||||
blurable.Blur()
|
||||
if blurable, ok := c.content.(Focusable); ok {
|
||||
return blurable.Blur()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *container) IsFocused() bool {
|
||||
if blurable, ok := c.content.(Focusable); ok {
|
||||
return blurable.IsFocused()
|
||||
}
|
||||
return c.focused
|
||||
}
|
||||
|
||||
// GetPosition returns the x, y coordinates of the container
|
||||
@@ -178,14 +183,14 @@ func (c *container) GetPosition() (x, y int) {
|
||||
return c.x, c.y
|
||||
}
|
||||
|
||||
// GetContent returns the content of the container
|
||||
func (c *container) GetContent() ModelWithView {
|
||||
return c.content
|
||||
func (c *container) SetPosition(x, y int) {
|
||||
c.x = x
|
||||
c.y = y
|
||||
}
|
||||
|
||||
type ContainerOption func(*container)
|
||||
|
||||
func NewContainer(content ModelWithView, options ...ContainerOption) Container {
|
||||
func NewContainer(content tea.ViewModel, options ...ContainerOption) Container {
|
||||
c := &container{
|
||||
content: content,
|
||||
borderStyle: lipgloss.NormalBorder(),
|
||||
|
||||
@@ -3,6 +3,7 @@ package layout
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type FlexDirection int
|
||||
@@ -12,22 +13,22 @@ const (
|
||||
FlexDirectionVertical
|
||||
)
|
||||
|
||||
type FlexPaneSize struct {
|
||||
type FlexChildSize struct {
|
||||
Fixed bool
|
||||
Size int
|
||||
}
|
||||
|
||||
var FlexPaneSizeGrow = FlexPaneSize{Fixed: false}
|
||||
var FlexChildSizeGrow = FlexChildSize{Fixed: false}
|
||||
|
||||
func FlexPaneSizeFixed(size int) FlexPaneSize {
|
||||
return FlexPaneSize{Fixed: true, Size: size}
|
||||
func FlexChildSizeFixed(size int) FlexChildSize {
|
||||
return FlexChildSize{Fixed: true, Size: size}
|
||||
}
|
||||
|
||||
type FlexLayout interface {
|
||||
ModelWithView
|
||||
tea.ViewModel
|
||||
Sizeable
|
||||
SetPanes(panes []Container) tea.Cmd
|
||||
SetPaneSizes(sizes []FlexPaneSize) tea.Cmd
|
||||
SetChildren(panes []tea.ViewModel) tea.Cmd
|
||||
SetSizes(sizes []FlexChildSize) tea.Cmd
|
||||
SetDirection(direction FlexDirection) tea.Cmd
|
||||
}
|
||||
|
||||
@@ -35,91 +36,69 @@ type flexLayout struct {
|
||||
width int
|
||||
height int
|
||||
direction FlexDirection
|
||||
panes []Container
|
||||
sizes []FlexPaneSize
|
||||
children []tea.ViewModel
|
||||
sizes []FlexChildSize
|
||||
}
|
||||
|
||||
type FlexLayoutOption func(*flexLayout)
|
||||
|
||||
func (f *flexLayout) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for _, pane := range f.panes {
|
||||
if pane != nil {
|
||||
cmds = append(cmds, pane.Init())
|
||||
}
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (f *flexLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
return f, f.SetSize(msg.Width, msg.Height)
|
||||
}
|
||||
|
||||
for i, pane := range f.panes {
|
||||
if pane != nil {
|
||||
u, cmd := pane.Update(msg)
|
||||
f.panes[i] = u.(Container)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return f, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (f *flexLayout) View() string {
|
||||
if len(f.panes) == 0 {
|
||||
if len(f.children) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
views := make([]string, 0, len(f.panes))
|
||||
for i, pane := range f.panes {
|
||||
if pane == nil {
|
||||
t := theme.CurrentTheme()
|
||||
views := make([]string, 0, len(f.children))
|
||||
for i, child := range f.children {
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var paneWidth, paneHeight int
|
||||
alignment := lipgloss.Center
|
||||
if alignable, ok := child.(Alignable); ok {
|
||||
alignment = alignable.Alignment()
|
||||
}
|
||||
var childWidth, childHeight int
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
paneWidth, paneHeight = f.calculatePaneSize(i)
|
||||
childWidth, childHeight = f.calculateChildSize(i)
|
||||
view := lipgloss.PlaceHorizontal(
|
||||
paneWidth,
|
||||
pane.Alignment(),
|
||||
pane.View(),
|
||||
childWidth,
|
||||
alignment,
|
||||
child.View(),
|
||||
// TODO: make configurable WithBackgroundStyle
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
views = append(views, view)
|
||||
} else {
|
||||
paneWidth, paneHeight = f.calculatePaneSize(i)
|
||||
childWidth, childHeight = f.calculateChildSize(i)
|
||||
view := lipgloss.Place(
|
||||
f.width,
|
||||
paneHeight,
|
||||
childHeight,
|
||||
lipgloss.Center,
|
||||
pane.Alignment(),
|
||||
pane.View(),
|
||||
alignment,
|
||||
child.View(),
|
||||
// TODO: make configurable WithBackgroundStyle
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
|
||||
)
|
||||
views = append(views, view)
|
||||
}
|
||||
}
|
||||
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
return lipgloss.JoinHorizontal(lipgloss.Center, views...)
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Center, views...)
|
||||
}
|
||||
|
||||
func (f *flexLayout) calculatePaneSize(index int) (width, height int) {
|
||||
if index >= len(f.panes) {
|
||||
func (f *flexLayout) calculateChildSize(index int) (width, height int) {
|
||||
if index >= len(f.children) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
totalFixed := 0
|
||||
flexCount := 0
|
||||
|
||||
for i, pane := range f.panes {
|
||||
if pane == nil {
|
||||
for i, child := range f.children {
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
if i < len(f.sizes) && f.sizes[i].Fixed {
|
||||
@@ -160,14 +139,18 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
|
||||
|
||||
var cmds []tea.Cmd
|
||||
currentX, currentY := 0, 0
|
||||
|
||||
for i, pane := range f.panes {
|
||||
if pane != nil {
|
||||
paneWidth, paneHeight := f.calculatePaneSize(i)
|
||||
|
||||
|
||||
for i, child := range f.children {
|
||||
if child != nil {
|
||||
paneWidth, paneHeight := f.calculateChildSize(i)
|
||||
alignment := lipgloss.Center
|
||||
if alignable, ok := child.(Alignable); ok {
|
||||
alignment = alignable.Alignment()
|
||||
}
|
||||
|
||||
// Calculate actual position based on alignment
|
||||
actualX, actualY := currentX, currentY
|
||||
|
||||
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
// In horizontal layout, vertical alignment affects Y position
|
||||
// (lipgloss.Center is used for vertical alignment in JoinHorizontal)
|
||||
@@ -175,11 +158,13 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
|
||||
} else {
|
||||
// In vertical layout, horizontal alignment affects X position
|
||||
contentWidth := paneWidth
|
||||
if pane.MaxWidth() > 0 && contentWidth > pane.MaxWidth() {
|
||||
contentWidth = pane.MaxWidth()
|
||||
if alignable, ok := child.(Alignable); ok {
|
||||
if alignable.MaxWidth() > 0 && contentWidth > alignable.MaxWidth() {
|
||||
contentWidth = alignable.MaxWidth()
|
||||
}
|
||||
}
|
||||
|
||||
switch pane.Alignment() {
|
||||
|
||||
switch alignment {
|
||||
case lipgloss.Center:
|
||||
actualX = (f.width - contentWidth) / 2
|
||||
case lipgloss.Right:
|
||||
@@ -188,16 +173,17 @@ func (f *flexLayout) SetSize(width, height int) tea.Cmd {
|
||||
actualX = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Set position if the pane is a *container
|
||||
if c, ok := pane.(*container); ok {
|
||||
c.x = actualX
|
||||
c.y = actualY
|
||||
|
||||
// Set position if the pane is Alignable
|
||||
if c, ok := child.(Alignable); ok {
|
||||
c.SetPosition(actualX, actualY)
|
||||
}
|
||||
|
||||
cmd := pane.SetSize(paneWidth, paneHeight)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
|
||||
if sizeable, ok := child.(Sizeable); ok {
|
||||
cmd := sizeable.SetSize(paneWidth, paneHeight)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
// Update position for next pane
|
||||
if f.direction == FlexDirectionHorizontal {
|
||||
currentX += paneWidth
|
||||
@@ -213,15 +199,15 @@ func (f *flexLayout) GetSize() (int, int) {
|
||||
return f.width, f.height
|
||||
}
|
||||
|
||||
func (f *flexLayout) SetPanes(panes []Container) tea.Cmd {
|
||||
f.panes = panes
|
||||
func (f *flexLayout) SetChildren(children []tea.ViewModel) tea.Cmd {
|
||||
f.children = children
|
||||
if f.width > 0 && f.height > 0 {
|
||||
return f.SetSize(f.width, f.height)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *flexLayout) SetPaneSizes(sizes []FlexPaneSize) tea.Cmd {
|
||||
func (f *flexLayout) SetSizes(sizes []FlexChildSize) tea.Cmd {
|
||||
f.sizes = sizes
|
||||
if f.width > 0 && f.height > 0 {
|
||||
return f.SetSize(f.width, f.height)
|
||||
@@ -237,11 +223,11 @@ func (f *flexLayout) SetDirection(direction FlexDirection) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewFlexLayout(options ...FlexLayoutOption) FlexLayout {
|
||||
func NewFlexLayout(children []tea.ViewModel, options ...FlexLayoutOption) FlexLayout {
|
||||
layout := &flexLayout{
|
||||
children: children,
|
||||
direction: FlexDirectionHorizontal,
|
||||
panes: []Container{},
|
||||
sizes: []FlexPaneSize{},
|
||||
sizes: []FlexChildSize{},
|
||||
}
|
||||
for _, option := range options {
|
||||
option(layout)
|
||||
@@ -255,13 +241,13 @@ func WithDirection(direction FlexDirection) FlexLayoutOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithPanes(panes ...Container) FlexLayoutOption {
|
||||
func WithChildren(children ...tea.ViewModel) FlexLayoutOption {
|
||||
return func(f *flexLayout) {
|
||||
f.panes = panes
|
||||
f.children = children
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaneSizes(sizes ...FlexPaneSize) FlexLayoutOption {
|
||||
func WithSizes(sizes ...FlexChildSize) FlexLayoutOption {
|
||||
return func(f *flexLayout) {
|
||||
f.sizes = sizes
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
)
|
||||
|
||||
var Current *LayoutInfo
|
||||
@@ -45,6 +46,13 @@ type Sizeable interface {
|
||||
GetSize() (int, int)
|
||||
}
|
||||
|
||||
type Alignable interface {
|
||||
MaxWidth() int
|
||||
Alignment() lipgloss.Position
|
||||
SetPosition(x, y int)
|
||||
GetPosition() (x, y int)
|
||||
}
|
||||
|
||||
func KeyMapToSlice(t any) (bindings []key.Binding) {
|
||||
typ := reflect.TypeOf(t)
|
||||
if typ.Kind() != reflect.Struct {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
chAnsi "github.com/charmbracelet/x/ansi"
|
||||
"github.com/muesli/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
@@ -23,29 +27,58 @@ func getLines(s string) (lines []string, widest int) {
|
||||
return lines, widest
|
||||
}
|
||||
|
||||
// overlayOptions holds configuration for overlay rendering
|
||||
type overlayOptions struct {
|
||||
whitespace *whitespace
|
||||
border bool
|
||||
borderColor *compat.AdaptiveColor
|
||||
}
|
||||
|
||||
// OverlayOption sets options for overlay rendering
|
||||
type OverlayOption func(*overlayOptions)
|
||||
|
||||
// PlaceOverlay places fg on top of bg.
|
||||
func PlaceOverlay(
|
||||
x, y int,
|
||||
fg, bg string,
|
||||
opts ...WhitespaceOption,
|
||||
opts ...OverlayOption,
|
||||
) string {
|
||||
fgLines, fgWidth := getLines(fg)
|
||||
bgLines, bgWidth := getLines(bg)
|
||||
bgHeight := len(bgLines)
|
||||
fgHeight := len(fgLines)
|
||||
|
||||
if fgWidth >= bgWidth && fgHeight >= bgHeight {
|
||||
// FIXME: return fg or bg?
|
||||
return fg
|
||||
// Parse options
|
||||
options := &overlayOptions{
|
||||
whitespace: &whitespace{},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
// TODO: allow placement outside of the bg box?
|
||||
x = util.Clamp(x, 0, bgWidth-fgWidth)
|
||||
y = util.Clamp(y, 0, bgHeight-fgHeight)
|
||||
// Adjust for borders if enabled
|
||||
if options.border {
|
||||
// Add space for left and right borders
|
||||
adjustedFgWidth := fgWidth + 2
|
||||
// Adjust placement to account for borders
|
||||
x = util.Clamp(x, 0, bgWidth-adjustedFgWidth)
|
||||
y = util.Clamp(y, 0, bgHeight-fgHeight)
|
||||
|
||||
ws := &whitespace{}
|
||||
for _, opt := range opts {
|
||||
opt(ws)
|
||||
// Pad all foreground lines to the same width for consistent borders
|
||||
for i := range fgLines {
|
||||
lineWidth := ansi.PrintableRuneWidth(fgLines[i])
|
||||
if lineWidth < fgWidth {
|
||||
fgLines[i] += strings.Repeat(" ", fgWidth-lineWidth)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if fgWidth >= bgWidth && fgHeight >= bgHeight {
|
||||
// FIXME: return fg or bg?
|
||||
return fg
|
||||
}
|
||||
// TODO: allow placement outside of the bg box?
|
||||
x = util.Clamp(x, 0, bgWidth-fgWidth)
|
||||
y = util.Clamp(y, 0, bgHeight-fgHeight)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
@@ -59,25 +92,62 @@ func PlaceOverlay(
|
||||
}
|
||||
|
||||
pos := 0
|
||||
|
||||
// Handle left side of the line up to the overlay
|
||||
if x > 0 {
|
||||
left := truncate.String(bgLine, uint(x))
|
||||
pos = ansi.PrintableRuneWidth(left)
|
||||
b.WriteString(left)
|
||||
if pos < x {
|
||||
b.WriteString(ws.render(x - pos))
|
||||
b.WriteString(options.whitespace.render(x - pos))
|
||||
pos = x
|
||||
}
|
||||
}
|
||||
|
||||
fgLine := fgLines[i-y]
|
||||
b.WriteString(fgLine)
|
||||
pos += ansi.PrintableRuneWidth(fgLine)
|
||||
// Render the overlay content with optional borders
|
||||
if options.border {
|
||||
// Get the foreground line
|
||||
fgLine := fgLines[i-y]
|
||||
fgLineWidth := ansi.PrintableRuneWidth(fgLine)
|
||||
|
||||
// Extract the styles at the border positions
|
||||
leftStyle := getStyleAtPosition(bgLine, pos)
|
||||
rightStyle := getStyleAtPosition(bgLine, pos + 1 + fgLineWidth)
|
||||
|
||||
// Left border - combine background from original with border foreground
|
||||
leftSeq := combineStyles(leftStyle, options.borderColor)
|
||||
if leftSeq != "" {
|
||||
b.WriteString(leftSeq)
|
||||
}
|
||||
b.WriteString("┃")
|
||||
b.WriteString("\x1b[0m") // Reset all styles
|
||||
pos++
|
||||
|
||||
// Content
|
||||
b.WriteString(fgLine)
|
||||
pos += fgLineWidth
|
||||
|
||||
// Right border - combine background from original with border foreground
|
||||
rightSeq := combineStyles(rightStyle, options.borderColor)
|
||||
if rightSeq != "" {
|
||||
b.WriteString(rightSeq)
|
||||
}
|
||||
b.WriteString("┃")
|
||||
b.WriteString("\x1b[0m") // Reset all styles
|
||||
pos++
|
||||
} else {
|
||||
// No border, just render the content
|
||||
fgLine := fgLines[i-y]
|
||||
b.WriteString(fgLine)
|
||||
pos += ansi.PrintableRuneWidth(fgLine)
|
||||
}
|
||||
|
||||
// Handle right side of the line after the overlay
|
||||
right := cutLeft(bgLine, pos)
|
||||
bgWidth := ansi.PrintableRuneWidth(bgLine)
|
||||
rightWidth := ansi.PrintableRuneWidth(right)
|
||||
if rightWidth <= bgWidth-pos {
|
||||
b.WriteString(ws.render(bgWidth - rightWidth - pos))
|
||||
b.WriteString(options.whitespace.render(bgWidth - rightWidth - pos))
|
||||
}
|
||||
|
||||
b.WriteString(right)
|
||||
@@ -92,6 +162,146 @@ func cutLeft(s string, cutWidth int) string {
|
||||
return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
|
||||
}
|
||||
|
||||
// ansiStyle represents parsed ANSI style attributes
|
||||
type ansiStyle struct {
|
||||
fgColor string
|
||||
bgColor string
|
||||
attrs []string
|
||||
}
|
||||
|
||||
// parseANSISequence parses an ANSI escape sequence into its components
|
||||
func parseANSISequence(seq string) ansiStyle {
|
||||
style := ansiStyle{}
|
||||
|
||||
// Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
|
||||
if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
|
||||
return style
|
||||
}
|
||||
|
||||
params := seq[2 : len(seq)-1]
|
||||
if params == "" {
|
||||
return style
|
||||
}
|
||||
|
||||
parts := strings.Split(params, ";")
|
||||
i := 0
|
||||
for i < len(parts) {
|
||||
switch parts[i] {
|
||||
case "0": // Reset
|
||||
style = ansiStyle{}
|
||||
case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
|
||||
style.attrs = append(style.attrs, parts[i])
|
||||
case "38": // Foreground color
|
||||
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
|
||||
// 256 color mode
|
||||
style.fgColor = strings.Join(parts[i:i+3], ";")
|
||||
i += 2
|
||||
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
|
||||
// RGB color mode
|
||||
style.fgColor = strings.Join(parts[i:i+5], ";")
|
||||
i += 4
|
||||
}
|
||||
case "48": // Background color
|
||||
if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
|
||||
// 256 color mode
|
||||
style.bgColor = strings.Join(parts[i:i+3], ";")
|
||||
i += 2
|
||||
} else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
|
||||
// RGB color mode
|
||||
style.bgColor = strings.Join(parts[i:i+5], ";")
|
||||
i += 4
|
||||
}
|
||||
case "30", "31", "32", "33", "34", "35", "36", "37": // Standard foreground colors
|
||||
style.fgColor = parts[i]
|
||||
case "40", "41", "42", "43", "44", "45", "46", "47": // Standard background colors
|
||||
style.bgColor = parts[i]
|
||||
case "90", "91", "92", "93", "94", "95", "96", "97": // Bright foreground colors
|
||||
style.fgColor = parts[i]
|
||||
case "100", "101", "102", "103", "104", "105", "106", "107": // Bright background colors
|
||||
style.bgColor = parts[i]
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return style
|
||||
}
|
||||
|
||||
// combineStyles creates an ANSI sequence that combines background from one style with foreground from another
|
||||
func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
|
||||
if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
// Add attributes
|
||||
parts = append(parts, bgStyle.attrs...)
|
||||
|
||||
// Add background color from the original style
|
||||
if bgStyle.bgColor != "" {
|
||||
parts = append(parts, bgStyle.bgColor)
|
||||
}
|
||||
|
||||
// Add foreground color if specified
|
||||
if fgColor != nil {
|
||||
// Use the light color (could be improved to detect terminal background)
|
||||
color := (*fgColor).Light
|
||||
|
||||
// Use RGBA to get color components
|
||||
r, g, b, _ := color.RGBA()
|
||||
// RGBA returns 16-bit values, we need 8-bit
|
||||
parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
|
||||
}
|
||||
|
||||
// getStyleAtPosition extracts the active ANSI style at a given visual position
|
||||
func getStyleAtPosition(s string, targetPos int) ansiStyle {
|
||||
// ANSI escape sequence regex
|
||||
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
|
||||
visualPos := 0
|
||||
currentStyle := ansiStyle{}
|
||||
|
||||
i := 0
|
||||
for i < len(s) && visualPos <= targetPos {
|
||||
// Check if we're at an ANSI escape sequence
|
||||
if match := ansiRegex.FindStringIndex(s[i:]); match != nil && match[0] == 0 {
|
||||
// Found an ANSI sequence at current position
|
||||
seq := s[i : i+match[1]]
|
||||
parsedStyle := parseANSISequence(seq)
|
||||
|
||||
// Update current style (merge with existing)
|
||||
if parsedStyle.fgColor != "" {
|
||||
currentStyle.fgColor = parsedStyle.fgColor
|
||||
}
|
||||
if parsedStyle.bgColor != "" {
|
||||
currentStyle.bgColor = parsedStyle.bgColor
|
||||
}
|
||||
if len(parsedStyle.attrs) > 0 {
|
||||
currentStyle.attrs = parsedStyle.attrs
|
||||
}
|
||||
|
||||
i += match[1]
|
||||
} else if i < len(s) {
|
||||
// Regular character
|
||||
if visualPos == targetPos {
|
||||
return currentStyle
|
||||
}
|
||||
_, size := utf8.DecodeRuneInString(s[i:])
|
||||
i += size
|
||||
visualPos++
|
||||
}
|
||||
}
|
||||
|
||||
return currentStyle
|
||||
}
|
||||
|
||||
type whitespace struct {
|
||||
style termenv.Style
|
||||
chars string
|
||||
@@ -129,3 +339,26 @@ func (w whitespace) render(width int) string {
|
||||
|
||||
// WhitespaceOption sets a styling rule for rendering whitespace.
|
||||
type WhitespaceOption func(*whitespace)
|
||||
|
||||
// WithWhitespace sets whitespace options for the overlay
|
||||
func WithWhitespace(opts ...WhitespaceOption) OverlayOption {
|
||||
return func(o *overlayOptions) {
|
||||
for _, opt := range opts {
|
||||
opt(o.whitespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithOverlayBorder enables border rendering for the overlay
|
||||
func WithOverlayBorder() OverlayOption {
|
||||
return func(o *overlayOptions) {
|
||||
o.border = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithOverlayBorderColor sets the border color for the overlay
|
||||
func WithOverlayBorderColor(color compat.AdaptiveColor) OverlayOption {
|
||||
return func(o *overlayOptions) {
|
||||
o.borderColor = &color
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/completions"
|
||||
"github.com/sst/opencode/internal/components/chat"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
var ChatPage PageID = "chat"
|
||||
|
||||
type chatPage struct {
|
||||
app *app.App
|
||||
editor layout.Container
|
||||
messages layout.Container
|
||||
layout layout.FlexLayout
|
||||
completionDialog dialog.CompletionDialog
|
||||
completionManager *completions.CompletionManager
|
||||
showCompletionDialog bool
|
||||
}
|
||||
|
||||
type ChatKeyMap struct {
|
||||
Cancel key.Binding
|
||||
ToggleTools key.Binding
|
||||
ShowCompletionDialog key.Binding
|
||||
}
|
||||
|
||||
var keyMap = ChatKeyMap{
|
||||
Cancel: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "cancel"),
|
||||
),
|
||||
ToggleTools: key.NewBinding(
|
||||
key.WithKeys("ctrl+h"),
|
||||
key.WithHelp("ctrl+h", "toggle tools"),
|
||||
),
|
||||
ShowCompletionDialog: key.NewBinding(
|
||||
key.WithKeys("/"),
|
||||
key.WithHelp("/", "Complete"),
|
||||
),
|
||||
}
|
||||
|
||||
func (p *chatPage) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{
|
||||
p.layout.Init(),
|
||||
}
|
||||
cmds = append(cmds, p.completionDialog.Init())
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
cmd := p.layout.SetSize(msg.Width, msg.Height)
|
||||
cmds = append(cmds, cmd)
|
||||
case chat.SendMsg:
|
||||
p.showCompletionDialog = false
|
||||
cmd := p.sendMessage(msg.Text, msg.Attachments)
|
||||
if cmd != nil {
|
||||
return p, cmd
|
||||
}
|
||||
case dialog.CompletionDialogCloseMsg:
|
||||
p.showCompletionDialog = false
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
_, cmd := p.editor.Update(msg)
|
||||
if cmd != nil {
|
||||
return p, cmd
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, keyMap.ShowCompletionDialog):
|
||||
p.showCompletionDialog = true
|
||||
// Continue sending keys to layout->chat
|
||||
case key.Matches(msg, keyMap.Cancel):
|
||||
if p.app.Session.Id != "" {
|
||||
// Cancel the current session's generation process
|
||||
// This allows users to interrupt long-running operations
|
||||
p.app.Cancel(context.Background(), p.app.Session.Id)
|
||||
return p, nil
|
||||
}
|
||||
case key.Matches(msg, keyMap.ToggleTools):
|
||||
return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
|
||||
}
|
||||
}
|
||||
|
||||
if p.showCompletionDialog {
|
||||
// Get the current text from the editor to determine which provider to use
|
||||
editorModel := p.editor.GetContent().(interface{ GetValue() string })
|
||||
currentInput := editorModel.GetValue()
|
||||
|
||||
provider := p.completionManager.GetProvider(currentInput)
|
||||
p.completionDialog.SetProvider(provider)
|
||||
|
||||
context, contextCmd := p.completionDialog.Update(msg)
|
||||
p.completionDialog = context.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, contextCmd)
|
||||
|
||||
// Doesn't forward event if enter key is pressed and there are completions
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
if keyMsg.String() == "enter" { // && !p.completionDialog.IsEmpty() {
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u, cmd := p.layout.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
p.layout = u.(layout.FlexLayout)
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *chatPage) sendMessage(text string, attachments []app.Attachment) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
cmd := p.app.SendChatMessage(context.Background(), text, attachments)
|
||||
cmds = append(cmds, cmd)
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (p *chatPage) SetSize(width, height int) tea.Cmd {
|
||||
return p.layout.SetSize(width, height)
|
||||
}
|
||||
|
||||
func (p *chatPage) GetSize() (int, int) {
|
||||
return p.layout.GetSize()
|
||||
}
|
||||
|
||||
func (p *chatPage) View() string {
|
||||
layoutView := p.layout.View()
|
||||
|
||||
if p.showCompletionDialog {
|
||||
editorWidth, _ := p.editor.GetSize()
|
||||
editorX, editorY := p.editor.GetPosition()
|
||||
|
||||
p.completionDialog.SetWidth(editorWidth)
|
||||
overlay := p.completionDialog.View()
|
||||
|
||||
layoutView = layout.PlaceOverlay(
|
||||
editorX,
|
||||
editorY-lipgloss.Height(overlay)+2,
|
||||
overlay,
|
||||
layoutView,
|
||||
)
|
||||
}
|
||||
|
||||
return layoutView
|
||||
}
|
||||
|
||||
func NewChatPage(app *app.App) layout.ModelWithView {
|
||||
completionManager := completions.NewCompletionManager(app)
|
||||
initialProvider := completionManager.GetProvider("")
|
||||
completionDialog := dialog.NewCompletionDialogComponent(initialProvider)
|
||||
|
||||
messagesContainer := layout.NewContainer(
|
||||
chat.NewMessagesComponent(app),
|
||||
)
|
||||
editorContainer := layout.NewContainer(
|
||||
chat.NewEditorComponent(app),
|
||||
layout.WithMaxWidth(layout.Current.Container.Width),
|
||||
layout.WithAlignCenter(),
|
||||
)
|
||||
|
||||
return &chatPage{
|
||||
app: app,
|
||||
editor: editorContainer,
|
||||
messages: messagesContainer,
|
||||
completionDialog: completionDialog,
|
||||
completionManager: completionManager,
|
||||
layout: layout.NewFlexLayout(
|
||||
layout.WithPanes(messagesContainer, editorContainer),
|
||||
layout.WithDirection(layout.FlexDirectionVertical),
|
||||
layout.WithPaneSizes(
|
||||
layout.FlexPaneSizeGrow,
|
||||
layout.FlexPaneSizeFixed(6),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user